From d3932fedf8d7cc567fced52d7f074154dbd7e572 Mon Sep 17 00:00:00 2001 From: Abdul Haseeb Date: Wed, 22 Jul 2026 00:16:36 +0500 Subject: [PATCH 1/9] Add wishlist support --- e2e/wishlist.spec.ts | 83 +++++++ messages/de.json | 30 ++- messages/en.json | 30 ++- messages/es.json | 62 +++-- messages/fr.json | 38 ++- messages/pl.json | 30 ++- .../[locale]/(storefront)/account/layout.tsx | 2 + .../[locale]/(storefront)/account/page.tsx | 19 ++ .../(storefront)/account/wishlist/page.tsx | 5 + .../products/[slug]/ProductDetails.tsx | 14 ++ .../[locale]/(storefront)/wishlist/page.tsx | 15 ++ src/app/[country]/[locale]/layout.tsx | 15 +- src/components/layout/Footer.tsx | 8 + src/components/layout/Header.tsx | 4 + src/components/layout/MobileMenu.tsx | 35 ++- src/components/layout/WishlistNavButton.tsx | 29 +++ src/components/products/ProductCard.tsx | 93 ++++---- .../products/__tests__/ProductCard.test.tsx | 4 + src/components/wishlist/WishlistButton.tsx | 92 ++++++++ .../wishlist/WishlistPageContent.tsx | 221 ++++++++++++++++++ .../__tests__/WishlistButton.test.tsx | 85 +++++++ src/contexts/WishlistContext.tsx | 191 +++++++++++++++ .../__tests__/WishlistContext.test.tsx | 135 +++++++++++ src/lib/data/__tests__/wishlist.test.ts | 129 ++++++++++ src/lib/data/index.ts | 1 + src/lib/data/wishlist.ts | 106 +++++++++ 26 files changed, 1385 insertions(+), 91 deletions(-) create mode 100644 e2e/wishlist.spec.ts create mode 100644 src/app/[country]/[locale]/(storefront)/account/wishlist/page.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/wishlist/page.tsx create mode 100644 src/components/layout/WishlistNavButton.tsx create mode 100644 src/components/wishlist/WishlistButton.tsx create mode 100644 src/components/wishlist/WishlistPageContent.tsx create mode 100644 src/components/wishlist/__tests__/WishlistButton.test.tsx create mode 100644 src/contexts/WishlistContext.tsx create mode 100644 src/contexts/__tests__/WishlistContext.test.tsx create mode 100644 src/lib/data/__tests__/wishlist.test.ts create mode 100644 src/lib/data/wishlist.ts diff --git a/e2e/wishlist.spec.ts b/e2e/wishlist.spec.ts new file mode 100644 index 00000000..d5fc00f5 --- /dev/null +++ b/e2e/wishlist.spec.ts @@ -0,0 +1,83 @@ +import { expect, type Page, test } from "@playwright/test"; + +/** + * Wishlist E2E coverage. + * + * 1) Guests are redirected to account sign-in/register when trying to wishlist. + * 2) Authenticated customers can add and remove wishlist items and see empty state. + */ + +const BASE = "/us/en"; + +test("guest is redirected to account when clicking wishlist on PDP", async ({ + page, +}) => { + await page.goto(`${BASE}/products`); + + const firstProduct = page.locator('a[href*="/products/"]').first(); + await expect(firstProduct).toBeVisible({ timeout: 15_000 }); + await firstProduct.click(); + await page.waitForURL(/\/products\/[^/]+/); + + const productPath = new URL(page.url()).pathname; + + await page.getByRole("button", { name: /add to wishlist/i }).click(); + + await expect(page).toHaveURL(/\/us\/en\/account\?redirect=/); + + const redirectParam = new URL(page.url()).searchParams.get("redirect"); + expect(redirectParam).toBe(productPath); +}); + +test("authenticated user can add and remove a wishlist item", async ({ + page, +}) => { + const email = `wishlist-e2e-${Date.now()}@example.com`; + const password = "Password123!"; + + await registerUser(page, email, password); + + await page.goto(`${BASE}/products`); + const firstProduct = page.locator('a[href*="/products/"]').first(); + await expect(firstProduct).toBeVisible({ timeout: 15_000 }); + await firstProduct.click(); + await page.waitForURL(/\/products\/[^/]+/); + + const addButton = page.getByRole("button", { name: /add to wishlist/i }); + await expect(addButton).toBeVisible(); + await addButton.click(); + + await expect( + page.getByRole("button", { + name: /added to wishlist|remove from wishlist/i, + }), + ).toBeVisible({ timeout: 10_000 }); + + await page.goto(`${BASE}/wishlist`); + await expect( + page.getByRole("heading", { name: /my wishlist/i }), + ).toBeVisible(); + + const removeButton = page.getByRole("button", { name: /^remove$/i }).first(); + await expect(removeButton).toBeVisible(); + await removeButton.click(); + + await expect( + page.getByRole("heading", { name: /your wishlist is empty/i }), + ).toBeVisible({ timeout: 10_000 }); +}); + +async function registerUser(page: Page, email: string, password: string) { + await page.goto(`${BASE}/account/register`); + + await page.getByLabel(/^first name$/i).fill("Wishlist"); + await page.getByLabel(/^last name$/i).fill("Tester"); + await page.getByLabel(/^email$/i).fill(email); + await page.locator("#password").fill(password); + await page.locator("#passwordConfirmation").fill(password); + + await page.getByRole("checkbox", { name: /i agree to the/i }).check(); + await page.getByRole("button", { name: /^create account$/i }).click(); + + await expect(page).toHaveURL(/\/us\/en\/account$/); +} diff --git a/messages/de.json b/messages/de.json index 373c3b77..9b1312cc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -37,7 +37,8 @@ "viewAllCategory": "Alle {category} anzeigen", "openSearch": "Suche öffnen", "closeSearch": "Suche schließen", - "myAccount": "Mein Konto" + "myAccount": "Mein Konto", + "wishlist": "Wunschliste" }, "footer": { "description": "Open-Source-E-Commerce basierend auf Spree REST API, TypeScript SDK und Next.js. Selbst hosten. Eigene Daten. Keine Plattformgebühren.", @@ -53,7 +54,8 @@ "cart": "Warenkorb", "policies": "Richtlinien", "poweredBy": "Betrieben von", - "copyright": "© {year} {storeName}. Unterstützt durch Spree Commerce." + "copyright": "© {year} {storeName}. Unterstützt durch Spree Commerce.", + "wishlist": "Wunschliste" }, "home": { "welcome": "{storeName} Storefront", @@ -311,7 +313,9 @@ "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen", "forgotPassword": "Passwort vergessen?", - "policyConsentRequired": "Sie müssen den Geschäftsrichtlinien zustimmen, um ein Konto zu erstellen" + "policyConsentRequired": "Sie müssen den Geschäftsrichtlinien zustimmen, um ein Konto zu erstellen", + "wishlist": "Wunschliste", + "wishlistDescription": "Verwalten Sie Ihre gemerkten Produkte" }, "register": { "createAccount": "Konto erstellen", @@ -524,5 +528,25 @@ "shipping": "Versand", "finalizingPayment": "Zahlung wird abgeschlossen...", "or": "oder" + }, + "wishlist": { + "myWishlist": "Meine Wunschliste", + "addToWishlist": "Zur Wunschliste hinzufügen", + "addedToWishlist": "Zur Wunschliste hinzugefügt", + "removeFromWishlist": "Von der Wunschliste entfernen", + "emptyTitle": "Ihre Wunschliste ist leer", + "emptyDescription": "Speichern Sie Produkte, die Ihnen gefallen, und finden Sie sie später hier wieder.", + "signInRequired": "Melden Sie sich an, um Ihre Wunschliste anzusehen und zu verwalten.", + "signInToView": "Anmelden, um die Wunschliste zu sehen", + "failedToLoad": "Ihre Wunschliste konnte nicht geladen werden. Bitte versuchen Sie es erneut.", + "failedToAdd": "Dieser Artikel konnte nicht zur Wunschliste hinzugefügt werden. Bitte versuchen Sie es erneut.", + "failedToRemove": "Dieser Artikel konnte nicht von der Wunschliste entfernt werden. Bitte versuchen Sie es erneut.", + "addToCart": "In den Warenkorb", + "addingToCart": "Wird hinzugefügt...", + "remove": "Entfernen", + "quantity": "Menge: {quantity}", + "refresh": "Aktualisieren", + "outlineHeart": "♡", + "filledHeart": "♥" } } diff --git a/messages/en.json b/messages/en.json index 5dd69a47..d965c779 100644 --- a/messages/en.json +++ b/messages/en.json @@ -37,7 +37,8 @@ "viewAllCategory": "View all {category}", "openSearch": "Open search", "closeSearch": "Close search", - "myAccount": "My Account" + "myAccount": "My Account", + "wishlist": "Wishlist" }, "footer": { "description": "Open-source ecommerce powered by Spree REST API, TypeScript SDK, and Next.js. Self-host it. Own your data. Zero platform fees.", @@ -53,7 +54,8 @@ "cart": "Cart", "policies": "Policies", "poweredBy": "Powered by", - "copyright": "© {year} {storeName}. Powered by Spree Commerce." + "copyright": "© {year} {storeName}. Powered by Spree Commerce.", + "wishlist": "Wishlist" }, "home": { "welcome": "{storeName} Storefront", @@ -311,7 +313,9 @@ "showPassword": "Show password", "hidePassword": "Hide password", "forgotPassword": "Forgot password?", - "policyConsentRequired": "You must agree to the store policies to create an account" + "policyConsentRequired": "You must agree to the store policies to create an account", + "wishlist": "Wishlist", + "wishlistDescription": "Manage your saved products" }, "register": { "createAccount": "Create Account", @@ -524,5 +528,25 @@ "shipping": "Shipping", "finalizingPayment": "Finalizing your payment...", "or": "or" + }, + "wishlist": { + "myWishlist": "My Wishlist", + "addToWishlist": "Add to wishlist", + "addedToWishlist": "Added to wishlist", + "removeFromWishlist": "Remove from wishlist", + "emptyTitle": "Your wishlist is empty", + "emptyDescription": "Save products you love and find them here later.", + "signInRequired": "Sign in to view and manage your wishlist.", + "signInToView": "Sign in to view wishlist", + "failedToLoad": "Could not load your wishlist. Please try again.", + "failedToAdd": "Could not add this item to your wishlist. Please try again.", + "failedToRemove": "Could not remove this item from your wishlist. Please try again.", + "addToCart": "Add to cart", + "addingToCart": "Adding...", + "remove": "Remove", + "quantity": "Qty: {quantity}", + "refresh": "Refresh", + "outlineHeart": "♡", + "filledHeart": "♥" } } diff --git a/messages/es.json b/messages/es.json index 8e7610ce..c30d211d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -37,7 +37,8 @@ "viewAllCategory": "Ver todo en {category}", "openSearch": "Abrir busqueda", "closeSearch": "Cerrar busqueda", - "myAccount": "Mi cuenta" + "myAccount": "Mi cuenta", + "wishlist": "Lista de deseos" }, "footer": { "description": "Comercio electrónico de código abierto basado en Spree REST API, TypeScript SDK y Next.js. Alójalo tú mismo. Tus datos. Sin comisiones de plataforma.", @@ -53,7 +54,8 @@ "cart": "Carrito", "policies": "Politicas", "poweredBy": "Impulsado por", - "copyright": "\u00a9 {year} {storeName}. Impulsado por Spree Commerce." + "copyright": "© {year} {storeName}. Impulsado por Spree Commerce.", + "wishlist": "Lista de deseos" }, "home": { "welcome": "{storeName} Storefront", @@ -163,7 +165,7 @@ "openImageZoom": "Abrir zoom de imagen", "priceUnder": "Menos de {price}", "priceAbove": "{price}+", - "priceRangeBucket": "{min} \u2013 {max}", + "priceRangeBucket": "{min} – {max}", "properties": "Propiedades", "yes": "Si", "no": "No", @@ -188,7 +190,7 @@ "emailAddress": "Correo electronico", "emailPlaceholder": "tu@ejemplo.com", "usingAccountEmail": "Usando el correo electronico de tu cuenta", - "signInPrompt": "\u00bfYa tienes una cuenta?", + "signInPrompt": "¿Ya tienes una cuenta?", "signIn": "Iniciar sesion", "signInDescription": "para acceder a tus direcciones guardadas e historial de pedidos.", "shippingAddress": "Direccion de envio", @@ -269,7 +271,7 @@ "addNewAddress": "Agregar nueva direccion", "saveAddress": "Guardar direccion", "failedToSave": "Error al guardar la direccion", - "deleteAddressTitle": "\u00bfEliminar direccion?", + "deleteAddressTitle": "¿Eliminar direccion?", "deleteAddressConfirmation": "Esto eliminara permanentemente esta direccion. Esta accion no se puede deshacer.", "delete": "Eliminar", "deleting": "Eliminando...", @@ -287,7 +289,7 @@ "signIn": "Iniciar sesion", "signingIn": "Iniciando sesion...", "invalidCredentials": "Correo electronico o contrasena invalidos", - "dontHaveAccount": "\u00bfNo tienes una cuenta?", + "dontHaveAccount": "¿No tienes una cuenta?", "signUp": "Registrate", "accountOverview": "Resumen de la cuenta", "orderHistory": "Historial de pedidos", @@ -306,12 +308,14 @@ "noAddresses": "No hay direcciones guardadas", "noAddressesDescription": "Las direcciones que agregues durante el checkout apareceran aqui.", "defaultAddress": "Predeterminada", - "deleteConfirm": "\u00bfEstas seguro de que deseas eliminar esta direccion?", + "deleteConfirm": "¿Estas seguro de que deseas eliminar esta direccion?", "setAsDefault": "Establecer como predeterminada", "showPassword": "Mostrar contrasena", "hidePassword": "Ocultar contrasena", - "forgotPassword": "\u00bfOlvidaste tu contrasena?", - "policyConsentRequired": "Debes aceptar las politicas de la tienda para crear una cuenta" + "forgotPassword": "¿Olvidaste tu contrasena?", + "policyConsentRequired": "Debes aceptar las politicas de la tienda para crear una cuenta", + "wishlist": "Lista de deseos", + "wishlistDescription": "Gestiona tus productos guardados" }, "register": { "createAccount": "Crear cuenta", @@ -321,7 +325,7 @@ "passwordsDontMatch": "Las contrasenas no coinciden", "passwordTooShort": "La contrasena debe tener al menos 6 caracteres", "registrationFailed": "Error en el registro. Por favor, intenta de nuevo.", - "alreadyHaveAccount": "\u00bfYa tienes una cuenta?", + "alreadyHaveAccount": "¿Ya tienes una cuenta?", "signIn": "Iniciar sesion", "unexpectedError": "Ocurrio un error inesperado. Por favor, intenta de nuevo.", "firstName": "Nombre", @@ -355,7 +359,7 @@ "paymentInformation": "Informacion de pago", "cardEndingIn": "{label} terminada en {digits}", "cardExpires": "Vence {month}/{year}", - "storeCreditApplied": "Aplicado {amount} \u2014 {remaining} restante", + "storeCreditApplied": "Aplicado {amount} — {remaining} restante", "billingAddress": "Direccion de facturacion", "orderNotFoundDescription": "El pedido que buscas no existe.", "storeCredit": "Credito de la tienda", @@ -377,13 +381,13 @@ "notAvailable": "N/D", "unknownShipmentStatus": "Desconocido", "orderTitle": "Pedido #{number}", - "shipmentCanceledRefund": "Envio cancelado \u2014 se ha emitido un reembolso.", + "shipmentCanceledRefund": "Envio cancelado — se ha emitido un reembolso.", "shippingMethodUnavailable": "No disponible", "totalColumn": "Total" }, "orderPlaced": { - "thanksForOrder": "\u00a1Gracias por tu pedido, {name}!", - "thanksForOrderAnonymous": "\u00a1Gracias por tu pedido!", + "thanksForOrder": "¡Gracias por tu pedido, {name}!", + "thanksForOrderAnonymous": "¡Gracias por tu pedido!", "orderNumber": "Pedido #{number}", "emailConfirmation": "Recibiras una confirmacion por correo electronico en breve.", "orderItems": "Articulos del pedido", @@ -417,7 +421,7 @@ "backToStore": "Volver a la tienda", "showOrderSummary": "Mostrar resumen del pedido", "hideOrderSummary": "Ocultar resumen del pedido", - "allRightsReserved": "\u00a9 {year} {storeName}. Todos los derechos reservados." + "allRightsReserved": "© {year} {storeName}. Todos los derechos reservados." }, "profile": { "profile": "Perfil", @@ -428,7 +432,7 @@ "currentPasswordHelp": "Confirma tu contrasena actual para cambiar tu correo electronico.", "saveChanges": "Guardar cambios", "saving": "Guardando...", - "profileUpdated": "\u00a1Perfil actualizado con exito!", + "profileUpdated": "¡Perfil actualizado con exito!", "accountInformation": "Informacion de la cuenta", "accountId": "ID de cuenta", "loadingProfile": "Cargando perfil...", @@ -443,9 +447,9 @@ "default": "Predeterminado", "removing": "Eliminando...", "secureInfo": "Tu informacion de pago esta almacenada de forma segura.", - "deleteConfirm": "\u00bfEstas seguro de que deseas eliminar esta tarjeta?", + "deleteConfirm": "¿Estas seguro de que deseas eliminar esta tarjeta?", "cardMaskedLabel": "{label} terminada en {digits}, vence {month}/{year}", - "removePaymentMethodTitle": "\u00bfEliminar metodo de pago?", + "removePaymentMethodTitle": "¿Eliminar metodo de pago?", "endingIn": "terminada en", "cardEndingIn": "{label} terminada en {digits}", "cardExpires": "Vence {month}/{year}", @@ -472,7 +476,7 @@ "activeGiftCards": "Tarjetas regalo activas", "expiredRedeemed": "Expiradas / Canjeadas", "copy": "Copiar", - "copied": "\u00a1Copiado!", + "copied": "¡Copiado!", "copyCodeToClipboard": "Copiar codigo al portapapeles", "expiresOn": "Expira el {date}", "percentUsed": "{percent}% usado", @@ -524,5 +528,25 @@ "shipping": "Envio", "finalizingPayment": "Finalizando tu pago...", "or": "o" + }, + "wishlist": { + "myWishlist": "Mi lista de deseos", + "addToWishlist": "Agregar a la lista de deseos", + "addedToWishlist": "Agregado a la lista de deseos", + "removeFromWishlist": "Quitar de la lista de deseos", + "emptyTitle": "Tu lista de deseos está vacía", + "emptyDescription": "Guarda los productos que te encantan y encuéntralos aquí más tarde.", + "signInRequired": "Inicia sesión para ver y administrar tu lista de deseos.", + "signInToView": "Inicia sesión para ver la lista de deseos", + "failedToLoad": "No se pudo cargar tu lista de deseos. Inténtalo de nuevo.", + "failedToAdd": "No se pudo agregar este artículo a tu lista de deseos. Inténtalo de nuevo.", + "failedToRemove": "No se pudo quitar este artículo de tu lista de deseos. Inténtalo de nuevo.", + "addToCart": "Agregar al carrito", + "addingToCart": "Agregando...", + "remove": "Quitar", + "quantity": "Cant.: {quantity}", + "refresh": "Actualizar", + "outlineHeart": "♡", + "filledHeart": "♥" } } diff --git a/messages/fr.json b/messages/fr.json index f36c9878..7e8a223c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -37,7 +37,8 @@ "viewAllCategory": "Voir tout dans {category}", "openSearch": "Ouvrir la recherche", "closeSearch": "Fermer la recherche", - "myAccount": "Mon compte" + "myAccount": "Mon compte", + "wishlist": "Liste d'envies" }, "footer": { "description": "E-commerce open source basé sur Spree REST API, TypeScript SDK et Next.js. Auto-hébergé. Vos données. Zéro frais de plateforme.", @@ -53,7 +54,8 @@ "cart": "Panier", "policies": "Politiques", "poweredBy": "Propulse par", - "copyright": "\u00a9 {year} {storeName}. Propulse par Spree Commerce." + "copyright": "© {year} {storeName}. Propulse par Spree Commerce.", + "wishlist": "Liste d'envies" }, "home": { "welcome": "{storeName} Storefront", @@ -163,7 +165,7 @@ "openImageZoom": "Ouvrir le zoom de l'image", "priceUnder": "Moins de {price}", "priceAbove": "{price}+", - "priceRangeBucket": "{min} \u2013 {max}", + "priceRangeBucket": "{min} – {max}", "properties": "Proprietes", "yes": "Oui", "no": "Non", @@ -311,7 +313,9 @@ "showPassword": "Afficher le mot de passe", "hidePassword": "Masquer le mot de passe", "forgotPassword": "Mot de passe oublie ?", - "policyConsentRequired": "Vous devez accepter les politiques du magasin pour creer un compte" + "policyConsentRequired": "Vous devez accepter les politiques du magasin pour creer un compte", + "wishlist": "Liste d'envies", + "wishlistDescription": "Gérez vos produits enregistrés" }, "register": { "createAccount": "Creer un compte", @@ -355,7 +359,7 @@ "paymentInformation": "Informations de paiement", "cardEndingIn": "{label} se terminant par {digits}", "cardExpires": "Expire le {month}/{year}", - "storeCreditApplied": "{amount} applique \u2014 {remaining} restant", + "storeCreditApplied": "{amount} applique — {remaining} restant", "billingAddress": "Adresse de facturation", "orderNotFoundDescription": "La commande que vous recherchez n'existe pas.", "storeCredit": "Credit boutique", @@ -377,7 +381,7 @@ "notAvailable": "N/D", "unknownShipmentStatus": "Inconnu", "orderTitle": "Commande #{number}", - "shipmentCanceledRefund": "Expedition annulee \u2014 un remboursement a ete emis.", + "shipmentCanceledRefund": "Expedition annulee — un remboursement a ete emis.", "shippingMethodUnavailable": "Indisponible", "totalColumn": "Total" }, @@ -417,7 +421,7 @@ "backToStore": "Retour a la boutique", "showOrderSummary": "Afficher le recapitulatif de la commande", "hideOrderSummary": "Masquer le recapitulatif de la commande", - "allRightsReserved": "\u00a9 {year} {storeName}. Tous droits reserves." + "allRightsReserved": "© {year} {storeName}. Tous droits reserves." }, "profile": { "profile": "Profil", @@ -524,5 +528,25 @@ "shipping": "Livraison", "finalizingPayment": "Finalisation du paiement...", "or": "ou" + }, + "wishlist": { + "myWishlist": "Ma liste d'envies", + "addToWishlist": "Ajouter à la liste d'envies", + "addedToWishlist": "Ajouté à la liste d'envies", + "removeFromWishlist": "Retirer de la liste d'envies", + "emptyTitle": "Votre liste d'envies est vide", + "emptyDescription": "Enregistrez les produits que vous aimez et retrouvez-les ici plus tard.", + "signInRequired": "Connectez-vous pour voir et gérer votre liste d'envies.", + "signInToView": "Se connecter pour voir la liste d'envies", + "failedToLoad": "Impossible de charger votre liste d'envies. Veuillez réessayer.", + "failedToAdd": "Impossible d'ajouter cet article à votre liste d'envies. Veuillez réessayer.", + "failedToRemove": "Impossible de retirer cet article de votre liste d'envies. Veuillez réessayer.", + "addToCart": "Ajouter au panier", + "addingToCart": "Ajout en cours...", + "remove": "Retirer", + "quantity": "Qté : {quantity}", + "refresh": "Actualiser", + "outlineHeart": "♡", + "filledHeart": "♥" } } diff --git a/messages/pl.json b/messages/pl.json index 86bd1c51..6413a15e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -37,7 +37,8 @@ "viewAllCategory": "Zobacz wszystkie {category}", "openSearch": "Otwórz wyszukiwanie", "closeSearch": "Zamknij wyszukiwanie", - "myAccount": "Moje konto" + "myAccount": "Moje konto", + "wishlist": "Lista życzeń" }, "footer": { "description": "Sklep e-commerce open source oparty na Spree REST API, TypeScript SDK i Next.js. Hostuj samodzielnie. Twoje dane. Zero opłat platformowych.", @@ -53,7 +54,8 @@ "cart": "Koszyk", "policies": "Regulaminy", "poweredBy": "Zasilany przez", - "copyright": "© {year} {storeName}. Zasilany przez Spree Commerce." + "copyright": "© {year} {storeName}. Zasilany przez Spree Commerce.", + "wishlist": "Lista życzeń" }, "home": { "welcome": "{storeName} Storefront", @@ -311,7 +313,9 @@ "showPassword": "Pokaż hasło", "hidePassword": "Ukryj hasło", "forgotPassword": "Zapomniałeś hasła?", - "policyConsentRequired": "Musisz zaakceptować regulamin sklepu, aby utworzyć konto" + "policyConsentRequired": "Musisz zaakceptować regulamin sklepu, aby utworzyć konto", + "wishlist": "Lista życzeń", + "wishlistDescription": "Zarządzaj zapisanymi produktami" }, "register": { "createAccount": "Utwórz konto", @@ -524,5 +528,25 @@ "shipping": "Dostawa", "finalizingPayment": "Finalizowanie płatności...", "or": "lub" + }, + "wishlist": { + "myWishlist": "Moja lista życzeń", + "addToWishlist": "Dodaj do listy życzeń", + "addedToWishlist": "Dodano do listy życzeń", + "removeFromWishlist": "Usuń z listy życzeń", + "emptyTitle": "Twoja lista życzeń jest pusta", + "emptyDescription": "Zapisz produkty, które lubisz, i znajdź je tutaj później.", + "signInRequired": "Zaloguj się, aby przeglądać i zarządzać listą życzeń.", + "signInToView": "Zaloguj się, aby zobaczyć listę życzeń", + "failedToLoad": "Nie udało się załadować listy życzeń. Spróbuj ponownie.", + "failedToAdd": "Nie udało się dodać tego produktu do listy życzeń. Spróbuj ponownie.", + "failedToRemove": "Nie udało się usunąć tego produktu z listy życzeń. Spróbuj ponownie.", + "addToCart": "Dodaj do koszyka", + "addingToCart": "Dodawanie...", + "remove": "Usuń", + "quantity": "Ilość: {quantity}", + "refresh": "Odśwież", + "outlineHeart": "♡", + "filledHeart": "♥" } } diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/layout.tsx index 407c631b..6a607b27 100644 --- a/src/app/[country]/[locale]/(storefront)/account/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/layout.tsx @@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react"; import { CreditCard, Gift, + Heart, Home, LogOut, MapPin, @@ -32,6 +33,7 @@ function getNavItems(t: ReturnType>): { label: t("paymentMethods"), icon: CreditCard, }, + { href: "/account/wishlist", label: t("wishlist"), icon: Heart }, { href: "/account/gift-cards", label: t("giftCards"), icon: Gift }, { href: "/account/profile", label: t("profile"), icon: User }, ]; diff --git a/src/app/[country]/[locale]/(storefront)/account/page.tsx b/src/app/[country]/[locale]/(storefront)/account/page.tsx index 539605f6..fb020bc2 100644 --- a/src/app/[country]/[locale]/(storefront)/account/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/page.tsx @@ -5,6 +5,7 @@ import { CreditCard, Eye, EyeOff, + Heart, MapPin, ShoppingBag, User, @@ -206,6 +207,24 @@ export default function AccountPage() { + + + +
+ +
+
+

