From 5b751ea7de280d12731bc940eac46431c62a6408 Mon Sep 17 00:00:00 2001 From: Alfredo Bonilla Date: Sat, 22 Feb 2025 17:12:13 -0600 Subject: [PATCH] feat: fix user routes --- .../migration.sql | 13 ++ apps/web/prisma/schema.prisma | 16 +++ apps/web/public/favicon.ico | Bin 15406 -> 15406 bytes apps/web/public/locales/en/common.json | 36 ++++- apps/web/public/locales/es/common.json | 37 +++++- apps/web/public/locales/pt/common.json | 24 +++- .../app/_components/features/FilterModal.tsx | 29 ++++- .../_components/features/ProductDetails.tsx | 30 ++++- .../features/ProfileOptionLayout.tsx | 2 +- .../app/_components/features/SearchBar.tsx | 45 ++++++- .../web/src/app/_components/layout/Header.tsx | 10 +- apps/web/src/app/marketplace/page.tsx | 12 +- apps/web/src/app/product/[id]/page.tsx | 97 +++++++++++++- apps/web/src/app/user-profile/page.tsx | 17 ++- .../app/user/edit-profile/my-profile/page.tsx | 5 +- apps/web/src/app/user/favorites/page.tsx | 113 +++++++++++----- .../web/src/app/user/register-coffee/page.tsx | 34 +++-- apps/web/src/app/user/settings/page.tsx | 123 ++++++++---------- apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/favorites.ts | 105 +++++++++++++++ .../src/server/api/routers/mockProducts.ts | 121 ----------------- apps/web/src/server/api/routers/product.ts | 98 ++++++++------ apps/web/src/services/contractsInterface.ts | 21 +++ packages/ui/src/cartContent.tsx | 51 ++++---- packages/ui/src/cartSidebar.tsx | 28 +++- packages/ui/src/form/radioButton.tsx | 5 +- 26 files changed, 732 insertions(+), 342 deletions(-) create mode 100644 apps/web/prisma/migrations/20250222223115_add_favorites/migration.sql create mode 100644 apps/web/src/server/api/routers/favorites.ts delete mode 100644 apps/web/src/server/api/routers/mockProducts.ts diff --git a/apps/web/prisma/migrations/20250222223115_add_favorites/migration.sql b/apps/web/prisma/migrations/20250222223115_add_favorites/migration.sql new file mode 100644 index 0000000..2f14000 --- /dev/null +++ b/apps/web/prisma/migrations/20250222223115_add_favorites/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE `Favorite` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `productId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `Favorite_userId_idx`(`userId`), + INDEX `Favorite_productId_idx`(`productId`), + UNIQUE INDEX `Favorite_userId_productId_key`(`userId`, `productId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index dac04c2..087331c 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -43,6 +43,7 @@ model Product { updatedAt DateTime @updatedAt shoppingCartItems ShoppingCartItem[] orderItems OrderItem[] + favorites Favorite[] @@index([name]) } @@ -137,6 +138,7 @@ model User { sessions Session[] farms Farm[] password String? + favorites Favorite[] } model Farm { @@ -154,6 +156,20 @@ model Farm { updatedAt DateTime @updatedAt } +model Favorite { + id String @id @default(cuid()) + userId String + productId Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + product Product @relation(fields: [productId], references: [id]) + + @@unique([userId, productId]) + @@index([userId]) + @@index([productId]) +} + model VerificationToken { identifier String token String @unique diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 60c702aac13409c82c4040f8fee9603eb84aa10c..e221bb4f2943b068f8207b50c4b8bab39737276f 100644 GIT binary patch literal 15406 zcmeHO_jgoBme!u#KVbHU{cVs0MqovdP(mh4M3DtTu_a-4?Aar!mB9p)p#fuqjSU8q zOvVHgjF2z_4EBzf8K*VIgKSI=A_tM-?)TMGb-(U@EvaYbw;j%@^PXN;)&1($t()o+ z5b!Sn{~GYS-v!_q9Ps+T1q8$g1Ox;J8-Kt2?*Rd;@U3&_rr$dR1bp=$0Rb~1r(4iOg?G704&b~)w=h(8)hW?8u z%kPiAK$E8SLi)WsO>!r6l{QD7N&KpNGP>-dWENeJLDL%~r244@9eOTpj>&P3FDMI> zt{-o9j!#&(P56!DLw7w?<1z~`h;Ab$T$YptH{_)Q&zi=EN?v7WiTLzev~Tbpjq#ys zeEh0A&T)EtM)3t1Jgq@GR6li&XMbB&oaB}FQDgP^h;5Bt<1-7-OYcQjP549A7@K{f zgH9pt=K7OB%@!@I?&A+%cck$gJ*>9vs z@&|Jzq~@aH75P_C5!H}i5h=E^*Cey}2jSXsN~a0G<-E~Nf)Cx7wg>M>ToOOJD`B5LmLUu6$AP(FRK*3!FY95@LBIU6 z?!s^JYc4g>50m`T!IC!34w-3C_~Y09D0$=Kj)(4g)G&0?W&GaPHNG@OhT&V(wg!xk zk{tUQ8C8BsV%Ofq9MfgwtSrb%7a3a-ZmfUl9my`P#vDTwo$TU$((MCd{kF3IRC`Z; zq*YzQI`=y1=<#uD?n^xQI&9Jv89wofIxlf+?kWD|mQGjWd%SmBJ#+2L-Nv^$DqrjK z7_se9eZ=-h&U4)E;O+Xnvb6fNstff4=3J}KD*UOwpe&_+Yn3`H0^u<%Z#+m5^9u+&*d?I^d59zq?v{TQx^p)0I z?$40@=OuG;vShqDPy!D>b{iLXelXqpj%&9eON-r#7^T zK6O>6%@35#jah%+Cw`%xZMFaA`3uTAi_JdCji&i`O`kmF zd21QNZ<1AXLON}|*$VpMpgjdPW<`tZ4}vTYgMF~3{~K&jt=LMYzy_sCUPYY5Z`ji! z{k)O_$tpYzza_85^^=#0EAP3{&nm2kOFvUYX1PMF7XOxw?&sTTY3ePPpo zw>@~@jZXOPAH@cptg)M)wCO2t|4I7Kx$cxVuD{dvuOxe7Pa_tnj9CU>`GhB5_;=gS zpDUk>HiNp#s!zx2Gw^>qVPCvuGkNKJ>AdLy?9F8r)0oFAAGthSUag35(vSS)p(L)j zE8#mHDZio5+c&ZQhE7Rdh?oYxarn+h%Ewjq92kAlhfm#k+cD)6Q`d4{Gky9jqo)mb zqaU%oDV}Qb8#0T|LjJ!~jqlp0pq`7|27jUj`VqUnQ+>Fuw8~4${_7ZnW0OB{xY6hN zO)oWUIQ``O(sb}OR<-=nQP5%g6hG2o=i7l7&C9cMw?kgvSb0@X*wxERwMea2<68$SaN+YG+|S&~+q^pJnVgQc0UTQkw7N%r_GiQBMS0*@H>mU|X-s8NPE z-UN*>MIW*D39cstYtJnF4deUyreB(@Y!dtSop(c`wga#8{3St0Zo?es6uyBouSw)5 zcaU$GBO_;KN}r_*A#?K}_d^wJo>_hVD3>wo8ilfDE1B$*zO7`E>eB@}iDO*+k8#Q; z`PkQSL+OvMh+jueIPG&yn!dr8=SsG+ivAbEV*(3hz=t6CRuJl|81AEGX0pXiMbOy@rVeEHrpkkQ?}@PSZ)5#&p#Qa#K6UeO=v>le z%PKkv+TB~E&-u)aF%oUp0NO7fF{IxAT>CObYgC01SGah84!k$cny>vw47}VN6Pw?| zc0H~R+x^&QeBE*Xnd`x|N3& z`N|9YfBDWl2~xBH`Do4MNeaqC$Bmqw<>t?xc?R(&nC-TbbwURR-N##S3t8Q^l>TKpo zT1$id;2$=&`BDmqpJyoNjUNt%`9&Ep`?_?2&844hNvBC4TH$97Xvn{zRLnIlhq%Z@x!q)+^` z0rb@vQ@eDjq0hBm=aaTJ8F}jv*bQHDWrd%7BeU`#5H{D}c z@t-nI|0E5zz>L+`JJ&a0_BCa*qu{G|+T5t_Vf1=@;WyS0E!`1s@=m}CulXCXcU`+# zY?U!izFXmEE{}HJ7hbFH^an>F#$axTz5?^*D!&C8Yz{x^XHM#aczHke&*h_(&{f@KDb@W4g$5`DT z{KS-n^PKutsboyu0pT$neSEAVxD{Hvjn_gP3Uw=J^xwyL=pt)#|D7(^hcbTX!GAc>OeTO5Y=X-iy4$ zbnJOL@-In{;l2{N^V`2cemY^E35eByKn{E@WQX~)=4GG1oAGlGsK=@QtmTNfPXY68 z(3%O%qd|lB^SGCTeMz4>Sm_Yf7bx55hyLMRe0I?>;6H=8Ct^7 zIj9G}4+3`?=5*ab(rz*y8Ri%#v74&lhc!BF59jfh|HPY9yb}0vmQ)UCI_PypYhH#t z`A->Qtit;ko<|#MTa3J-;eXH`W=>9#0gEQW_o`8L-&ZW+5C18HHv25#Khw1Lr?8&2 zILAhuLI*|t^1C_k#(%9Zf9epdHMzHs};fAS>}`}+sT z|6wIRF4;YgZ=11(UOxB13crd2Hhrn$q(KYIl%LL=eA~L)kPG*mu*PcjY?=QN;87;{ zyh+K73%6N+|AKv?ELqz#EBwSlzngj_@W^Alw;r>39U4DsdTM!B)I$rFFn&g1BlhZ?&C<8E$3d}Cf!Tmkl>*0ZnG z->eTAJoTz-9r13}$Maj%&Oak2_Kqt(&pY!iaT^c7ufL}J)F{+oc+SPT#LnBkz+LqK zb>7C7g*A?=h-?-9KkAf98DPewof7L0Z9j*eqER^P#KEv>?j zIJM`8OVl}Tk2+7@OAf(4``TmP38&3S7c2a%6Qz&Edqv(0@cyXBd&o0QhRuinYSuyg z#@|v9%dy6k{ypn1^&Lj*@rUD1ahJkR`<8$`AbdnzeRg0CyazY(9OiS^D*W)FhR@AMe&D(rkC`uF+{rtuA#Ywa>RX%Y4I-gS zb0+ri#t&P_J1N>I`d75Sp0yz^{Pf+zl%JkmbX@5Tjy2oU*5c1b-Ej2QI>?D@545~c zhfpu@p1u2$+rqtx-GqDm%4j$I)FZ4X9yZCS8OyaV1-|CTqRU4ymdp>D@c!wFk`nsed9?<$H@aT(L=HPM1Wy&#~0bdIgf8F_Q zos8+8Dh{~uHrwX zgK+Mtvpa1@eLl{eyZlqm(xI<-Cx6z7opz6R8nofGeTl2^+rNu1i zf;ci}9dtX+J9SLzw5#yTFG#k13+VD(nCZB`d*KH?(j_mbKbgy5ZKPFwpKIP|^qGXd ze{IsAtp9M?N*Dep$ogXRGiou^8G)Sf|29w`(%)o_k+%C;MSq5z4f7j6ZALfz-vXXL z&Xra44dSbrm@5Ty@m|ZQd*nOgMw@+x>Z9-=x5{&5WqA<>ga5Hg z*JKs`?ADjIg7=X8za(beXW;)CV{-k*PydbHFEWbv;a{9mgH{Rnd;I6`_u?Wyy{#`w>Ta*+mE95eLqf o19x$btRmfhazMACZ=}(mW14R>{!keegYuw%{`)5f{$>vRALa~Fxc~qF literal 15406 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 A#Q*>R diff --git a/apps/web/public/locales/en/common.json b/apps/web/public/locales/en/common.json index 523f35b..66e454e 100644 --- a/apps/web/public/locales/en/common.json +++ b/apps/web/public/locales/en/common.json @@ -129,6 +129,7 @@ "chat_with_seller": "Chat with the seller", "bags_available": "Bags Available", "bags": "bags", + "bags_available_count": "{{count}} bags available", "unit_price": "Unit price ({{weight}})", "sold_out": "SOLD OUT", "add_to_favorites": "Add to favorites", @@ -211,6 +212,7 @@ "contributor": "Contributor", "producer": "Producer", "united_states": "United States", + "costa_rica": "Costa Rica", "since": "Since", "edit_my_profile": "Edit my profile", @@ -315,5 +317,37 @@ "your_coffee": "Your Coffee", "delivery_information": "Delivery Information", - "shipping_to": "Shipping to" + "shipping_to": "Shipping to", + + "badges": { + "lover": "Coffee Lover", + "contributor": "Contributor", + "producer": "Producer" + }, + + "update_status": "Update Status", + "status_updated": "Status updated successfully", + + "cart": { + "empty_message": "Your cart is empty", + "quantity_label": "Quantity", + "remove_confirmation_title": "Do you want to remove this item?", + "remove_confirmation_yes": "Yes, remove", + "cancel": "Cancel", + "total": "Total", + "checkout": "Checkout" + }, + + "added_to_favorites": "Added to favorites", + "error_adding_to_favorites": "Error adding to favorites", + "removed_from_favorites": "Removed from favorites", + "error_removing_from_favorites": "Error removing from favorites", + "error_updating_favorites": "Error updating favorites", + "please_connect_to_favorite": "Please connect to add to favorites", + "loading_strk_price": "Loading STRK price...", + "connect_to_buy": "Connect to buy", + "share_product": "Share product", + "farm_details": "Farm details", + "no_favorite_products": "No favorite products yet", + "click_to_upload": "Click to upload" } diff --git a/apps/web/public/locales/es/common.json b/apps/web/public/locales/es/common.json index 8abf835..aa1faff 100644 --- a/apps/web/public/locales/es/common.json +++ b/apps/web/public/locales/es/common.json @@ -121,6 +121,7 @@ "chat_with_seller": "Chatear con el vendedor", "bags_available": "Bolsas disponibles", "bags": "bolsas", + "bags_available_count": "{{count}} bolsas disponibles", "unit_price": "Precio por unidad ({{weight}})", "sold_out": "AGOTADO", "add_to_favorites": "Agregar a favoritos", @@ -203,6 +204,7 @@ "contributor": "Contribuyente", "producer": "Productor", "united_states": "Estados Unidos", + "costa_rica": "Costa Rica", "since": "Desde", "edit_my_profile": "Editar mi perfil", @@ -280,5 +282,38 @@ "en": "Inglés", "es": "Español", "pt": "Portugués" - } + }, + + "badges": { + "lover": "Amante del Café", + "contributor": "Contribuidor", + "producer": "Productor" + }, + + "update_status": "Actualizar Estado", + "status_updated": "Estado actualizado con éxito", + + "cart": { + "empty_message": "Tu carrito está vacío", + "quantity_label": "Cantidad", + "remove_confirmation_title": "¿Deseas eliminar este artículo?", + "remove_confirmation_yes": "Sí, eliminar", + "cancel": "Cancelar", + "total": "Total", + "checkout": "Pagar" + }, + + "added_to_favorites": "Agregado a favoritos", + "error_adding_to_favorites": "Error al agregar a favoritos", + "removed_from_favorites": "Eliminado de favoritos", + "error_removing_from_favorites": "Error al eliminar de favoritos", + "error_updating_favorites": "Error al actualizar favoritos", + "please_connect_to_favorite": "Por favor conéctate para agregar a favoritos", + "loading_strk_price": "Cargando precio en STRK...", + "connect_to_buy": "Conéctate para comprar", + "marketplace": "Mercado", + "share_product": "Compartir producto", + "farm_details": "Detalles de la granja", + "no_favorite_products": "Aún no hay productos favoritos", + "click_to_upload": "Haz clic para subir" } diff --git a/apps/web/public/locales/pt/common.json b/apps/web/public/locales/pt/common.json index f2fcf4d..0aee4e4 100644 --- a/apps/web/public/locales/pt/common.json +++ b/apps/web/public/locales/pt/common.json @@ -121,6 +121,7 @@ "chat_with_seller": "Conversar com o vendedor", "bags_available": "Sacos disponíveis", "bags": "sacos", + "bags_available_count": "{{count}} sacos disponíveis", "unit_price": "Preço por unidade ({{weight}})", "sold_out": "ESGOTADO", "add_to_favorites": "Adicionar aos favoritos", @@ -203,6 +204,7 @@ "contributor": "Contribuidor", "producer": "Produtor", "united_states": "Estados Unidos", + "costa_rica": "Costa Rica", "since": "Desde", "edit_my_profile": "Editar meu perfil", @@ -280,5 +282,25 @@ "en": "Inglês", "es": "Espanhol", "pt": "Português" - } + }, + + "badges": { + "lover": "Amante do Café", + "contributor": "Contribuidor", + "producer": "Produtor" + }, + + "update_status": "Atualizar Status", + "status_updated": "Status atualizado com sucesso", + + "cart": { + "empty_message": "Seu carrinho está vazio", + "quantity_label": "Quantidade", + "remove_confirmation_title": "Deseja remover este item?", + "remove_confirmation_yes": "Sim, remover", + "cancel": "Cancelar", + "total": "Total", + "checkout": "Finalizar compra" + }, + "click_to_upload": "Clique para fazer upload" } diff --git a/apps/web/src/app/_components/features/FilterModal.tsx b/apps/web/src/app/_components/features/FilterModal.tsx index 18f10d7..62d1e81 100644 --- a/apps/web/src/app/_components/features/FilterModal.tsx +++ b/apps/web/src/app/_components/features/FilterModal.tsx @@ -15,6 +15,15 @@ interface FilterModalProps { onClose: () => void; } +interface ProductMetadata { + region?: string; + farmName?: string; + strength?: string; + imageUrl?: string; + imageAlt?: string; + description?: string; +} + export default function FilterModal({ isOpen, onClose }: FilterModalProps) { const { t } = useTranslation(); const [selectedStrength, setSelectedStrength] = useState(""); @@ -41,10 +50,22 @@ export default function FilterModal({ isOpen, onClose }: FilterModalProps) { const { data } = await refetch(); if (data?.productsFound) { - const products = data.productsFound.map((product) => ({ - ...product, - region: product.region, - })); + const products = data.productsFound.map((product) => { + const metadata: ProductMetadata = + typeof product.nftMetadata === "string" + ? (JSON.parse(product.nftMetadata) as ProductMetadata) + : (product.nftMetadata as ProductMetadata); + return { + ...product, + nftMetadata: + typeof product.nftMetadata === "string" + ? product.nftMetadata + : JSON.stringify(product.nftMetadata), + region: metadata.region ?? "", + farmName: metadata.farmName ?? "", + strength: metadata.strength ?? "", + }; + }); setSearchResults(products); setQuantityProducts(products.length); } else { diff --git a/apps/web/src/app/_components/features/ProductDetails.tsx b/apps/web/src/app/_components/features/ProductDetails.tsx index e57d77d..f39c139 100644 --- a/apps/web/src/app/_components/features/ProductDetails.tsx +++ b/apps/web/src/app/_components/features/ProductDetails.tsx @@ -3,6 +3,7 @@ import { HeartIcon as HeartSolidIcon } from "@heroicons/react/24/solid"; import Button from "@repo/ui/button"; import { DataCard } from "@repo/ui/dataCard"; import { H2, Text } from "@repo/ui/typography"; +import { useSession } from "next-auth/react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -30,6 +31,9 @@ interface ProductDetailsProps { }; isConnected?: boolean; onConnect?: () => void; + isFavorited?: boolean; + onToggleFavorite?: () => void; + isLoadingFavorite?: boolean; } interface CoinGeckoResponse { @@ -59,6 +63,9 @@ export default function ProductDetails({ product, isConnected, onConnect, + isFavorited, + onToggleFavorite, + isLoadingFavorite, }: ProductDetailsProps) { const { image, @@ -75,7 +82,6 @@ export default function ProductDetails({ const { t } = useTranslation(); const [quantity, setQuantity] = useState(1); - const [isLiked, setIsLiked] = useState(false); const [selectedImage, setSelectedImage] = useState(0); const [strkPrice, setStrkPrice] = useState(null); const [isLoadingPrice, setIsLoadingPrice] = useState(false); @@ -87,6 +93,7 @@ export default function ProductDetails({ void refetchCart(); }, }); + const { data: session } = useSession(); useEffect(() => { const fetchStrkPrice = async () => { @@ -160,6 +167,18 @@ export default function ProductDetails({ } }; + const handleFavoriteClick = () => { + console.log("Favorite clicked", { isConnected, isFavorited, session }); + if (!session) { + // If not authenticated, trigger connect + onConnect?.(); + return; + } + if (onToggleFavorite) { + onToggleFavorite(); + } + }; + return (
{/* Navigation Bar */} @@ -205,13 +224,16 @@ export default function ProductDetails({ + ))}
- - -

- {t("language")} -

- -
- {languages.map((lang, index) => ( -
- - {index < languages.length - 1 && ( -
- )} -
- ))} -
- - -
); diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index b021ed5..aca745d 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -1,4 +1,5 @@ import { cartRouter } from "~/server/api/routers/cart"; +import { favoritesRouter } from "~/server/api/routers/favorites"; import { orderRouter } from "~/server/api/routers/order"; import { producerRouter } from "~/server/api/routers/producer"; import { productRouter } from "~/server/api/routers/product"; @@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({ product: productRouter, user: userRouter, cart: cartRouter, + favorites: favoritesRouter, producer: producerRouter, order: orderRouter, }); diff --git a/apps/web/src/server/api/routers/favorites.ts b/apps/web/src/server/api/routers/favorites.ts new file mode 100644 index 0000000..0a383f6 --- /dev/null +++ b/apps/web/src/server/api/routers/favorites.ts @@ -0,0 +1,105 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; + +export const favoritesRouter = createTRPCRouter({ + // Get user's favorites + getUserFavorites: protectedProcedure.query(async ({ ctx }) => { + try { + return await ctx.db.favorite.findMany({ + where: { userId: ctx.session.user.id }, + include: { + product: true, + }, + }); + } catch (error) { + console.error("Error getting favorites:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to get favorites", + }); + } + }), + + // Add to favorites + addToFavorites: protectedProcedure + .input(z.object({ productId: z.number() })) + .mutation(async ({ ctx, input }) => { + try { + // First check if the product exists + const product = await ctx.db.product.findUnique({ + where: { id: input.productId }, + }); + + if (!product) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Product not found", + }); + } + + // Then try to create the favorite + return await ctx.db.favorite.create({ + data: { + userId: ctx.session.user.id, + productId: input.productId, + }, + include: { + product: true, + }, + }); + } catch (error) { + console.error("Error adding to favorites:", error); + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to add to favorites", + }); + } + }), + + // Remove from favorites + removeFromFavorites: protectedProcedure + .input(z.object({ productId: z.number() })) + .mutation(async ({ ctx, input }) => { + try { + return await ctx.db.favorite.delete({ + where: { + userId_productId: { + userId: ctx.session.user.id, + productId: input.productId, + }, + }, + }); + } catch (error) { + console.error("Error removing from favorites:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to remove from favorites", + }); + } + }), + + // Check if product is favorited + isProductFavorited: protectedProcedure + .input(z.object({ productId: z.number() })) + .query(async ({ ctx, input }) => { + try { + const favorite = await ctx.db.favorite.findUnique({ + where: { + userId_productId: { + userId: ctx.session.user.id, + productId: input.productId, + }, + }, + }); + return !!favorite; + } catch (error) { + console.error("Error checking favorite status:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to check favorite status", + }); + } + }), +}); diff --git a/apps/web/src/server/api/routers/mockProducts.ts b/apps/web/src/server/api/routers/mockProducts.ts deleted file mode 100644 index 14b67d1..0000000 --- a/apps/web/src/server/api/routers/mockProducts.ts +++ /dev/null @@ -1,121 +0,0 @@ -// src/server/api/routers/mockProducts.ts - -// TODO: Replace this mocked data with real data fetched from the blockchain in the near future. - -export const mockedProducts = [ - { - id: 1, - tokenId: 1, - name: "product_name_1", - price: 15.99, - region: "Alajuela", - farmName: "Beneficio Las Peñas", - strength: "light", - process: "honey", - nftMetadata: JSON.stringify({ - description: "product_description_1", - imageUrl: "/images/cafe1.webp", - imageAlt: "product_image_alt_1", - }), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 2, - tokenId: 2, - name: "product_name_2", - price: 17.99, - region: "Cartago", - farmName: "Beneficio Las Nubes", - strength: "medium", - process: "washed", - nftMetadata: JSON.stringify({ - description: "product_description_2", - imageUrl: "/images/cafe2.webp", - imageAlt: "product_image_alt_2", - }), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 3, - tokenId: 3, - name: "product_name_3", - price: 19.99, - region: "Heredia", - farmName: "Beneficio Monteverde", - strength: "strong", - process: "natural", - nftMetadata: JSON.stringify({ - description: "product_description_3", - imageUrl: "/images/cafe3.webp", - imageAlt: "product_image_alt_3", - }), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 4, - tokenId: 4, - name: "product_name_4", - price: 16.99, - region: "Guanacaste", - farmName: "Finca Santa Rosa", - strength: "light", - nftMetadata: JSON.stringify({ - description: "product_description_4", - imageUrl: "/images/cafe4.webp", - imageAlt: "product_image_alt_4", - }), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 5, - tokenId: 5, - name: "product_name_5", - price: 18.99, - region: "Puntarenas", - farmName: "Finca El Mirador", - strength: "medium", - nftMetadata: JSON.stringify({ - description: "product_description_5", - imageUrl: "/images/cafe5.webp", - imageAlt: "product_image_alt_5", - }), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 6, - tokenId: 6, - name: "product_name_6", - price: 20.99, - region: "San José", - farmName: "Finca La Esperanza", - strength: "extra_strong", - nftMetadata: JSON.stringify({ - description: "product_description_6", - imageUrl: "/images/cafe6.webp", - imageAlt: "product_image_alt_6", - }), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 7, - tokenId: 7, - name: "product_name_7", - price: 14.99, - region: "Limón", - farmName: "Finca El Caribe", - strength: "mild", - nftMetadata: JSON.stringify({ - description: "product_description_7", - imageUrl: "/images/cafe7.webp", - imageAlt: "product_image_alt_7", - }), - createdAt: new Date(), - updatedAt: new Date(), - }, -]; diff --git a/apps/web/src/server/api/routers/product.ts b/apps/web/src/server/api/routers/product.ts index 84e79fb..88ea3e0 100755 --- a/apps/web/src/server/api/routers/product.ts +++ b/apps/web/src/server/api/routers/product.ts @@ -1,9 +1,15 @@ +// TODO: Implement blockchain sync mechanism to keep database in sync with on-chain data +// This should include: +// 1. Listening to blockchain events for product updates +// 2. Syncing product availability/stock from the blockchain +// 3. Updating prices based on blockchain data +// 4. Handling blockchain transaction confirmations + +import type { Prisma } from "@prisma/client"; import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { db } from "~/server/db"; -import { mockedProducts } from "./mockProducts"; -// TODO: Replace mockedProducts with real data fetched from the blockchain in the near future. const normalizeText = (text: string): string => { return text .toLowerCase() @@ -22,15 +28,13 @@ export const productRouter = createTRPCRouter({ .query(async ({ input }) => { const { limit, cursor } = input; - // Fetch products from the database using Prisma const products = await db.product.findMany({ - take: limit, // Limit the number of products - skip: cursor ? 1 : 0, // Skip if cursor is provided - cursor: cursor ? { id: cursor } : undefined, // Cursor-based pagination - orderBy: { id: "asc" }, // Order products by ID ascending + take: limit, + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + orderBy: { id: "asc" }, }); - // Determine next cursor for pagination const nextCursor = products.length === limit ? products[products.length - 1]?.id : null; @@ -129,25 +133,36 @@ export const productRouter = createTRPCRouter({ region: z.string(), }), ) - .query(({ input }) => { + .query(async ({ input }) => { const { region } = input; - const normalizedSearchTerm = normalizeText(region); - const productsFound = mockedProducts.filter((product) => { - const normalizedRegion = normalizeText(product.region); - const normalizedName = normalizeText(product.name); - const normalizedFarmName = normalizeText(product.farmName); - - return ( - normalizedRegion.includes(normalizedSearchTerm) || - normalizedName.includes(normalizedSearchTerm) || - normalizedFarmName.includes(normalizedSearchTerm) - ); + const products = await db.product.findMany({ + where: { + OR: [ + { + nftMetadata: { + path: "$.region", + string_contains: normalizedSearchTerm, + }, + }, + { + name: { + contains: normalizedSearchTerm, + }, + }, + { + nftMetadata: { + path: "$.farmName", + string_contains: normalizedSearchTerm, + }, + }, + ], + }, }); return { - productsFound, + productsFound: products, }; }), @@ -159,35 +174,42 @@ export const productRouter = createTRPCRouter({ orderBy: z.string().optional(), }), ) - .query(({ input }) => { + .query(async ({ input }) => { const { strength, region, orderBy } = input; - let filteredProducts = [...mockedProducts]; + + const conditions: Prisma.ProductWhereInput[] = []; if (strength) { const normalizedStrength = normalizeText(strength); - filteredProducts = filteredProducts.filter( - (product) => normalizeText(product.strength) === normalizedStrength, - ); + conditions.push({ + nftMetadata: { + path: "$.strength", + string_contains: normalizedStrength, + }, + }); } if (region) { const normalizedRegion = normalizeText(region); - filteredProducts = filteredProducts.filter( - (product) => normalizeText(product.region) === normalizedRegion, - ); - } - - if (orderBy) { - filteredProducts.sort((a, b) => { - if (orderBy === "Highest price") { - return b.price - a.price; - } - return a.price - b.price; + conditions.push({ + nftMetadata: { + path: "$.region", + string_contains: normalizedRegion, + }, }); } + const products = await db.product.findMany({ + where: conditions.length > 0 ? { AND: conditions } : undefined, + orderBy: orderBy + ? { + price: orderBy === "Highest price" ? "desc" : "asc", + } + : undefined, + }); + return { - productsFound: filteredProducts, + productsFound: products, }; }), }); diff --git a/apps/web/src/services/contractsInterface.ts b/apps/web/src/services/contractsInterface.ts index edf48e0..7eb4154 100644 --- a/apps/web/src/services/contractsInterface.ts +++ b/apps/web/src/services/contractsInterface.ts @@ -336,6 +336,27 @@ class ContractsInterface { throw error; } } + + async get_product_stock(tokenId: number) { + // connect user account to contracts + this.connect_account(); + + // Call the contract to get the stock + if (!this.marketplaceContract) { + throw new Error("Marketplace contract is not loaded"); + } + + try { + const stock = await this.marketplaceContract.call( + "listed_product_stock", + [tokenId, "0x0"], + ); + return Number(stock); + } catch (error) { + console.error("Error getting product stock:", error); + return 0; + } + } } export { diff --git a/packages/ui/src/cartContent.tsx b/packages/ui/src/cartContent.tsx index b89efe4..4fe5819 100644 --- a/packages/ui/src/cartContent.tsx +++ b/packages/ui/src/cartContent.tsx @@ -122,39 +122,44 @@ export function CartContent({ {items.map((item) => (
-
+
{item.product.name} -
- +
+ {item.quantity} +
+
+
+
+ {item.product.name} - - {translations.quantityLabel}: {item.quantity} + + ${(item.product.price * item.quantity).toFixed(2)}
-
-
- - {item.product.price * item.quantity} USD - - {onRemoveItem && ( - - )} +
+ + ${item.product.price.toFixed(2)} each + + {onRemoveItem && ( + + )} +
))} diff --git a/packages/ui/src/cartSidebar.tsx b/packages/ui/src/cartSidebar.tsx index f2d9979..7bf0849 100644 --- a/packages/ui/src/cartSidebar.tsx +++ b/packages/ui/src/cartSidebar.tsx @@ -1,6 +1,7 @@ "use client"; import Button from "./button"; +import { Separator } from "./separator"; import { Sidebar } from "./sidebar"; import { Text } from "./typography"; @@ -24,12 +25,27 @@ export function CartSidebar({ checkoutLabel = "Checkout", }: CartSidebarProps) { const footer = totalPrice !== undefined && onCheckout && ( -
-
- Total - ${totalPrice.toFixed(2)} USD +
+ +
+
+ Subtotal + + ${totalPrice.toFixed(2)} USD + +
+
+ Total + + ${totalPrice.toFixed(2)} USD + +
-
@@ -37,7 +53,7 @@ export function CartSidebar({ return ( -
{children}
+
{children}
); } diff --git a/packages/ui/src/form/radioButton.tsx b/packages/ui/src/form/radioButton.tsx index 043a4fe..a0a5d0b 100644 --- a/packages/ui/src/form/radioButton.tsx +++ b/packages/ui/src/form/radioButton.tsx @@ -55,10 +55,7 @@ function RadioButton({ onChange={handleChange} onKeyPress={handleKeyPress} aria-checked={field.value === value} - className={cn( - "h-5 w-5 cursor-pointer rounded-full border border-surface-border bg-white", - field.value === value && "bg-surface-primary-default", - )} + className="sr-only" />