diff --git a/e2e/wishlist.spec.ts b/e2e/wishlist.spec.ts new file mode 100644 index 00000000..cf2a3cc5 --- /dev/null +++ b/e2e/wishlist.spec.ts @@ -0,0 +1,87 @@ +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, +}): Promise => { + 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, +}): Promise => { + 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, +): Promise { + 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 6061798f..d37011c1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -41,6 +41,7 @@ "openSearch": "Suche öffnen", "closeSearch": "Suche schließen", "myAccount": "Mein Konto", + "wishlist": "Wunschliste", "wholesale": "Großhandel" }, "regionPreferences": { @@ -67,6 +68,7 @@ "policies": "Richtlinien", "poweredBy": "Betrieben von", "copyright": "© {year} {storeName}. Unterstützt durch Spree Commerce.", + "wishlist": "Wunschliste", "wholesale": "Handel & Großhandel" }, "home": { @@ -332,7 +334,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", @@ -546,6 +550,26 @@ "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": "♥" + }, "wholesale": { "portalTitle": "Großhandelsportal", "portalName": "Großhandelsportal", diff --git a/messages/en.json b/messages/en.json index 8c5a8807..6cabf843 100644 --- a/messages/en.json +++ b/messages/en.json @@ -41,6 +41,7 @@ "openSearch": "Open search", "closeSearch": "Close search", "myAccount": "My Account", + "wishlist": "Wishlist", "wholesale": "Wholesale" }, "regionPreferences": { @@ -67,6 +68,7 @@ "policies": "Policies", "poweredBy": "Powered by", "copyright": "© {year} {storeName}. Powered by Spree Commerce.", + "wishlist": "Wishlist", "wholesale": "Trade & Wholesale" }, "home": { @@ -332,7 +334,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", @@ -546,6 +550,26 @@ "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": "♥" + }, "wholesale": { "portalTitle": "Wholesale Portal", "portalName": "Wholesale Portal", diff --git a/messages/es.json b/messages/es.json index 9db1c1d4..fce09091 100644 --- a/messages/es.json +++ b/messages/es.json @@ -41,6 +41,7 @@ "openSearch": "Abrir busqueda", "closeSearch": "Cerrar busqueda", "myAccount": "Mi cuenta", + "wishlist": "Lista de deseos", "wholesale": "Mayorista" }, "regionPreferences": { @@ -67,6 +68,7 @@ "policies": "Politicas", "poweredBy": "Impulsado por", "copyright": "© {year} {storeName}. Impulsado por Spree Commerce.", + "wishlist": "Lista de deseos", "wholesale": "Comercio y venta al por mayor" }, "home": { @@ -332,7 +334,9 @@ "showPassword": "Mostrar contrasena", "hidePassword": "Ocultar contrasena", "forgotPassword": "¿Olvidaste tu contrasena?", - "policyConsentRequired": "Debes aceptar las politicas de la tienda para crear una cuenta" + "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", @@ -546,6 +550,26 @@ "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": "♥" + }, "wholesale": { "portalTitle": "Portal mayorista", "portalName": "Portal mayorista", diff --git a/messages/fr.json b/messages/fr.json index 81e2ecaf..2fa54050 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -41,6 +41,7 @@ "openSearch": "Ouvrir la recherche", "closeSearch": "Fermer la recherche", "myAccount": "Mon compte", + "wishlist": "Liste d'envies", "wholesale": "Vente en gros" }, "regionPreferences": { @@ -67,6 +68,7 @@ "policies": "Politiques", "poweredBy": "Propulse par", "copyright": "© {year} {storeName}. Propulse par Spree Commerce.", + "wishlist": "Liste d'envies", "wholesale": "Professionnels & vente en gros" }, "home": { @@ -332,7 +334,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", @@ -546,6 +550,26 @@ "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": "♥" + }, "wholesale": { "portalTitle": "Portail grossiste", "portalName": "Portail grossiste", diff --git a/messages/pl.json b/messages/pl.json index 22ac0a33..af04cb28 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -41,6 +41,7 @@ "openSearch": "Otwórz wyszukiwanie", "closeSearch": "Zamknij wyszukiwanie", "myAccount": "Moje konto", + "wishlist": "Lista życzeń", "wholesale": "Hurt" }, "regionPreferences": { @@ -67,6 +68,7 @@ "policies": "Regulaminy", "poweredBy": "Zasilany przez", "copyright": "© {year} {storeName}. Zasilany przez Spree Commerce.", + "wishlist": "Lista życzeń", "wholesale": "Handel i sprzedaż hurtowa" }, "home": { @@ -332,7 +334,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", @@ -546,6 +550,26 @@ "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": "♥" + }, "wholesale": { "portalTitle": "Portal hurtowy", "portalName": "Portal hurtowy", diff --git a/src/app/[country]/[locale]/(storefront)/account/(authenticated)/wishlist/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/wishlist/page.tsx new file mode 100644 index 00000000..4261143f --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/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)/account/page.tsx b/src/app/[country]/[locale]/(storefront)/account/page.tsx index 878116b1..c117f9b2 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, @@ -212,6 +213,24 @@ export default function AccountPage() { + + + +
+ +
+
+

+ {t("wishlist")} +

+

+ {t("wishlistDescription")} +

+
+
+
+ + 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() }), })); diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx index a08da778..23ffcebf 100644 --- a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx +++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx @@ -11,6 +11,7 @@ import { MediaGallery } from "@/components/products/MediaGallery"; import { ProductCustomFields } from "@/components/products/ProductCustomFields"; import { VariantPicker } from "@/components/products/VariantPicker"; import { Button } from "@/components/ui/button"; +import { WishlistButton } from "@/components/wishlist/WishlistButton"; import { useCart } from "@/contexts/CartContext"; import { useHiddenPricing } from "@/contexts/HiddenPricingContext"; import { useStore } from "@/contexts/StoreContext"; @@ -120,6 +121,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 (
@@ -187,13 +193,22 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { {/* Quantity & Add to Cart */}
{pricesHidden ? ( - // Guest on a prices-hidden channel: no pricing, no ordering — - // route them through the wholesale sign-in first. - +
+ {/* // Guest on a prices-hidden channel: no pricing, no ordering — */} + {/* // route them through the wholesale sign-in first. */} + + {selectedVariantId && ( + + )} +
) : (
+ + {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 faf27431..02572c87 100644 --- a/src/app/[country]/[locale]/layout.tsx +++ b/src/app/[country]/[locale]/layout.tsx @@ -11,6 +11,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 { DEFAULT_LOCALE, loadMessages, @@ -199,12 +200,14 @@ function CountryLocaleProviders({ initialMarkets={markets} > - - - {children} - - - + + + + {children} + + + + 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/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 3604d8ec..8a03bbd5 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -128,6 +128,14 @@ export async function Footer({ basePath, locale, categoryLinks }: FooterProps) { {t("orderHistory")} +
  • + + {t("wishlist")} + +
  • + {/* Wishlist */} + + {/* Cart */} diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx index cacb5a1f..f1dc591f 100644 --- a/src/components/layout/MobileMenu.tsx +++ b/src/components/layout/MobileMenu.tsx @@ -216,6 +216,13 @@ export function MobileMenu({ > {t("allProducts")} + setOpen(false)} + className={linkClass} + > + {t("wishlist")} + {rootCategories.map((category) => category.children && category.children.length > 0 ? (
    - {/* Footer: centered Region and language control (mobile only) */} + {/* Footer: region preferences and account links on mobile */} - +
    + +
    + +
    + + + {t("wishlist")} + + + + + + {t("myAccount")} + + +
    {/* Category sub-panels — one for each level in the stack */} diff --git a/src/components/layout/WishlistNavButton.tsx b/src/components/layout/WishlistNavButton.tsx new file mode 100644 index 00000000..cc5ceb20 --- /dev/null +++ b/src/components/layout/WishlistNavButton.tsx @@ -0,0 +1,31 @@ +"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(); + const ariaLabel = + itemCount > 0 ? `${t("wishlist")} (${itemCount})` : t("wishlist"); + + return ( + + ); +} diff --git a/src/components/products/ProductCard.tsx b/src/components/products/ProductCard.tsx index c97627c5..6c4b8b33 100644 --- a/src/components/products/ProductCard.tsx +++ b/src/components/products/ProductCard.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { memo } from "react"; import { HiddenPricePrompt } from "@/components/products/HiddenPricePrompt"; import { ProductImage } from "@/components/ui/product-image"; +import { WishlistButton } from "@/components/wishlist/WishlistButton"; import { trackSelectItem } from "@/lib/analytics/gtm"; interface ProductCardProps { @@ -116,6 +117,12 @@ export const ProductCard = memo(function ProductCard({ {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..7c3bf0c1 --- /dev/null +++ b/src/components/wishlist/WishlistPageContent.tsx @@ -0,0 +1,286 @@ +"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; + product_slug?: string; + product?: { + name?: string; + slug?: string; + thumbnail_url?: string | null; + primary_media?: { + original_url?: string | null; + } | 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; + primary_media?: { + original_url?: string | null; + } | 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 = + 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 || + itemProduct?.thumbnail_url || + wishlistItem.thumbnail_url || + itemProductAttributes?.thumbnail_url || + itemProduct?.primary_media?.original_url || + product?.thumbnail_url || + product?.primary_media?.original_url || + productAttributes?.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..0221f4cd --- /dev/null +++ b/src/contexts/__tests__/WishlistContext.test.tsx @@ -0,0 +1,139 @@ +import type { Wishlist } from "@spree/sdk"; +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 unknown as Wishlist; + +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 unknown as Wishlist; + + 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..cc79c1b2 --- /dev/null +++ b/src/lib/data/__tests__/wishlist.test.ts @@ -0,0 +1,153 @@ +import type { Wishlist } from "@spree/sdk"; +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", + product_id: "prod_1", + 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", + 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 unknown as Wishlist; + +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).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.product"], + }, + { 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..b1c16915 --- /dev/null +++ b/src/lib/data/wishlist.ts @@ -0,0 +1,135 @@ +"use server"; + +import type { Wishlist, WishlistItem } from "@spree/sdk"; +import { updateTag } from "next/cache"; +import { getClient, isAuthError, withAuthRefresh } from "@/lib/spree"; +import { actionResult } from "./utils"; + +type EnrichedWishlistItem = WishlistItem & { + product_name?: string; + product_slug?: string; + thumbnail_url?: string | null; +}; + +async function enrichWishlistItems(wishlist: Wishlist): Promise { + if (!wishlist.items?.length) return wishlist; + + const items = wishlist.items.map((item) => { + const nextItem = { ...item } as EnrichedWishlistItem; + const product = nextItem.product; + 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 || null; + + return nextItem; + }); + + return { ...wishlist, items }; +} + +async function fetchWishlistById(id: string, token: string): Promise { + const wishlist = await getClient().wishlists.get( + id, + { + expand: ["items.product"], + }, + { token }, + ); + + return enrichWishlistItems(wishlist); +} + +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"); +}