+ {t("wishlist")} +

+

+ {t("wishlistDescription")} +

+
+
+
+ + diff --git a/src/app/[country]/[locale]/(storefront)/account/wishlist/page.tsx b/src/app/[country]/[locale]/(storefront)/account/wishlist/page.tsx new file mode 100644 index 00000000..4261143f --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/wishlist/page.tsx @@ -0,0 +1,5 @@ +import { WishlistPageContent } from "@/components/wishlist/WishlistPageContent"; + +export default function AccountWishlistPage() { + return ; +} diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx index ac93f97b..26471e98 100644 --- a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx +++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx @@ -9,6 +9,7 @@ import { ProductCustomFields } from "@/components/products/ProductCustomFields"; import { VariantPicker } from "@/components/products/VariantPicker"; import { Button } from "@/components/ui/button"; import { QuantityPicker } from "@/components/ui/quantity-picker"; +import { WishlistButton } from "@/components/wishlist/WishlistButton"; import { useCart } from "@/contexts/CartContext"; import { useStore } from "@/contexts/StoreContext"; import { trackAddToCart, trackViewItem } from "@/lib/analytics/gtm"; @@ -110,6 +111,11 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { trackAddToCart(product, selectedVariant, quantity, currency); }; + const selectedVariantId = + selectedVariant?.id || + product.default_variant?.id || + product.default_variant_id; + return (
@@ -202,6 +208,14 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { t("outOfStock") )} + + {selectedVariantId && ( + + )}
diff --git a/src/app/[country]/[locale]/(storefront)/wishlist/page.tsx b/src/app/[country]/[locale]/(storefront)/wishlist/page.tsx new file mode 100644 index 00000000..c6af6903 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/wishlist/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from "next/navigation"; + +interface WishlistRedirectPageProps { + params: Promise<{ + country: string; + locale: string; + }>; +} + +export default async function WishlistRedirectPage({ + params, +}: WishlistRedirectPageProps) { + const { country, locale } = await params; + redirect(`/${country}/${locale}/account/wishlist`); +} diff --git a/src/app/[country]/[locale]/layout.tsx b/src/app/[country]/[locale]/layout.tsx index 0040345a..8d270e9a 100644 --- a/src/app/[country]/[locale]/layout.tsx +++ b/src/app/[country]/[locale]/layout.tsx @@ -7,6 +7,7 @@ import { Toaster } from "@/components/ui/sonner"; import { AuthProvider } from "@/contexts/AuthContext"; import { CartProvider } from "@/contexts/CartContext"; import { StoreProvider } from "@/contexts/StoreContext"; +import { WishlistProvider } from "@/contexts/WishlistContext"; import { getMarkets } from "@/lib/data/markets"; import { generateStoreMetadata } from "@/lib/metadata/store"; import { buildOrganizationJsonLd } from "@/lib/seo"; @@ -81,12 +82,14 @@ export default async function CountryLocaleLayout({ initialMarkets={markets} > - - - {children} - - - + + + + {children} + + + + diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 0e3622a9..4dd9c359 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -117,6 +117,14 @@ export async function Footer({ {t("orderHistory")} +
  • + + {t("wishlist")} + +
  • + {/* Wishlist */} + + {/* Cart */} diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx index 877cf61a..2c4799f4 100644 --- a/src/components/layout/MobileMenu.tsx +++ b/src/components/layout/MobileMenu.tsx @@ -231,6 +231,13 @@ export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) { > {t("allProducts")} + setOpen(false)} + className={linkClass} + > + {t("wishlist")} + {rootCategories.map((category) => category.children && category.children.length > 0 ? ( - - - - {t("myAccount")} - - +
    + + + {t("wishlist")} + + + + + + {t("myAccount")} + + +
    diff --git a/src/components/layout/WishlistNavButton.tsx b/src/components/layout/WishlistNavButton.tsx new file mode 100644 index 00000000..46221d35 --- /dev/null +++ b/src/components/layout/WishlistNavButton.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Heart } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { useWishlist } from "@/contexts/WishlistContext"; +import { extractBasePath } from "@/lib/utils/path"; + +export function WishlistNavButton() { + const pathname = usePathname(); + const basePath = extractBasePath(pathname); + const t = useTranslations("header"); + const { itemCount } = useWishlist(); + + return ( + + ); +} diff --git a/src/components/products/ProductCard.tsx b/src/components/products/ProductCard.tsx index 4ad0406a..7d4a56e2 100644 --- a/src/components/products/ProductCard.tsx +++ b/src/components/products/ProductCard.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useTranslations } from "next-intl"; import { memo } from "react"; import { ProductImage } from "@/components/ui/product-image"; +import { WishlistButton } from "@/components/wishlist/WishlistButton"; import { trackSelectItem } from "@/lib/analytics/gtm"; interface ProductCardProps { @@ -60,52 +61,62 @@ export const ProductCard = memo(function ProductCard({ }; return ( - - {/* Image */} -
    - - {onSale && ( - - {t("sale")} - - )} -
    - - {/* Content */} -
    -

    - {product.name} -

    - -
    - {displayPrice && ( - - {displayPrice} +
    + + {/* Image */} +
    + + {onSale && ( + + {t("sale")} )} - {onSale && strikethroughPrice && ( - - {strikethroughPrice} +
    + + {/* Content */} +
    +

    + {product.name} +

    + +
    + {displayPrice && ( + + {displayPrice} + + )} + {onSale && strikethroughPrice && ( + + {strikethroughPrice} + + )} +
    + + {!product.purchasable && ( + + {t("outOfStock")} )}
    + - {!product.purchasable && ( - {t("outOfStock")} - )} -
    - + +
    ); }); diff --git a/src/components/products/__tests__/ProductCard.test.tsx b/src/components/products/__tests__/ProductCard.test.tsx index 6cd60b31..cb6c61c0 100644 --- a/src/components/products/__tests__/ProductCard.test.tsx +++ b/src/components/products/__tests__/ProductCard.test.tsx @@ -7,6 +7,10 @@ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); +vi.mock("@/components/wishlist/WishlistButton", () => ({ + WishlistButton: () => , +})); + vi.mock("@/contexts/StoreContext", () => ({ useStore: () => ({ currency: "USD", locale: "en", loading: false }), })); diff --git a/src/components/wishlist/WishlistButton.tsx b/src/components/wishlist/WishlistButton.tsx new file mode 100644 index 00000000..1ddd2734 --- /dev/null +++ b/src/components/wishlist/WishlistButton.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Heart } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/AuthContext"; +import { useWishlist } from "@/contexts/WishlistContext"; +import { extractBasePath } from "@/lib/utils/path"; + +interface WishlistButtonProps { + variantId: string; + size?: "default" | "sm" | "lg" | "icon" | "icon-sm" | "icon-lg"; + className?: string; + showLabel?: boolean; +} + +export function WishlistButton({ + variantId, + size = "sm", + className, + showLabel = true, +}: WishlistButtonProps) { + const pathname = usePathname(); + const router = useRouter(); + const t = useTranslations("wishlist"); + const basePath = extractBasePath(pathname); + const { isAuthenticated, loading: authLoading } = useAuth(); + const { hasVariant, addItem, removeItemByVariant, updating } = useWishlist(); + const [justAdded, setJustAdded] = useState(false); + + useEffect(() => { + if (!justAdded) return; + const timeout = setTimeout(() => setJustAdded(false), 1600); + return () => clearTimeout(timeout); + }, [justAdded]); + + const isInWishlist = hasVariant(variantId); + + const handleClick = async () => { + if (authLoading) return; + + if (!isAuthenticated) { + const signInUrl = `${basePath}/account?redirect=${encodeURIComponent(pathname)}`; + router.push(signInUrl); + return; + } + + if (isInWishlist) { + const removed = await removeItemByVariant(variantId); + if (removed) { + setJustAdded(false); + } + return; + } + + const added = await addItem(variantId, 1); + if (added) { + setJustAdded(true); + } + }; + + const label = !isAuthenticated + ? t("addToWishlist") + : isInWishlist + ? justAdded + ? t("addedToWishlist") + : t("removeFromWishlist") + : t("addToWishlist"); + + const active = isAuthenticated && isInWishlist; + + return ( + + ); +} diff --git a/src/components/wishlist/WishlistPageContent.tsx b/src/components/wishlist/WishlistPageContent.tsx new file mode 100644 index 00000000..fef9c72d --- /dev/null +++ b/src/components/wishlist/WishlistPageContent.tsx @@ -0,0 +1,221 @@ +"use client"; + +import type { Variant, WishlistItem } from "@spree/sdk"; +import { AlertCircle, Heart, Loader2, ShoppingBag, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { ProductImage } from "@/components/ui/product-image"; +import { useAuth } from "@/contexts/AuthContext"; +import { useCart } from "@/contexts/CartContext"; +import { useWishlist } from "@/contexts/WishlistContext"; +import { extractBasePath } from "@/lib/utils/path"; + +type VariantWithProduct = Variant & { + name?: string; + slug?: string; + product?: { + name?: string; + slug?: string; + thumbnail_url?: string | null; + }; +}; + +function getDisplayFields(item: WishlistItem) { + const variant = item.variant as VariantWithProduct; + const productName = + variant.product?.name || variant.name || variant.sku || "Product"; + const productSlug = variant.product?.slug || variant.slug; + const imageUrl = + variant.thumbnail_url || variant.product?.thumbnail_url || null; + + return { productName, productSlug, imageUrl }; +} + +export function WishlistPageContent() { + const t = useTranslations("wishlist"); + const tc = useTranslations("common"); + const pathname = usePathname(); + const basePath = extractBasePath(pathname); + const { isAuthenticated, loading: authLoading } = useAuth(); + const { + wishlist, + loading, + updating, + error, + refreshWishlist, + removeItemByVariant, + } = useWishlist(); + const { addItem } = useCart(); + const [addingVariantId, setAddingVariantId] = useState(null); + + if (authLoading || loading) { + return ( +
    +
    +
    +
    +
    +
    +
    + ); + } + + if (!isAuthenticated) { + return ( +
    +
    + +

    + {t("myWishlist")} +

    +

    {t("signInRequired")}

    +
    + +
    +
    +
    + ); + } + + const items = wishlist?.items ?? []; + + if (items.length === 0) { + return ( +
    +
    + +

    + {t("emptyTitle")} +

    +

    {t("emptyDescription")}

    +
    + +
    +
    +
    + ); + } + + return ( +
    +
    +

    {t("myWishlist")}

    + +
    + + {error && ( + + + {error} + + )} + +
    + {items.map((item) => { + const { productName, productSlug, imageUrl } = getDisplayFields(item); + + return ( +
    +
    + +
    + +
    +

    + {productSlug ? ( + + {productName} + + ) : ( + productName + )} +

    + + {item.variant.options_text && ( +

    + {item.variant.options_text} +

    + )} + +
    + {t("quantity", { quantity: item.quantity })} + {item.variant.price?.display_amount && ( + + {item.variant.price.display_amount} + + )} +
    + +
    + + + +
    +
    +
    + ); + })} +
    +
    + ); +} diff --git a/src/components/wishlist/__tests__/WishlistButton.test.tsx b/src/components/wishlist/__tests__/WishlistButton.test.tsx new file mode 100644 index 00000000..8597fa90 --- /dev/null +++ b/src/components/wishlist/__tests__/WishlistButton.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { WishlistButton } from "@/components/wishlist/WishlistButton"; + +const mockPush = vi.fn(); +const mockAddItem = vi.fn(); +const mockRemoveItemByVariant = vi.fn(); +const mockHasVariant = vi.fn(); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/us/en/products/classic-tee", + useRouter: () => ({ push: mockPush }), +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => { + const map: Record = { + outlineHeart: "♡", + filledHeart: "♥", + addToWishlist: "Add to wishlist", + addedToWishlist: "Added to wishlist", + removeFromWishlist: "Remove from wishlist", + }; + return map[key] ?? key; + }, +})); + +let authenticated = true; + +vi.mock("@/contexts/AuthContext", () => ({ + useAuth: () => ({ isAuthenticated: authenticated, loading: false }), +})); + +vi.mock("@/contexts/WishlistContext", () => ({ + useWishlist: () => ({ + hasVariant: mockHasVariant, + addItem: mockAddItem, + removeItemByVariant: mockRemoveItemByVariant, + updating: false, + }), +})); + +describe("WishlistButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + authenticated = true; + mockHasVariant.mockReturnValue(false); + mockAddItem.mockResolvedValue(true); + mockRemoveItemByVariant.mockResolvedValue(true); + }); + + it("shows add state for new variants", () => { + render(); + expect( + screen.getByRole("button", { name: "Add to wishlist" }), + ).toBeInTheDocument(); + }); + + it("shows remove state for variants already in wishlist", () => { + mockHasVariant.mockReturnValue(true); + render(); + expect( + screen.getByRole("button", { name: "Remove from wishlist" }), + ).toBeInTheDocument(); + }); + + it("redirects guests to account login with redirect param", () => { + authenticated = false; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add to wishlist" })); + + expect(mockPush).toHaveBeenCalledWith( + "/us/en/account?redirect=%2Fus%2Fen%2Fproducts%2Fclassic-tee", + ); + }); + + it("adds variant when authenticated and not in wishlist", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add to wishlist" })); + + expect(mockAddItem).toHaveBeenCalledWith("var_2", 1); + }); +}); diff --git a/src/contexts/WishlistContext.tsx b/src/contexts/WishlistContext.tsx new file mode 100644 index 00000000..92fc640b --- /dev/null +++ b/src/contexts/WishlistContext.tsx @@ -0,0 +1,191 @@ +"use client"; + +import type { Wishlist, WishlistItem } from "@spree/sdk"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { toast } from "sonner"; +import { useAuth } from "@/contexts/AuthContext"; +import { + addWishlistItem as addWishlistItemAction, + getWishlist as getWishlistAction, + removeWishlistItemByVariant as removeWishlistItemByVariantAction, +} from "@/lib/data/wishlist"; + +interface WishlistContextType { + wishlist: Wishlist | null; + loading: boolean; + updating: boolean; + error: string | null; + itemCount: number; + refreshWishlist: () => Promise; + addItem: (variantId: string, quantity?: number) => Promise; + removeItemByVariant: (variantId: string) => Promise; + hasVariant: (variantId: string) => boolean; + findItemByVariant: (variantId: string) => WishlistItem | undefined; +} + +const WishlistContext = createContext( + undefined, +); + +export function WishlistProvider({ children }: { children: ReactNode }) { + const [wishlist, setWishlist] = useState(null); + const [loading, setLoading] = useState(true); + const [updating, setUpdating] = useState(false); + const [error, setError] = useState(null); + const { isAuthenticated, loading: authLoading } = useAuth(); + const router = useRouter(); + const t = useTranslations("wishlist"); + const failedToLoadMessageRef = useRef("Could not load your wishlist."); + + useEffect(() => { + failedToLoadMessageRef.current = t("failedToLoad"); + }, [t]); + + const refreshWishlist = useCallback(async () => { + if (!isAuthenticated) { + setWishlist(null); + setError(null); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + try { + const nextWishlist = await getWishlistAction(); + setWishlist(nextWishlist); + } catch { + setError(failedToLoadMessageRef.current); + setWishlist(null); + } finally { + setLoading(false); + } + }, [isAuthenticated]); + + useEffect(() => { + if (authLoading) return; + refreshWishlist(); + }, [authLoading, refreshWishlist]); + + const mutateWishlist = useCallback( + async ( + action: () => Promise<{ + success: boolean; + wishlist?: Wishlist; + error?: string; + }>, + fallbackMessage: string, + ): Promise => { + setUpdating(true); + setError(null); + try { + const result = await action(); + if (!result.success) { + const message = result.error || fallbackMessage; + setError(message); + toast.error(message); + return false; + } + + setWishlist(result.wishlist ?? null); + router.refresh(); + return true; + } catch (cause) { + const message = + cause instanceof Error ? cause.message : fallbackMessage; + setError(message); + toast.error(message); + return false; + } finally { + setUpdating(false); + } + }, + [router], + ); + + const addItem = useCallback( + async (variantId: string, quantity = 1) => { + return mutateWishlist( + () => addWishlistItemAction(variantId, quantity), + t("failedToAdd"), + ); + }, + [mutateWishlist, t], + ); + + const removeItemByVariant = useCallback( + async (variantId: string) => { + return mutateWishlist( + () => removeWishlistItemByVariantAction(variantId), + t("failedToRemove"), + ); + }, + [mutateWishlist, t], + ); + + const hasVariant = useCallback( + (variantId: string) => + !!wishlist?.items?.some((item) => item.variant_id === variantId), + [wishlist], + ); + + const findItemByVariant = useCallback( + (variantId: string) => + wishlist?.items?.find((item) => item.variant_id === variantId), + [wishlist], + ); + + const itemCount = wishlist?.items?.length ?? 0; + + const value = useMemo( + () => ({ + wishlist, + loading, + updating, + error, + itemCount, + refreshWishlist, + addItem, + removeItemByVariant, + hasVariant, + findItemByVariant, + }), + [ + wishlist, + loading, + updating, + error, + itemCount, + refreshWishlist, + addItem, + removeItemByVariant, + hasVariant, + findItemByVariant, + ], + ); + + return ( + + {children} + + ); +} + +export function useWishlist() { + const context = useContext(WishlistContext); + if (context === undefined) { + throw new Error("useWishlist must be used within a WishlistProvider"); + } + return context; +} diff --git a/src/contexts/__tests__/WishlistContext.test.tsx b/src/contexts/__tests__/WishlistContext.test.tsx new file mode 100644 index 00000000..e21f4fa6 --- /dev/null +++ b/src/contexts/__tests__/WishlistContext.test.tsx @@ -0,0 +1,135 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/data/wishlist", () => ({ + getWishlist: vi.fn(), + addWishlistItem: vi.fn(), + removeWishlistItemByVariant: vi.fn(), +})); + +let authenticated = true; + +vi.mock("@/contexts/AuthContext", () => ({ + useAuth: () => ({ isAuthenticated: authenticated, loading: false }), +})); + +const translate = (key: string) => key; + +vi.mock("next-intl", () => ({ + useTranslations: () => translate, +})); + +vi.mock("sonner", () => ({ + toast: { error: vi.fn() }, +})); + +import { useWishlist, WishlistProvider } from "@/contexts/WishlistContext"; +import { + addWishlistItem, + getWishlist, + removeWishlistItemByVariant, +} from "@/lib/data/wishlist"; + +const mockGetWishlist = vi.mocked(getWishlist); +const mockAddWishlistItem = vi.mocked(addWishlistItem); +const mockRemoveWishlistItemByVariant = vi.mocked(removeWishlistItemByVariant); + +const wishlistFixture = { + id: "wl_1", + name: "My Wishlist", + token: "wl-token", + is_default: true, + is_private: false, + items: [ + { + id: "wi_1", + variant_id: "var_1", + wishlist_id: "wl_1", + quantity: 1, + variant: { id: "var_1", price: { display_amount: "$10.00" } }, + }, + ], +} as never; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("WishlistContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + authenticated = true; + mockGetWishlist.mockResolvedValue(wishlistFixture); + }); + + it("loads wishlist on mount", async () => { + const { result } = renderHook(() => useWishlist(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.wishlist).toEqual(wishlistFixture); + expect(result.current.itemCount).toBe(1); + }); + + it("adds an item and updates state", async () => { + const updatedWishlist = { + ...wishlistFixture, + items: [...wishlistFixture.items, { id: "wi_2", variant_id: "var_2" }], + } as never; + + mockAddWishlistItem.mockResolvedValue({ + success: true, + wishlist: updatedWishlist, + }); + + const { result } = renderHook(() => useWishlist(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.addItem("var_2"); + }); + + expect(result.current.wishlist?.items?.length).toBe(2); + expect(result.current.hasVariant("var_2")).toBe(true); + }); + + it("removes an item by variant and updates state", async () => { + mockRemoveWishlistItemByVariant.mockResolvedValue({ + success: true, + wishlist: { ...wishlistFixture, items: [] }, + }); + + const { result } = renderHook(() => useWishlist(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.removeItemByVariant("var_1"); + }); + + expect(result.current.wishlist?.items).toHaveLength(0); + expect(result.current.hasVariant("var_1")).toBe(false); + }); + + it("stays empty and skips fetch when user is not authenticated", async () => { + authenticated = false; + + const { result } = renderHook(() => useWishlist(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockGetWishlist).not.toHaveBeenCalled(); + expect(result.current.wishlist).toBeNull(); + expect(result.current.itemCount).toBe(0); + }); +}); diff --git a/src/lib/data/__tests__/wishlist.test.ts b/src/lib/data/__tests__/wishlist.test.ts new file mode 100644 index 00000000..7c341be8 --- /dev/null +++ b/src/lib/data/__tests__/wishlist.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockClient = { + wishlists: { + list: vi.fn(), + get: vi.fn(), + create: vi.fn(), + items: { + create: vi.fn(), + delete: vi.fn(), + }, + }, +}; + +const mockWithAuthRefresh = vi.fn(); + +vi.mock("@/lib/spree", () => ({ + getClient: () => mockClient, + withAuthRefresh: (fn: (options: { token?: string }) => Promise) => + mockWithAuthRefresh(fn), + isAuthError: vi.fn(() => false), +})); + +vi.mock("next/cache", () => ({ + updateTag: vi.fn(), +})); + +import { + addWishlistItem, + getWishlist, + removeWishlistItemByVariant, +} from "@/lib/data/wishlist"; + +const wishlistFixture = { + id: "wl_1", + name: "My Wishlist", + token: "wl-token", + is_default: true, + is_private: false, + items: [ + { + id: "wi_1", + variant_id: "var_1", + wishlist_id: "wl_1", + quantity: 1, + variant: { + id: "var_1", + product_id: "prod_1", + sku: "SKU-1", + options_text: "Size: M", + track_inventory: false, + media_count: 0, + thumbnail_url: null, + purchasable: true, + in_stock: true, + backorderable: false, + weight: null, + height: null, + width: null, + depth: null, + price: { display_amount: "$10.00" }, + original_price: null, + option_values: [], + }, + }, + ], +} as never; + +describe("wishlist server actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWithAuthRefresh.mockImplementation(async (fn) => fn({ token: "jwt" })); + }); + + it("returns wishlist for authenticated users", async () => { + mockClient.wishlists.list.mockResolvedValue({ + data: [{ id: "wl_1", is_default: true }], + }); + mockClient.wishlists.get.mockResolvedValue(wishlistFixture); + + const result = await getWishlist(); + + expect(result).toEqual(wishlistFixture); + expect(mockClient.wishlists.get).toHaveBeenCalledWith( + "wl_1", + { expand: ["items.variant"] }, + { token: "jwt" }, + ); + }); + + it("adds item when variant is not present", async () => { + mockClient.wishlists.list.mockResolvedValue({ + data: [{ id: "wl_1", is_default: true }], + }); + mockClient.wishlists.get + .mockResolvedValueOnce({ ...wishlistFixture, items: [] }) + .mockResolvedValueOnce({ + ...wishlistFixture, + items: [...wishlistFixture.items, { id: "wi_2", variant_id: "var_2" }], + }); + + const result = await addWishlistItem("var_2", 1); + + expect(mockClient.wishlists.items.create).toHaveBeenCalledWith( + "wl_1", + { variant_id: "var_2", quantity: 1 }, + { token: "jwt" }, + ); + expect(result.success).toBe(true); + }); + + it("removes item by variant id", async () => { + mockClient.wishlists.list.mockResolvedValue({ + data: [{ id: "wl_1", is_default: true }], + }); + mockClient.wishlists.get + .mockResolvedValueOnce(wishlistFixture) + .mockResolvedValueOnce({ ...wishlistFixture, items: [] }); + + const result = await removeWishlistItemByVariant("var_1"); + + expect(mockClient.wishlists.items.delete).toHaveBeenCalledWith( + "wl_1", + "wi_1", + { token: "jwt" }, + ); + expect(result.success).toBe(true); + }); +}); diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts index 838e1ca7..06091670 100644 --- a/src/lib/data/index.ts +++ b/src/lib/data/index.ts @@ -10,3 +10,4 @@ export * from "./customer"; export * from "./orders"; export * from "./policies"; export * from "./products"; +export * from "./wishlist"; diff --git a/src/lib/data/wishlist.ts b/src/lib/data/wishlist.ts new file mode 100644 index 00000000..bc2235eb --- /dev/null +++ b/src/lib/data/wishlist.ts @@ -0,0 +1,106 @@ +"use server"; + +import type { Wishlist } from "@spree/sdk"; +import { updateTag } from "next/cache"; +import { getClient, isAuthError, withAuthRefresh } from "@/lib/spree"; +import { actionResult } from "./utils"; + +async function fetchWishlistById(id: string, token: string): Promise { + return getClient().wishlists.get( + id, + { expand: ["items.variant"] }, + { token }, + ); +} + +async function getOrCreateDefaultWishlist(token: string): Promise { + const response = await getClient().wishlists.list({ limit: 50 }, { token }); + const existing = + response.data.find((wishlist) => wishlist.is_default) ?? response.data[0]; + + if (existing) { + return fetchWishlistById(existing.id, token); + } + + const created = await getClient().wishlists.create( + { + name: "My Wishlist", + is_default: true, + }, + { token }, + ); + + return fetchWishlistById(created.id, token); +} + +export async function getWishlist(): Promise { + try { + return await withAuthRefresh(async (options) => { + if (!options.token) return null; + return getOrCreateDefaultWishlist(options.token); + }); + } catch (error) { + if (!isAuthError(error)) { + throw error; + } + return null; + } +} + +export async function addWishlistItem(variantId: string, quantity = 1) { + return actionResult(async () => { + const wishlist = await withAuthRefresh(async (options) => { + if (!options.token) { + throw new Error("Not authenticated"); + } + + const current = await getOrCreateDefaultWishlist(options.token); + const existingItem = current.items?.find( + (item) => item.variant_id === variantId, + ); + + if (!existingItem) { + await getClient().wishlists.items.create( + current.id, + { variant_id: variantId, quantity }, + options, + ); + } + + return fetchWishlistById(current.id, options.token); + }); + + updateTag("wishlist"); + return { wishlist }; + }, "Failed to add item to wishlist"); +} + +export async function removeWishlistItemByVariant(variantId: string) { + return actionResult(async () => { + const wishlist = await withAuthRefresh(async (options) => { + if (!options.token) { + throw new Error("Not authenticated"); + } + + const current = await getOrCreateDefaultWishlist(options.token); + const existingItem = current.items?.find( + (item) => item.variant_id === variantId, + ); + + if (!existingItem) { + return current; + } + + await getClient().wishlists.items.delete( + current.id, + existingItem.id, + options, + ); + + return fetchWishlistById(current.id, options.token); + }); + + updateTag("wishlist"); + return { wishlist }; + }, "Failed to remove item from wishlist"); +} From 55d480e4a12c69287b9b13adf52f22637a03ccfd Mon Sep 17 00:00:00 2001 From: Abdul Haseeb Date: Fri, 24 Jul 2026 21:57:46 +0500 Subject: [PATCH 2/9] fix(wishlist): resolve typecheck failures and polish wishlist UX/accessibility flows --- e2e/wishlist.spec.ts | 10 +++++++--- .../[locale]/(storefront)/account/layout.tsx | 19 +++++++++++++++++-- src/components/layout/WishlistNavButton.tsx | 4 +++- .../wishlist/WishlistPageContent.tsx | 4 +--- .../__tests__/WishlistContext.test.tsx | 10 +++++++--- src/lib/data/__tests__/wishlist.test.ts | 8 ++++++-- 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/e2e/wishlist.spec.ts b/e2e/wishlist.spec.ts index d5fc00f5..cf2a3cc5 100644 --- a/e2e/wishlist.spec.ts +++ b/e2e/wishlist.spec.ts @@ -11,7 +11,7 @@ const BASE = "/us/en"; test("guest is redirected to account when clicking wishlist on PDP", async ({ page, -}) => { +}): Promise => { await page.goto(`${BASE}/products`); const firstProduct = page.locator('a[href*="/products/"]').first(); @@ -31,7 +31,7 @@ test("guest is redirected to account when clicking wishlist on PDP", async ({ test("authenticated user can add and remove a wishlist item", async ({ page, -}) => { +}): Promise => { const email = `wishlist-e2e-${Date.now()}@example.com`; const password = "Password123!"; @@ -67,7 +67,11 @@ test("authenticated user can add and remove a wishlist item", async ({ ).toBeVisible({ timeout: 10_000 }); }); -async function registerUser(page: Page, email: string, password: string) { +async function registerUser( + page: Page, + email: string, + password: string, +): Promise { await page.goto(`${BASE}/account/register`); await page.getByLabel(/^first name$/i).fill("Wishlist"); diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/layout.tsx index 6a607b27..28c1cac9 100644 --- a/src/app/[country]/[locale]/(storefront)/account/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/layout.tsx @@ -163,10 +163,18 @@ export default function AccountLayout({ ]); const isAuthPage = authPagePaths.has(pathname); const isMainAccountPage = pathname === `${basePath}/account`; + const isGuestAccessibleAccountPage = + pathname === `${basePath}/account/wishlist`; // Redirect to login if not authenticated and trying to access protected sub-pages useEffect(() => { - if (!loading && !isAuthenticated && !isAuthPage && !isMainAccountPage) { + if ( + !loading && + !isAuthenticated && + !isAuthPage && + !isMainAccountPage && + !isGuestAccessibleAccountPage + ) { router.replace(`${basePath}/account`); } }, [ @@ -174,12 +182,19 @@ export default function AccountLayout({ isAuthenticated, isAuthPage, isMainAccountPage, + isGuestAccessibleAccountPage, basePath, router, ]); // Show loading or redirect-in-progress skeleton - if (loading || (!isAuthenticated && !isAuthPage && !isMainAccountPage)) { + if ( + loading || + (!isAuthenticated && + !isAuthPage && + !isMainAccountPage && + !isGuestAccessibleAccountPage) + ) { if (isAuthPage || isMainAccountPage) { return (
    diff --git a/src/components/layout/WishlistNavButton.tsx b/src/components/layout/WishlistNavButton.tsx index 46221d35..cc5ceb20 100644 --- a/src/components/layout/WishlistNavButton.tsx +++ b/src/components/layout/WishlistNavButton.tsx @@ -13,10 +13,12 @@ export function WishlistNavButton() { const basePath = extractBasePath(pathname); const t = useTranslations("header"); const { itemCount } = useWishlist(); + const ariaLabel = + itemCount > 0 ? `${t("wishlist")} (${itemCount})` : t("wishlist"); return ( - - {selectedVariantId && ( - - )} -
    +
    + {/* // Guest on a prices-hidden channel: no pricing, no ordering — */} + {/* // route them through the wholesale sign-in first. */} + + {selectedVariantId && ( + + )} +
    ) : (
    - {/* Footer: Country switcher (mobile + tablet) + Account (mobile only) */} - - + {/* Footer: region preferences and account links on mobile */} +
    +
    + +
    @@ -299,20 +288,17 @@ export function MobileMenu({ {t("wishlist")} + - {t("myAccount")}
    - {/* Footer: centered Region and language control (mobile only) */} - - - +
    {/* Category sub-panels — one for each level in the stack */} diff --git a/src/components/products/ProductCard.tsx b/src/components/products/ProductCard.tsx index 5373ddaf..8f779001 100644 --- a/src/components/products/ProductCard.tsx +++ b/src/components/products/ProductCard.tsx @@ -65,9 +65,9 @@ export const ProductCard = memo(function ProductCard({
    - {/* Image */}
    {t("sale")} - - {/* Image */} -
    - - {onSale && ( - - {t("sale")} - - )} -
    - - {/* Content */} -
    -

    - {product.name} -

    - -
    - {displayPrice ? ( - - {displayPrice} - ) : ( - // Null price: a deliberate hide inside a HiddenPricingProvider - // (renders a sign-in prompt), otherwise renders nothing. - )}
    - {/* Content */}

    {product.name}

    - {displayPrice && ( + {displayPrice ? ( {displayPrice} + ) : ( + )} + {onSale && strikethroughPrice && ( {strikethroughPrice} From 438d49f3362d13c429bf0bb2a071f04fd2da4bd1 Mon Sep 17 00:00:00 2001 From: Abdul Haseeb Date: Sat, 25 Jul 2026 02:13:20 +0500 Subject: [PATCH 4/9] resolve wishlist item title/image mapping and honor variant media --- .../products/[slug]/ProductDetails.tsx | 8 ++ src/components/layout/MobileMenu.tsx | 49 ++++------ .../wishlist/WishlistPageContent.tsx | 98 ++++++++++++++++++- src/lib/data/__tests__/wishlist.test.ts | 31 +++++- src/lib/data/wishlist.ts | 80 ++++++++++++++- 5 files changed, 231 insertions(+), 35 deletions(-) diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx index ab26d740..b72ef105 100644 --- a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx +++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx @@ -235,6 +235,14 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { t("outOfStock") )} + + {selectedVariantId && ( + + )}
    )}
    diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx index 1e3d9777..f1dc591f 100644 --- a/src/components/layout/MobileMenu.tsx +++ b/src/components/layout/MobileMenu.tsx @@ -12,6 +12,7 @@ import { Sheet, SheetClose, SheetContent, + SheetFooter, SheetTitle, } from "@/components/ui/sheet"; @@ -262,42 +263,34 @@ export function MobileMenu({ {t("wholesale")} )} - - - {t("myAccount")} - -
    {/* Footer: region preferences and account links on mobile */} -
    -
    + +
    +
    -
    - - - {t("wishlist")} - - +
    + + + {t("wishlist")} + + - - - {t("myAccount")} - - -
    + + + {t("myAccount")} + +
    diff --git a/src/components/wishlist/WishlistPageContent.tsx b/src/components/wishlist/WishlistPageContent.tsx index 78e5e747..054bbfb2 100644 --- a/src/components/wishlist/WishlistPageContent.tsx +++ b/src/components/wishlist/WishlistPageContent.tsx @@ -17,20 +17,112 @@ import { extractBasePath } from "@/lib/utils/path"; type VariantWithProduct = Variant & { name?: string; slug?: string; + product_name?: string; + product_slug?: string; + images?: Array<{ + url?: string | null; + original_url?: string | null; + styles?: { + large?: string | null; + product?: string | null; + small?: string | null; + }; + }>; + option_values?: Array<{ + image_url?: string | null; + }>; product?: { name?: string; slug?: string; thumbnail_url?: string | null; + attributes?: { + name?: string; + slug?: string; + thumbnail_url?: string | null; + }; + }; +}; + +type WishlistItemWithDisplay = WishlistItem & { + name?: string; + slug?: string; + product_name?: string; + product_slug?: string; + thumbnail_url?: string | null; + product?: { + name?: string; + slug?: string; + thumbnail_url?: string | null; + images?: Array<{ + url?: string | null; + original_url?: string | null; + styles?: { + large?: string | null; + product?: string | null; + small?: string | null; + }; + }>; + attributes?: { + name?: string; + slug?: string; + thumbnail_url?: string | null; + }; }; }; function getDisplayFields(item: WishlistItem) { + const wishlistItem = item as WishlistItemWithDisplay; const variant = item.variant as VariantWithProduct; + const itemProduct = wishlistItem.product; + const itemProductAttributes = itemProduct?.attributes; + const product = variant.product; + const productAttributes = product?.attributes; + + const variantName = + variant.name && variant.name !== "Product" ? variant.name : undefined; + const productName = - variant.product?.name || variant.name || variant.sku || "Product"; - const productSlug = variant.product?.slug || variant.slug; + itemProduct?.name || + itemProductAttributes?.name || + product?.name || + productAttributes?.name || + wishlistItem.product_name || + variant.product_name || + wishlistItem.name || + variantName || + variant.sku || + "Product"; + + const productSlug = + itemProduct?.slug || + itemProductAttributes?.slug || + product?.slug || + productAttributes?.slug || + wishlistItem.product_slug || + variant.product_slug || + wishlistItem.slug || + variant.slug; + const imageUrl = - variant.thumbnail_url || variant.product?.thumbnail_url || null; + variant.thumbnail_url || + variant.option_values?.find((value) => Boolean(value.image_url)) + ?.image_url || + variant.images?.[0]?.styles?.product || + variant.images?.[0]?.styles?.large || + variant.images?.[0]?.styles?.small || + variant.images?.[0]?.url || + variant.images?.[0]?.original_url || + wishlistItem.thumbnail_url || + itemProduct?.thumbnail_url || + itemProductAttributes?.thumbnail_url || + itemProduct?.images?.[0]?.styles?.product || + itemProduct?.images?.[0]?.styles?.large || + itemProduct?.images?.[0]?.styles?.small || + itemProduct?.images?.[0]?.url || + itemProduct?.images?.[0]?.original_url || + product?.thumbnail_url || + productAttributes?.thumbnail_url || + null; return { productName, productSlug, imageUrl }; } diff --git a/src/lib/data/__tests__/wishlist.test.ts b/src/lib/data/__tests__/wishlist.test.ts index 82d3ffbd..b017e566 100644 --- a/src/lib/data/__tests__/wishlist.test.ts +++ b/src/lib/data/__tests__/wishlist.test.ts @@ -2,6 +2,9 @@ import type { Wishlist } from "@spree/sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockClient = { + products: { + get: vi.fn(), + }, wishlists: { list: vi.fn(), get: vi.fn(), @@ -71,6 +74,13 @@ describe("wishlist server actions", () => { beforeEach(() => { vi.clearAllMocks(); mockWithAuthRefresh.mockImplementation(async (fn) => fn({ token: "jwt" })); + mockClient.products.get.mockResolvedValue({ + id: "prod_1", + name: "Sample Product", + slug: "sample-product", + thumbnail_url: "https://example.com/sample-product.jpg", + images: [], + }); }); it("returns wishlist for authenticated users", async () => { @@ -81,10 +91,27 @@ describe("wishlist server actions", () => { const result = await getWishlist(); - expect(result).toEqual(wishlistFixture); + expect(result).toMatchObject({ + id: wishlistFixture.id, + is_default: wishlistFixture.is_default, + items: [ + expect.objectContaining({ + id: "wi_1", + product_name: "Sample Product", + product_slug: "sample-product", + thumbnail_url: "https://example.com/sample-product.jpg", + }), + ], + }); expect(mockClient.wishlists.get).toHaveBeenCalledWith( "wl_1", - { expand: ["items.variant"] }, + { + expand: [ + "items.variant", + "items.variant.product", + "items.variant.images", + ], + }, { token: "jwt" }, ); }); diff --git a/src/lib/data/wishlist.ts b/src/lib/data/wishlist.ts index bc2235eb..bb3d6d08 100644 --- a/src/lib/data/wishlist.ts +++ b/src/lib/data/wishlist.ts @@ -5,12 +5,88 @@ import { updateTag } from "next/cache"; import { getClient, isAuthError, withAuthRefresh } from "@/lib/spree"; import { actionResult } from "./utils"; +type WishlistItemWithVariantProductId = NonNullable< + Wishlist["items"] +>[number] & { + variant?: { + product_id?: string; + }; + product_name?: string; + product_slug?: string; + thumbnail_url?: string | null; +}; + +async function enrichWishlistItems( + wishlist: Wishlist, + token: string, +): Promise { + if (!wishlist.items?.length) return wishlist; + + const productIds = Array.from( + new Set( + wishlist.items + .map( + (item) => + (item as WishlistItemWithVariantProductId).variant?.product_id, + ) + .filter((id): id is string => Boolean(id)), + ), + ); + + if (productIds.length === 0) return wishlist; + + const products = await Promise.all( + productIds.map(async (id) => { + const product = await getClient().products.get( + id, + { expand: ["images"] }, + { token }, + ); + return [id, product] as const; + }), + ); + + const productMap = new Map(products); + + const items = wishlist.items.map((item) => { + const nextItem = { ...item } as WishlistItemWithVariantProductId; + const productId = nextItem.variant?.product_id; + if (!productId) return nextItem; + + const product = productMap.get(productId); + if (!product) return nextItem; + + nextItem.product_name = nextItem.product_name || product.name; + nextItem.product_slug = nextItem.product_slug || product.slug; + nextItem.thumbnail_url = + nextItem.thumbnail_url || + product.thumbnail_url || + product.images?.[0]?.styles?.product || + product.images?.[0]?.styles?.large || + product.images?.[0]?.styles?.small || + product.images?.[0]?.url || + null; + + return nextItem; + }); + + return { ...wishlist, items }; +} + async function fetchWishlistById(id: string, token: string): Promise { - return getClient().wishlists.get( + const wishlist = await getClient().wishlists.get( id, - { expand: ["items.variant"] }, + { + expand: [ + "items.variant", + "items.variant.product", + "items.variant.images", + ], + }, { token }, ); + + return enrichWishlistItems(wishlist, token); } async function getOrCreateDefaultWishlist(token: string): Promise { From 7e6575a15e67a045477b2564625d25dd40116f26 Mon Sep 17 00:00:00 2001 From: Abdul Haseeb Date: Sat, 25 Jul 2026 15:14:00 +0500 Subject: [PATCH 5/9] use variant-first image fallback and primary media enrichment --- .../wishlist/WishlistPageContent.tsx | 41 ++++--------------- src/lib/data/__tests__/wishlist.test.ts | 8 +--- src/lib/data/wishlist.ts | 26 ++++++------ 3 files changed, 24 insertions(+), 51 deletions(-) diff --git a/src/components/wishlist/WishlistPageContent.tsx b/src/components/wishlist/WishlistPageContent.tsx index 054bbfb2..7c3bf0c1 100644 --- a/src/components/wishlist/WishlistPageContent.tsx +++ b/src/components/wishlist/WishlistPageContent.tsx @@ -19,22 +19,13 @@ type VariantWithProduct = Variant & { slug?: string; product_name?: string; product_slug?: string; - images?: Array<{ - url?: string | null; - original_url?: string | null; - styles?: { - large?: string | null; - product?: string | null; - small?: string | null; - }; - }>; - option_values?: Array<{ - image_url?: string | null; - }>; product?: { name?: string; slug?: string; thumbnail_url?: string | null; + primary_media?: { + original_url?: string | null; + } | null; attributes?: { name?: string; slug?: string; @@ -53,15 +44,9 @@ type WishlistItemWithDisplay = WishlistItem & { name?: string; slug?: string; thumbnail_url?: string | null; - images?: Array<{ - url?: string | null; + primary_media?: { original_url?: string | null; - styles?: { - large?: string | null; - product?: string | null; - small?: string | null; - }; - }>; + } | null; attributes?: { name?: string; slug?: string; @@ -105,22 +90,12 @@ function getDisplayFields(item: WishlistItem) { const imageUrl = variant.thumbnail_url || - variant.option_values?.find((value) => Boolean(value.image_url)) - ?.image_url || - variant.images?.[0]?.styles?.product || - variant.images?.[0]?.styles?.large || - variant.images?.[0]?.styles?.small || - variant.images?.[0]?.url || - variant.images?.[0]?.original_url || - wishlistItem.thumbnail_url || itemProduct?.thumbnail_url || + wishlistItem.thumbnail_url || itemProductAttributes?.thumbnail_url || - itemProduct?.images?.[0]?.styles?.product || - itemProduct?.images?.[0]?.styles?.large || - itemProduct?.images?.[0]?.styles?.small || - itemProduct?.images?.[0]?.url || - itemProduct?.images?.[0]?.original_url || + itemProduct?.primary_media?.original_url || product?.thumbnail_url || + product?.primary_media?.original_url || productAttributes?.thumbnail_url || null; diff --git a/src/lib/data/__tests__/wishlist.test.ts b/src/lib/data/__tests__/wishlist.test.ts index b017e566..4732336c 100644 --- a/src/lib/data/__tests__/wishlist.test.ts +++ b/src/lib/data/__tests__/wishlist.test.ts @@ -79,7 +79,7 @@ describe("wishlist server actions", () => { name: "Sample Product", slug: "sample-product", thumbnail_url: "https://example.com/sample-product.jpg", - images: [], + primary_media: null, }); }); @@ -106,11 +106,7 @@ describe("wishlist server actions", () => { expect(mockClient.wishlists.get).toHaveBeenCalledWith( "wl_1", { - expand: [ - "items.variant", - "items.variant.product", - "items.variant.images", - ], + expand: ["items.variant", "items.variant.product"], }, { token: "jwt" }, ); diff --git a/src/lib/data/wishlist.ts b/src/lib/data/wishlist.ts index bb3d6d08..da9e0dbb 100644 --- a/src/lib/data/wishlist.ts +++ b/src/lib/data/wishlist.ts @@ -16,6 +16,15 @@ type WishlistItemWithVariantProductId = NonNullable< thumbnail_url?: string | null; }; +type ProductWithPrimaryMedia = { + name?: string; + slug?: string; + thumbnail_url?: string | null; + primary_media?: { + original_url?: string | null; + } | null; +}; + async function enrichWishlistItems( wishlist: Wishlist, token: string, @@ -37,11 +46,11 @@ async function enrichWishlistItems( const products = await Promise.all( productIds.map(async (id) => { - const product = await getClient().products.get( + const product = (await getClient().products.get( id, - { expand: ["images"] }, + { expand: ["primary_media"] }, { token }, - ); + )) as ProductWithPrimaryMedia; return [id, product] as const; }), ); @@ -61,10 +70,7 @@ async function enrichWishlistItems( nextItem.thumbnail_url = nextItem.thumbnail_url || product.thumbnail_url || - product.images?.[0]?.styles?.product || - product.images?.[0]?.styles?.large || - product.images?.[0]?.styles?.small || - product.images?.[0]?.url || + product.primary_media?.original_url || null; return nextItem; @@ -77,11 +83,7 @@ async function fetchWishlistById(id: string, token: string): Promise { const wishlist = await getClient().wishlists.get( id, { - expand: [ - "items.variant", - "items.variant.product", - "items.variant.images", - ], + expand: ["items.variant", "items.variant.product"], }, { token }, ); From 4556c556cdc4c41c632eda7202e73308cba1f20e Mon Sep 17 00:00:00 2001 From: Abdul Haseeb Date: Sat, 25 Jul 2026 15:45:05 +0500 Subject: [PATCH 6/9] fix(tests): isolate ProductDetails SKU test from wishlist auth context --- .../(storefront)/products/[slug]/ProductDetails.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx index 03b1c17a..834eba3c 100644 --- a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx +++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.test.tsx @@ -16,6 +16,10 @@ vi.mock("@/components/products/ProductCustomFields", () => ({ ProductCustomFields: () => null, })); +vi.mock("@/components/wishlist/WishlistButton", () => ({ + WishlistButton: () => null, +})); + vi.mock("@/contexts/CartContext", () => ({ useCart: () => ({ addItem: vi.fn() }), })); From 2133aac45987333c01a3818503338fbfaad663b6 Mon Sep 17 00:00:00 2001 From: Abdul Haseeb Date: Sun, 26 Jul 2026 21:12:17 +0500 Subject: [PATCH 7/9] use SDK wishlist/product types and remove primary_media fallback --- src/lib/data/wishlist.ts | 45 ++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/lib/data/wishlist.ts b/src/lib/data/wishlist.ts index da9e0dbb..fe2190c6 100644 --- a/src/lib/data/wishlist.ts +++ b/src/lib/data/wishlist.ts @@ -1,13 +1,13 @@ "use server"; -import type { Wishlist } from "@spree/sdk"; +import type { Product, Wishlist, WishlistItem } from "@spree/sdk"; import { updateTag } from "next/cache"; import { getClient, isAuthError, withAuthRefresh } from "@/lib/spree"; import { actionResult } from "./utils"; -type WishlistItemWithVariantProductId = NonNullable< - Wishlist["items"] ->[number] & { +type EnrichedWishlistItem = WishlistItem & { + product_id?: string; + product?: Product; variant?: { product_id?: string; }; @@ -16,15 +16,6 @@ type WishlistItemWithVariantProductId = NonNullable< thumbnail_url?: string | null; }; -type ProductWithPrimaryMedia = { - name?: string; - slug?: string; - thumbnail_url?: string | null; - primary_media?: { - original_url?: string | null; - } | null; -}; - async function enrichWishlistItems( wishlist: Wishlist, token: string, @@ -34,10 +25,14 @@ async function enrichWishlistItems( const productIds = Array.from( new Set( wishlist.items - .map( - (item) => - (item as WishlistItemWithVariantProductId).variant?.product_id, - ) + .map((item) => { + const enrichedItem = item as EnrichedWishlistItem; + return ( + enrichedItem.product_id || + enrichedItem.variant?.product_id || + enrichedItem.product?.id + ); + }) .filter((id): id is string => Boolean(id)), ), ); @@ -48,9 +43,9 @@ async function enrichWishlistItems( productIds.map(async (id) => { const product = (await getClient().products.get( id, - { expand: ["primary_media"] }, + {}, { token }, - )) as ProductWithPrimaryMedia; + )) as Product; return [id, product] as const; }), ); @@ -58,8 +53,11 @@ async function enrichWishlistItems( const productMap = new Map(products); const items = wishlist.items.map((item) => { - const nextItem = { ...item } as WishlistItemWithVariantProductId; - const productId = nextItem.variant?.product_id; + const nextItem = { ...item } as EnrichedWishlistItem; + const productId = + nextItem.product_id || + nextItem.variant?.product_id || + nextItem.product?.id; if (!productId) return nextItem; const product = productMap.get(productId); @@ -68,10 +66,7 @@ async function enrichWishlistItems( nextItem.product_name = nextItem.product_name || product.name; nextItem.product_slug = nextItem.product_slug || product.slug; nextItem.thumbnail_url = - nextItem.thumbnail_url || - product.thumbnail_url || - product.primary_media?.original_url || - null; + nextItem.thumbnail_url || product.thumbnail_url || null; return nextItem; }); From 5ac262c1bc8fddcb793026d1477a69d6fa5a3eb4 Mon Sep 17 00:00:00 2001 From: Abdul Haseeb Date: Fri, 31 Jul 2026 12:39:14 +0500 Subject: [PATCH 8/9] simplify item enrichment for SDK 1.2.1 --- .../{ => (authenticated)}/wishlist/page.tsx | 0 src/components/account/AccountShell.tsx | 2 ++ src/lib/data/__tests__/wishlist.test.ts | 1 + src/lib/data/wishlist.ts | 26 ++----------------- 4 files changed, 5 insertions(+), 24 deletions(-) rename src/app/[country]/[locale]/(storefront)/account/{ => (authenticated)}/wishlist/page.tsx (100%) diff --git a/src/app/[country]/[locale]/(storefront)/account/wishlist/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/wishlist/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/wishlist/page.tsx rename to src/app/[country]/[locale]/(storefront)/account/(authenticated)/wishlist/page.tsx diff --git a/src/components/account/AccountShell.tsx b/src/components/account/AccountShell.tsx index f63f6168..7c1243fd 100644 --- a/src/components/account/AccountShell.tsx +++ b/src/components/account/AccountShell.tsx @@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react"; import { CreditCard, Gift, + Heart, Home, LogOut, MapPin, @@ -25,6 +26,7 @@ function getNavItems(t: ReturnType>): { return [ { href: "/account", label: t("overview"), icon: Home }, { href: "/account/orders", label: t("orders"), icon: ShoppingBag }, + { href: "/account/wishlist", label: t("wishlist"), icon: Heart }, { href: "/account/addresses", label: t("addresses"), icon: MapPin }, { href: "/account/credit-cards", diff --git a/src/lib/data/__tests__/wishlist.test.ts b/src/lib/data/__tests__/wishlist.test.ts index 4732336c..3260372d 100644 --- a/src/lib/data/__tests__/wishlist.test.ts +++ b/src/lib/data/__tests__/wishlist.test.ts @@ -44,6 +44,7 @@ const wishlistFixture = { items: [ { id: "wi_1", + product_id: "prod_1", variant_id: "var_1", wishlist_id: "wl_1", quantity: 1, diff --git a/src/lib/data/wishlist.ts b/src/lib/data/wishlist.ts index fe2190c6..ef897262 100644 --- a/src/lib/data/wishlist.ts +++ b/src/lib/data/wishlist.ts @@ -6,11 +6,6 @@ import { getClient, isAuthError, withAuthRefresh } from "@/lib/spree"; import { actionResult } from "./utils"; type EnrichedWishlistItem = WishlistItem & { - product_id?: string; - product?: Product; - variant?: { - product_id?: string; - }; product_name?: string; product_slug?: string; thumbnail_url?: string | null; @@ -23,18 +18,7 @@ async function enrichWishlistItems( if (!wishlist.items?.length) return wishlist; const productIds = Array.from( - new Set( - wishlist.items - .map((item) => { - const enrichedItem = item as EnrichedWishlistItem; - return ( - enrichedItem.product_id || - enrichedItem.variant?.product_id || - enrichedItem.product?.id - ); - }) - .filter((id): id is string => Boolean(id)), - ), + new Set(wishlist.items.map((item) => item.product_id).filter(Boolean)), ); if (productIds.length === 0) return wishlist; @@ -54,13 +38,7 @@ async function enrichWishlistItems( const items = wishlist.items.map((item) => { const nextItem = { ...item } as EnrichedWishlistItem; - const productId = - nextItem.product_id || - nextItem.variant?.product_id || - nextItem.product?.id; - if (!productId) return nextItem; - - const product = productMap.get(productId); + const product = productMap.get(nextItem.product_id); if (!product) return nextItem; nextItem.product_name = nextItem.product_name || product.name; From 47e2e788158c5a05da4b1251e7d5b613c75a9894 Mon Sep 17 00:00:00 2001 From: Abdul Haseeb Date: Fri, 31 Jul 2026 14:07:37 +0500 Subject: [PATCH 9/9] avoid N+1 product calls by expanding items.product --- src/lib/data/__tests__/wishlist.test.ts | 18 ++++++-------- src/lib/data/wishlist.ts | 32 ++++--------------------- 2 files changed, 12 insertions(+), 38 deletions(-) diff --git a/src/lib/data/__tests__/wishlist.test.ts b/src/lib/data/__tests__/wishlist.test.ts index 3260372d..cc79c1b2 100644 --- a/src/lib/data/__tests__/wishlist.test.ts +++ b/src/lib/data/__tests__/wishlist.test.ts @@ -2,9 +2,6 @@ import type { Wishlist } from "@spree/sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockClient = { - products: { - get: vi.fn(), - }, wishlists: { list: vi.fn(), get: vi.fn(), @@ -48,6 +45,12 @@ const wishlistFixture = { variant_id: "var_1", wishlist_id: "wl_1", quantity: 1, + product: { + id: "prod_1", + name: "Sample Product", + slug: "sample-product", + thumbnail_url: "https://example.com/sample-product.jpg", + }, variant: { id: "var_1", product_id: "prod_1", @@ -75,13 +78,6 @@ describe("wishlist server actions", () => { beforeEach(() => { vi.clearAllMocks(); mockWithAuthRefresh.mockImplementation(async (fn) => fn({ token: "jwt" })); - mockClient.products.get.mockResolvedValue({ - id: "prod_1", - name: "Sample Product", - slug: "sample-product", - thumbnail_url: "https://example.com/sample-product.jpg", - primary_media: null, - }); }); it("returns wishlist for authenticated users", async () => { @@ -107,7 +103,7 @@ describe("wishlist server actions", () => { expect(mockClient.wishlists.get).toHaveBeenCalledWith( "wl_1", { - expand: ["items.variant", "items.variant.product"], + expand: ["items.product"], }, { token: "jwt" }, ); diff --git a/src/lib/data/wishlist.ts b/src/lib/data/wishlist.ts index ef897262..b1c16915 100644 --- a/src/lib/data/wishlist.ts +++ b/src/lib/data/wishlist.ts @@ -1,6 +1,6 @@ "use server"; -import type { Product, Wishlist, WishlistItem } from "@spree/sdk"; +import type { Wishlist, WishlistItem } from "@spree/sdk"; import { updateTag } from "next/cache"; import { getClient, isAuthError, withAuthRefresh } from "@/lib/spree"; import { actionResult } from "./utils"; @@ -11,34 +11,12 @@ type EnrichedWishlistItem = WishlistItem & { thumbnail_url?: string | null; }; -async function enrichWishlistItems( - wishlist: Wishlist, - token: string, -): Promise { +async function enrichWishlistItems(wishlist: Wishlist): Promise { if (!wishlist.items?.length) return wishlist; - const productIds = Array.from( - new Set(wishlist.items.map((item) => item.product_id).filter(Boolean)), - ); - - if (productIds.length === 0) return wishlist; - - const products = await Promise.all( - productIds.map(async (id) => { - const product = (await getClient().products.get( - id, - {}, - { token }, - )) as Product; - return [id, product] as const; - }), - ); - - const productMap = new Map(products); - const items = wishlist.items.map((item) => { const nextItem = { ...item } as EnrichedWishlistItem; - const product = productMap.get(nextItem.product_id); + const product = nextItem.product; if (!product) return nextItem; nextItem.product_name = nextItem.product_name || product.name; @@ -56,12 +34,12 @@ async function fetchWishlistById(id: string, token: string): Promise { const wishlist = await getClient().wishlists.get( id, { - expand: ["items.variant", "items.variant.product"], + expand: ["items.product"], }, { token }, ); - return enrichWishlistItems(wishlist, token); + return enrichWishlistItems(wishlist); } async function getOrCreateDefaultWishlist(token: string): Promise {