diff --git a/.env.example b/.env.example index 577a51ef..ab55f9d0 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,21 @@ SPREE_API_URL=http://localhost:3000 SPREE_PUBLISHABLE_KEY=your_publishable_api_key +# Wholesale B2B portal (opt-in addon - powers the gated /wholesale surface) +# +# SPREE_WHOLESALE_CHANNEL is the ON/OFF switch. There is no default: leave it +# unset and the storefront runs DTC-only — every wholesale entry point (nav, +# footer, homepage section) is hidden and the /wholesale routes 404. Set it to +# the code of a gated Spree channel to enable the portal. Requests to that +# surface carry the code via the X-Spree-Channel header. +# +# SPREE_WHOLESALE_PUBLISHABLE_KEY is optional. The channel header alone selects +# the channel, so this falls back to SPREE_PUBLISHABLE_KEY when unset; set it +# only to bind a channel-scoped publishable key. +# +# SPREE_WHOLESALE_CHANNEL=wholesale +# SPREE_WHOLESALE_PUBLISHABLE_KEY= + # Store defaults (should match your Spree store settings) # These are used by the middleware for initial redirects before API data is loaded # Set these to your store's default_country_iso and default_locale diff --git a/.gitignore b/.gitignore index 3cd5af83..609d88ff 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ next-env.d.ts # claude code .claude/ + diff --git a/README.md b/README.md index 388fd874..64147290 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ SPREE_PUBLISHABLE_KEY=your_publishable_api_key_here | `SENTRY_ORG` | Sentry organization slug (for source map uploads) | _(none)_ | | `SENTRY_PROJECT` | Sentry project slug (for source map uploads) | _(none)_ | | `SENTRY_AUTH_TOKEN` | Sentry auth token (for source map uploads in CI) | _(none)_ | +| `SPREE_WHOLESALE_CHANNEL` | Enable switch for the wholesale B2B portal — the code of a gated Spree channel to bind the `/wholesale` surface to. No default: unset means DTC-only (see below) | _(disabled)_ | +| `SPREE_WHOLESALE_PUBLISHABLE_KEY` | Channel-scoped publishable key for the wholesale surface. Optional — the channel header selects the channel, so this falls back to `SPREE_PUBLISHABLE_KEY` | _(falls back to `SPREE_PUBLISHABLE_KEY`)_ | | `SPREE_WEBHOOK_SECRET` | Webhook endpoint secret key (for transactional emails) | _(disabled)_ | | `RESEND_API_KEY` | [Resend](https://resend.com) API key for sending emails in production | _(dev: writes to disk)_ | | `EMAIL_FROM` | "From" address for transactional emails (e.g. `Store `) | `orders@example.com` | @@ -129,6 +131,14 @@ SPREE_PUBLISHABLE_KEY=your_publishable_api_key_here > **Privacy note:** PII collection is disabled by default. Only set `SENTRY_SEND_DEFAULT_PII` / `NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PII` to `true` if you have appropriate user consent or a privacy policy covering this data. +#### Wholesale B2B portal (opt-in addon) + +The storefront ships an optional gated wholesale portal at `/wholesale`, a B2B surface that runs on a separate Spree channel while sharing the same storefront and backend as DTC. + +`SPREE_WHOLESALE_CHANNEL` is the enable switch and has **no default**. Leave it unset and the storefront is DTC-only: the wholesale nav link, footer link, homepage section, and the `/wholesale` routes are all hidden — the routes return 404. Set it to the code of a gated channel on your Spree backend (e.g. `wholesale`) to turn the portal on. `SPREE_WHOLESALE_PUBLISHABLE_KEY` is optional and only needed to bind a channel-scoped publishable key; without it the surface reuses `SPREE_PUBLISHABLE_KEY` and selects the channel via the `X-Spree-Channel` header. + +See the [wholesale portal guide](https://spreecommerce.org/docs/developer/storefront/nextjs/wholesale) for how channel switching works end to end. + ### Development ```bash diff --git a/messages/de.json b/messages/de.json index 373c3b77..03f5cfa7 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", + "wholesale": "Großhandel" }, "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.", + "wholesale": "Handel & Großhandel" }, "home": { "welcome": "{storeName} Storefront", @@ -68,7 +70,18 @@ "fastShipping": "Schneller Versand", "shippingDescription": "Schnelle und zuverlässige Lieferung bis an Ihre Haustür.", "support": "24/7 Support", - "supportDescription": "Unser Team ist jederzeit für Sie da." + "supportDescription": "Unser Team ist jederzeit für Sie da.", + "wholesaleBadge": "Handel & Großhandel", + "wholesaleTitle": "Einkauf für Ihr Unternehmen?", + "wholesaleDescription": "Freigegebene Geschäftskunden erhalten Großhandelspreise auf den gesamten Katalog, schnelle Sammelbestellung per SKU und ihren eigenen Bestellverlauf. Einmal bewerben — wir prüfen und aktivieren Ihr Konto.", + "wholesaleCtaPrimary": "Zum Großhandelsportal", + "wholesaleCtaSecondary": "Konto beantragen", + "wholesaleBenefitPricingTitle": "Handelspreise", + "wholesaleBenefitPricingDescription": "Ihre ausgehandelten Konditionen werden automatisch auf den gesamten Katalog angewendet.", + "wholesaleBenefitQuickOrderTitle": "Schnelle Sammelbestellung", + "wholesaleBenefitQuickOrderDescription": "Geben Sie SKUs und Mengen ein, um in Sekunden eine große Bestellung zusammenzustellen.", + "wholesaleBenefitOrdersTitle": "Bestellverlauf", + "wholesaleBenefitOrdersDescription": "Sehen Sie vergangene Bestellungen ein und bestellen Sie Ihre Standardartikel mit wenigen Klicks nach." }, "cart": { "cart": "Warenkorb", @@ -524,5 +537,88 @@ "shipping": "Versand", "finalizingPayment": "Zahlung wird abgeschlossen...", "or": "oder" + }, + "wholesale": { + "portalTitle": "Großhandelsportal", + "portalName": "Großhandelsportal", + "badge": "Großhandel", + "nav": { + "catalog": "Katalog", + "quickOrder": "Schnellbestellung", + "cart": "Warenkorb", + "backToStore": "Zurück zum Shop", + "signOut": "Abmelden", + "signIn": "Anmelden" + }, + "signInWall": { + "title": "Handelspreise für freigegebene Käufer", + "description": "Melden Sie sich bei Ihrem Großhandelskonto an, um den gesamten Katalog zu Ihren ausgehandelten Handelspreisen zu durchsuchen und Sammelbestellungen aufzugeben.", + "gatedNotice": "Dieses Portal steht ausschließlich freigegebenen Großhandelskäufern zur Verfügung. Preise und Produkte sind nach der Anmeldung sichtbar.", + "noAccount": "Sie haben kein Großhandelskonto?", + "applyLink": "Zugang beantragen", + "formTitle": "Anmelden", + "formDescription": "Verwenden Sie die E-Mail-Adresse und das Passwort Ihres Großhandelskontos.", + "submit": "Anmelden" + }, + "pending": { + "title": "Ihre Bewerbung wird geprüft", + "description": "Vielen Dank für Ihre Bewerbung. Ihr Konto ist eingerichtet, aber der Großhandelszugang steht noch zur Genehmigung aus.", + "nameLabel": "Name", + "emailLabel": "E-Mail", + "whatNext": "Unser Team prüft neue Großhandelsbewerbungen und aktiviert nach der Genehmigung die Handelspreise. Sobald Ihr Konto aktiviert ist, können Sie sich hier anmelden.", + "support": "Fragen? Kontaktieren Sie wholesale@example.com" + }, + "plp": { + "title": "Großhandelskatalog", + "subtitle": "Ihre Handelspreise werden an der Kasse automatisch angewendet.", + "empty": "Im Großhandelskatalog sind noch keine Produkte verfügbar.", + "noMatchingProducts": "Keine Produkte entsprechen \"{query}\".", + "tradePriceNote": "Alle angezeigten Katalogpreise sind Handelspreise und gelten für Bestellungen ab {min} pro Artikel." + }, + "pdp": { + "tradePriceHint": "Handelspreis gilt für Bestellungen ab {min}" + }, + "cart": { + "title": "Großhandels-Warenkorb", + "emptyDescription": "Fügen Sie Produkte aus dem Katalog hinzu oder nutzen Sie die Schnellbestellung, um zu beginnen.", + "browseCatalog": "Katalog durchsuchen", + "unlockNudge": "{count} weitere hinzufügen, um Handelspreise freizuschalten", + "tradePriceApplied": "Handelspreis angewendet" + }, + "quickOrder": { + "title": "Schnellbestellung", + "subtitle": "Geben Sie SKUs und Mengen ein, um mehrere Produkte auf einmal zu Ihrem Warenkorb hinzuzufügen.", + "skuHeader": "SKU", + "qtyHeader": "Menge", + "actionsHeader": "Aktionen", + "skuPlaceholder": "SKU eingeben", + "addRow": "Zeile hinzufügen", + "removeRow": "Zeile entfernen", + "addAll": "Alle zum Warenkorb hinzufügen", + "adding": "Wird hinzugefügt…", + "added": "{name} hinzugefügt", + "skuNotFound": "Kein Produkt für diese SKU gefunden.", + "notPurchasable": "Dieser Artikel ist nicht käuflich verfügbar.", + "addFailed": "Dieser Artikel konnte nicht hinzugefügt werden. Bitte versuchen Sie es erneut.", + "searching": "Suche läuft…", + "noResults": "Keine Produkte gefunden" + }, + "apply": { + "title": "Großhandelszugang beantragen", + "description": "Erzählen Sie uns von Ihrem Unternehmen. Wir prüfen Ihre Bewerbung und aktivieren nach der Genehmigung die Handelspreise.", + "companyLabel": "Firmenname", + "companyPlaceholder": "Acme Trading Co.", + "phoneLabel": "Telefon", + "phonePlaceholder": "+1 (555) 000-0000", + "submit": "Bewerbung absenden", + "alreadyMember": "Sie haben bereits ein Großhandelskonto?", + "receivedTitle": "Bewerbung eingegangen", + "receivedDescription": "Vielen Dank für Ihre Bewerbung. Wir prüfen Ihr Konto und geben den Großhandelszugang in Kürze frei.", + "goToPortal": "Zum Portal" + }, + "hiddenPrice": { + "signInForPricing": "Zur Preisanzeige anmelden", + "signInToOrder": "Zum Bestellen anmelden" + } } } diff --git a/messages/en.json b/messages/en.json index 5dd69a47..f9137543 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", + "wholesale": "Wholesale" }, "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.", + "wholesale": "Trade & Wholesale" }, "home": { "welcome": "{storeName} Storefront", @@ -68,7 +70,18 @@ "fastShipping": "Fast Shipping", "shippingDescription": "Quick and reliable delivery to your doorstep.", "support": "24/7 Support", - "supportDescription": "Our team is here to help you anytime." + "supportDescription": "Our team is here to help you anytime.", + "wholesaleBadge": "Trade & Wholesale", + "wholesaleTitle": "Buying for your business?", + "wholesaleDescription": "Approved trade buyers get wholesale pricing on the full catalog, bulk quick-order by SKU, and their own order history. Apply once — we'll review and activate your account.", + "wholesaleCtaPrimary": "Enter wholesale portal", + "wholesaleCtaSecondary": "Apply for an account", + "wholesaleBenefitPricingTitle": "Trade pricing", + "wholesaleBenefitPricingDescription": "Your negotiated rates applied automatically across the full catalog.", + "wholesaleBenefitQuickOrderTitle": "Bulk quick-order", + "wholesaleBenefitQuickOrderDescription": "Enter SKUs and quantities to build a large order in seconds.", + "wholesaleBenefitOrdersTitle": "Order history", + "wholesaleBenefitOrdersDescription": "Review past orders and reorder your regular lines in a couple of clicks." }, "cart": { "cart": "Cart", @@ -524,5 +537,88 @@ "shipping": "Shipping", "finalizingPayment": "Finalizing your payment...", "or": "or" + }, + "wholesale": { + "portalTitle": "Wholesale Portal", + "portalName": "Wholesale Portal", + "badge": "Wholesale", + "nav": { + "catalog": "Catalog", + "quickOrder": "Quick Order", + "cart": "Cart", + "backToStore": "Back to store", + "signOut": "Sign out", + "signIn": "Sign in" + }, + "signInWall": { + "title": "Trade pricing for approved buyers", + "description": "Sign in to your wholesale account to browse the full catalog at your negotiated trade pricing and place bulk orders.", + "gatedNotice": "This portal is available to approved wholesale buyers only. Prices and products are visible after you sign in.", + "noAccount": "Don't have a wholesale account?", + "applyLink": "Apply for access", + "formTitle": "Sign in", + "formDescription": "Use the email and password for your wholesale account.", + "submit": "Sign in" + }, + "pending": { + "title": "Your application is under review", + "description": "Thanks for applying. Your account is set up, but wholesale access is still pending approval.", + "nameLabel": "Name", + "emailLabel": "Email", + "whatNext": "Our team reviews new wholesale applications and enables trade pricing once approved. You'll be able to sign in here as soon as your account is activated.", + "support": "Questions? Contact wholesale@example.com" + }, + "plp": { + "title": "Wholesale Catalog", + "subtitle": "Your trade pricing is applied automatically at checkout.", + "empty": "No products are available on the wholesale catalog yet.", + "noMatchingProducts": "No products match \"{query}\".", + "tradePriceNote": "All catalog prices shown are trade prices, applied on orders of {min}+ per item." + }, + "pdp": { + "tradePriceHint": "Trade price applies on orders of {min}+" + }, + "cart": { + "title": "Wholesale Cart", + "emptyDescription": "Add products from the catalog or use quick order to get started.", + "browseCatalog": "Browse catalog", + "unlockNudge": "Add {count} more to unlock trade pricing", + "tradePriceApplied": "Trade price applied" + }, + "quickOrder": { + "title": "Quick Order", + "subtitle": "Enter SKUs and quantities to add multiple products to your cart at once.", + "skuHeader": "SKU", + "qtyHeader": "Qty", + "actionsHeader": "Actions", + "skuPlaceholder": "Enter a SKU", + "addRow": "Add row", + "removeRow": "Remove row", + "addAll": "Add all to cart", + "adding": "Adding…", + "added": "Added {name}", + "skuNotFound": "No product found for this SKU.", + "notPurchasable": "This item is not available to purchase.", + "addFailed": "Couldn't add this item. Please try again.", + "searching": "Searching…", + "noResults": "No products found" + }, + "apply": { + "title": "Apply for wholesale access", + "description": "Tell us about your business. We'll review your application and enable trade pricing once approved.", + "companyLabel": "Company name", + "companyPlaceholder": "Acme Trading Co.", + "phoneLabel": "Phone", + "phonePlaceholder": "+1 (555) 000-0000", + "submit": "Submit application", + "alreadyMember": "Already have a wholesale account?", + "receivedTitle": "Application received", + "receivedDescription": "Thanks for applying. We'll review your account and approve wholesale access shortly.", + "goToPortal": "Go to portal" + }, + "hiddenPrice": { + "signInForPricing": "Sign in for pricing", + "signInToOrder": "Sign in to order" + } } } diff --git a/messages/es.json b/messages/es.json index 8e7610ce..c717211d 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", + "wholesale": "Mayorista" }, "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.", + "wholesale": "Comercio y venta al por mayor" }, "home": { "welcome": "{storeName} Storefront", @@ -68,7 +70,18 @@ "fastShipping": "Envio rapido", "shippingDescription": "Entrega rapida y fiable hasta tu puerta.", "support": "Soporte 24/7", - "supportDescription": "Nuestro equipo esta aqui para ayudarte en cualquier momento." + "supportDescription": "Nuestro equipo esta aqui para ayudarte en cualquier momento.", + "wholesaleBadge": "Comercio y venta al por mayor", + "wholesaleTitle": "¿Compras para tu empresa?", + "wholesaleDescription": "Los compradores mayoristas aprobados obtienen precios al por mayor en todo el catálogo, pedidos rápidos por lotes mediante SKU y su propio historial de pedidos. Solicítalo una vez: lo revisaremos y activaremos tu cuenta.", + "wholesaleCtaPrimary": "Entrar al portal mayorista", + "wholesaleCtaSecondary": "Solicitar una cuenta", + "wholesaleBenefitPricingTitle": "Precios comerciales", + "wholesaleBenefitPricingDescription": "Tus tarifas negociadas se aplican automáticamente en todo el catálogo.", + "wholesaleBenefitQuickOrderTitle": "Pedido rápido por lotes", + "wholesaleBenefitQuickOrderDescription": "Introduce SKUs y cantidades para crear un pedido grande en segundos.", + "wholesaleBenefitOrdersTitle": "Historial de pedidos", + "wholesaleBenefitOrdersDescription": "Consulta pedidos anteriores y vuelve a pedir tus líneas habituales con un par de clics." }, "cart": { "cart": "Carrito", @@ -163,7 +176,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 +201,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 +282,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 +300,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,11 +319,11 @@ "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?", + "forgotPassword": "¿Olvidaste tu contrasena?", "policyConsentRequired": "Debes aceptar las politicas de la tienda para crear una cuenta" }, "register": { @@ -321,7 +334,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 +368,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 +390,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 +430,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 +441,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 +456,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 +485,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 +537,88 @@ "shipping": "Envio", "finalizingPayment": "Finalizando tu pago...", "or": "o" + }, + "wholesale": { + "portalTitle": "Portal mayorista", + "portalName": "Portal mayorista", + "badge": "Mayorista", + "nav": { + "catalog": "Catálogo", + "quickOrder": "Pedido rápido", + "cart": "Carrito", + "backToStore": "Volver a la tienda", + "signOut": "Cerrar sesión", + "signIn": "Iniciar sesión" + }, + "signInWall": { + "title": "Precios comerciales para compradores aprobados", + "description": "Inicia sesión en tu cuenta mayorista para explorar todo el catálogo con tus precios comerciales negociados y realizar pedidos por lotes.", + "gatedNotice": "Este portal está disponible solo para compradores mayoristas aprobados. Los precios y productos son visibles después de iniciar sesión.", + "noAccount": "¿No tienes una cuenta mayorista?", + "applyLink": "Solicitar acceso", + "formTitle": "Iniciar sesión", + "formDescription": "Usa el correo electrónico y la contraseña de tu cuenta mayorista.", + "submit": "Iniciar sesión" + }, + "pending": { + "title": "Tu solicitud está en revisión", + "description": "Gracias por tu solicitud. Tu cuenta está creada, pero el acceso mayorista aún está pendiente de aprobación.", + "nameLabel": "Nombre", + "emailLabel": "Correo electrónico", + "whatNext": "Nuestro equipo revisa las nuevas solicitudes mayoristas y activa los precios comerciales una vez aprobadas. Podrás iniciar sesión aquí en cuanto se active tu cuenta.", + "support": "¿Preguntas? Contacta con wholesale@example.com" + }, + "plp": { + "title": "Catálogo mayorista", + "subtitle": "Tus precios comerciales se aplican automáticamente al finalizar la compra.", + "empty": "Aún no hay productos disponibles en el catálogo mayorista.", + "noMatchingProducts": "Ningún producto coincide con \"{query}\".", + "tradePriceNote": "Todos los precios del catálogo que se muestran son precios comerciales, aplicables a pedidos de {min}+ por artículo." + }, + "pdp": { + "tradePriceHint": "El precio comercial se aplica a pedidos de {min}+" + }, + "cart": { + "title": "Carrito mayorista", + "emptyDescription": "Añade productos del catálogo o usa el pedido rápido para empezar.", + "browseCatalog": "Explorar catálogo", + "unlockNudge": "Añade {count} más para desbloquear los precios comerciales", + "tradePriceApplied": "Precio comercial aplicado" + }, + "quickOrder": { + "title": "Pedido rápido", + "subtitle": "Introduce SKUs y cantidades para añadir varios productos a tu carrito a la vez.", + "skuHeader": "SKU", + "qtyHeader": "Cant.", + "actionsHeader": "Acciones", + "skuPlaceholder": "Introduce un SKU", + "addRow": "Añadir fila", + "removeRow": "Eliminar fila", + "addAll": "Añadir todo al carrito", + "adding": "Añadiendo…", + "added": "{name} añadido", + "skuNotFound": "No se encontró ningún producto para este SKU.", + "notPurchasable": "Este artículo no está disponible para comprar.", + "addFailed": "No se pudo añadir este artículo. Inténtalo de nuevo.", + "searching": "Buscando…", + "noResults": "No se encontraron productos" + }, + "apply": { + "title": "Solicitar acceso mayorista", + "description": "Cuéntanos sobre tu empresa. Revisaremos tu solicitud y activaremos los precios comerciales una vez aprobada.", + "companyLabel": "Nombre de la empresa", + "companyPlaceholder": "Acme Trading Co.", + "phoneLabel": "Teléfono", + "phonePlaceholder": "+1 (555) 000-0000", + "submit": "Enviar solicitud", + "alreadyMember": "¿Ya tienes una cuenta mayorista?", + "receivedTitle": "Solicitud recibida", + "receivedDescription": "Gracias por tu solicitud. Revisaremos tu cuenta y aprobaremos el acceso mayorista en breve.", + "goToPortal": "Ir al portal" + }, + "hiddenPrice": { + "signInForPricing": "Inicia sesión para ver precios", + "signInToOrder": "Inicia sesión para pedir" + } } } diff --git a/messages/fr.json b/messages/fr.json index f36c9878..6b89b4c7 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", + "wholesale": "Vente en gros" }, "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.", + "wholesale": "Professionnels & vente en gros" }, "home": { "welcome": "{storeName} Storefront", @@ -68,7 +70,18 @@ "fastShipping": "Livraison rapide", "shippingDescription": "Livraison rapide et fiable jusqu'a votre porte.", "support": "Support 24/7", - "supportDescription": "Notre equipe est la pour vous aider a tout moment." + "supportDescription": "Notre equipe est la pour vous aider a tout moment.", + "wholesaleBadge": "Professionnels & vente en gros", + "wholesaleTitle": "Vous achetez pour votre entreprise ?", + "wholesaleDescription": "Les acheteurs professionnels approuvés bénéficient de tarifs de gros sur l'ensemble du catalogue, de la commande rapide en volume par SKU et de leur propre historique de commandes. Postulez une seule fois — nous examinerons votre demande et activerons votre compte.", + "wholesaleCtaPrimary": "Accéder au portail grossiste", + "wholesaleCtaSecondary": "Demander un compte", + "wholesaleBenefitPricingTitle": "Tarifs professionnels", + "wholesaleBenefitPricingDescription": "Vos tarifs négociés appliqués automatiquement sur l'ensemble du catalogue.", + "wholesaleBenefitQuickOrderTitle": "Commande rapide en volume", + "wholesaleBenefitQuickOrderDescription": "Saisissez des SKU et des quantités pour composer une grande commande en quelques secondes.", + "wholesaleBenefitOrdersTitle": "Historique des commandes", + "wholesaleBenefitOrdersDescription": "Consultez vos commandes passées et recommandez vos références habituelles en quelques clics." }, "cart": { "cart": "Panier", @@ -163,7 +176,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", @@ -355,7 +368,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 +390,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 +430,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 +537,88 @@ "shipping": "Livraison", "finalizingPayment": "Finalisation du paiement...", "or": "ou" + }, + "wholesale": { + "portalTitle": "Portail grossiste", + "portalName": "Portail grossiste", + "badge": "Vente en gros", + "nav": { + "catalog": "Catalogue", + "quickOrder": "Commande rapide", + "cart": "Panier", + "backToStore": "Retour à la boutique", + "signOut": "Se déconnecter", + "signIn": "Se connecter" + }, + "signInWall": { + "title": "Tarifs professionnels pour les acheteurs approuvés", + "description": "Connectez-vous à votre compte grossiste pour parcourir l'ensemble du catalogue à vos tarifs professionnels négociés et passer des commandes en volume.", + "gatedNotice": "Ce portail est réservé aux acheteurs en gros approuvés. Les prix et les produits sont visibles après votre connexion.", + "noAccount": "Vous n'avez pas de compte grossiste ?", + "applyLink": "Demander un accès", + "formTitle": "Se connecter", + "formDescription": "Utilisez l'e-mail et le mot de passe de votre compte grossiste.", + "submit": "Se connecter" + }, + "pending": { + "title": "Votre demande est en cours d'examen", + "description": "Merci pour votre demande. Votre compte est créé, mais l'accès grossiste est encore en attente d'approbation.", + "nameLabel": "Nom", + "emailLabel": "E-mail", + "whatNext": "Notre équipe examine les nouvelles demandes de grossistes et active les tarifs professionnels après approbation. Vous pourrez vous connecter ici dès que votre compte sera activé.", + "support": "Des questions ? Contactez wholesale@example.com" + }, + "plp": { + "title": "Catalogue grossiste", + "subtitle": "Vos tarifs professionnels sont appliqués automatiquement au paiement.", + "empty": "Aucun produit n'est encore disponible dans le catalogue grossiste.", + "noMatchingProducts": "Aucun produit ne correspond à \"{query}\".", + "tradePriceNote": "Tous les prix du catalogue affichés sont des tarifs professionnels, applicables aux commandes de {min}+ par article." + }, + "pdp": { + "tradePriceHint": "Le tarif professionnel s'applique aux commandes de {min}+" + }, + "cart": { + "title": "Panier grossiste", + "emptyDescription": "Ajoutez des produits du catalogue ou utilisez la commande rapide pour commencer.", + "browseCatalog": "Parcourir le catalogue", + "unlockNudge": "Ajoutez {count} de plus pour débloquer les tarifs professionnels", + "tradePriceApplied": "Tarif professionnel appliqué" + }, + "quickOrder": { + "title": "Commande rapide", + "subtitle": "Saisissez des SKU et des quantités pour ajouter plusieurs produits à votre panier en une seule fois.", + "skuHeader": "SKU", + "qtyHeader": "Qté", + "actionsHeader": "Actions", + "skuPlaceholder": "Saisissez un SKU", + "addRow": "Ajouter une ligne", + "removeRow": "Supprimer la ligne", + "addAll": "Tout ajouter au panier", + "adding": "Ajout…", + "added": "{name} ajouté", + "skuNotFound": "Aucun produit trouvé pour ce SKU.", + "notPurchasable": "Cet article n'est pas disponible à l'achat.", + "addFailed": "Impossible d'ajouter cet article. Veuillez réessayer.", + "searching": "Recherche…", + "noResults": "Aucun produit trouvé" + }, + "apply": { + "title": "Demander un accès grossiste", + "description": "Parlez-nous de votre entreprise. Nous examinerons votre demande et activerons les tarifs professionnels après approbation.", + "companyLabel": "Nom de l'entreprise", + "companyPlaceholder": "Acme Trading Co.", + "phoneLabel": "Téléphone", + "phonePlaceholder": "+1 (555) 000-0000", + "submit": "Envoyer la demande", + "alreadyMember": "Vous avez déjà un compte grossiste ?", + "receivedTitle": "Demande reçue", + "receivedDescription": "Merci pour votre demande. Nous examinerons votre compte et approuverons l'accès grossiste sous peu.", + "goToPortal": "Accéder au portail" + }, + "hiddenPrice": { + "signInForPricing": "Connectez-vous pour voir les prix", + "signInToOrder": "Connectez-vous pour commander" + } } } diff --git a/messages/pl.json b/messages/pl.json index 86bd1c51..25ca1fa5 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", + "wholesale": "Hurt" }, "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.", + "wholesale": "Handel i sprzedaż hurtowa" }, "home": { "welcome": "{storeName} Storefront", @@ -68,7 +70,18 @@ "fastShipping": "Szybka wysyłka", "shippingDescription": "Szybka i niezawodna dostawa pod Twoje drzwi.", "support": "Wsparcie 24/7", - "supportDescription": "Nasz zespół jest do Twojej dyspozycji o każdej porze." + "supportDescription": "Nasz zespół jest do Twojej dyspozycji o każdej porze.", + "wholesaleBadge": "Handel i sprzedaż hurtowa", + "wholesaleTitle": "Kupujesz dla swojej firmy?", + "wholesaleDescription": "Zatwierdzeni nabywcy hurtowi otrzymują ceny hurtowe na cały katalog, szybkie zamówienia zbiorcze według numeru SKU oraz własną historię zamówień. Złóż wniosek raz — sprawdzimy go i aktywujemy Twoje konto.", + "wholesaleCtaPrimary": "Przejdź do portalu hurtowego", + "wholesaleCtaSecondary": "Złóż wniosek o konto", + "wholesaleBenefitPricingTitle": "Ceny handlowe", + "wholesaleBenefitPricingDescription": "Twoje wynegocjowane stawki stosowane automatycznie w całym katalogu.", + "wholesaleBenefitQuickOrderTitle": "Szybkie zamówienie zbiorcze", + "wholesaleBenefitQuickOrderDescription": "Wprowadź numery SKU i ilości, aby w kilka sekund złożyć duże zamówienie.", + "wholesaleBenefitOrdersTitle": "Historia zamówień", + "wholesaleBenefitOrdersDescription": "Przeglądaj poprzednie zamówienia i zamawiaj ponownie swoje stałe pozycje w kilka kliknięć." }, "cart": { "cart": "Koszyk", @@ -524,5 +537,88 @@ "shipping": "Dostawa", "finalizingPayment": "Finalizowanie płatności...", "or": "lub" + }, + "wholesale": { + "portalTitle": "Portal hurtowy", + "portalName": "Portal hurtowy", + "badge": "Hurt", + "nav": { + "catalog": "Katalog", + "quickOrder": "Szybkie zamówienie", + "cart": "Koszyk", + "backToStore": "Wróć do sklepu", + "signOut": "Wyloguj się", + "signIn": "Zaloguj się" + }, + "signInWall": { + "title": "Ceny handlowe dla zatwierdzonych nabywców", + "description": "Zaloguj się do swojego konta hurtowego, aby przeglądać cały katalog w wynegocjowanych cenach handlowych i składać zamówienia zbiorcze.", + "gatedNotice": "Ten portal jest dostępny wyłącznie dla zatwierdzonych nabywców hurtowych. Ceny i produkty są widoczne po zalogowaniu.", + "noAccount": "Nie masz konta hurtowego?", + "applyLink": "Złóż wniosek o dostęp", + "formTitle": "Zaloguj się", + "formDescription": "Użyj adresu e-mail i hasła swojego konta hurtowego.", + "submit": "Zaloguj się" + }, + "pending": { + "title": "Twój wniosek jest rozpatrywany", + "description": "Dziękujemy za złożenie wniosku. Twoje konto zostało utworzone, ale dostęp hurtowy nadal czeka na zatwierdzenie.", + "nameLabel": "Imię i nazwisko", + "emailLabel": "E-mail", + "whatNext": "Nasz zespół sprawdza nowe wnioski hurtowe i po zatwierdzeniu włącza ceny handlowe. Będziesz mógł zalogować się tutaj, gdy tylko Twoje konto zostanie aktywowane.", + "support": "Masz pytania? Skontaktuj się z wholesale@example.com" + }, + "plp": { + "title": "Katalog hurtowy", + "subtitle": "Twoje ceny handlowe są stosowane automatycznie przy realizacji zamówienia.", + "empty": "W katalogu hurtowym nie ma jeszcze dostępnych produktów.", + "noMatchingProducts": "Żaden produkt nie pasuje do \"{query}\".", + "tradePriceNote": "Wszystkie wyświetlane ceny katalogowe to ceny handlowe, obowiązujące przy zamówieniach od {min} szt. na pozycję." + }, + "pdp": { + "tradePriceHint": "Cena handlowa obowiązuje przy zamówieniach od {min}" + }, + "cart": { + "title": "Koszyk hurtowy", + "emptyDescription": "Dodaj produkty z katalogu lub użyj szybkiego zamówienia, aby rozpocząć.", + "browseCatalog": "Przeglądaj katalog", + "unlockNudge": "Dodaj jeszcze {count}, aby odblokować ceny handlowe", + "tradePriceApplied": "Zastosowano cenę handlową" + }, + "quickOrder": { + "title": "Szybkie zamówienie", + "subtitle": "Wprowadź numery SKU i ilości, aby dodać wiele produktów do koszyka jednocześnie.", + "skuHeader": "SKU", + "qtyHeader": "Ilość", + "actionsHeader": "Akcje", + "skuPlaceholder": "Wprowadź SKU", + "addRow": "Dodaj wiersz", + "removeRow": "Usuń wiersz", + "addAll": "Dodaj wszystko do koszyka", + "adding": "Dodawanie…", + "added": "Dodano {name}", + "skuNotFound": "Nie znaleziono produktu dla tego numeru SKU.", + "notPurchasable": "Ten artykuł nie jest dostępny do zakupu.", + "addFailed": "Nie udało się dodać tego artykułu. Spróbuj ponownie.", + "searching": "Wyszukiwanie…", + "noResults": "Nie znaleziono produktów" + }, + "apply": { + "title": "Złóż wniosek o dostęp hurtowy", + "description": "Opowiedz nam o swojej firmie. Sprawdzimy Twój wniosek i po zatwierdzeniu włączymy ceny handlowe.", + "companyLabel": "Nazwa firmy", + "companyPlaceholder": "Acme Trading Co.", + "phoneLabel": "Telefon", + "phonePlaceholder": "+1 (555) 000-0000", + "submit": "Wyślij wniosek", + "alreadyMember": "Masz już konto hurtowe?", + "receivedTitle": "Wniosek otrzymany", + "receivedDescription": "Dziękujemy za złożenie wniosku. Sprawdzimy Twoje konto i wkrótce zatwierdzimy dostęp hurtowy.", + "goToPortal": "Przejdź do portalu" + }, + "hiddenPrice": { + "signInForPricing": "Zaloguj się, aby zobaczyć ceny", + "signInToOrder": "Zaloguj się, aby zamówić" + } } } diff --git a/package-lock.json b/package-lock.json index c33a9105..b5d8f4f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@next/third-parties": "^16.1.6", "@paypal/react-paypal-js": "^9.1.1", "@sentry/nextjs": "^10.38.0", - "@spree/sdk": "^1.1.0", + "@spree/sdk": "^1.2.0", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "@swc/helpers": "^0.5.21", @@ -5698,9 +5698,9 @@ } }, "node_modules/@spree/sdk": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-1.1.0.tgz", - "integrity": "sha512-xPLoi4FzwH4VnuoJs4xePfynq0SF1Cth0oRbAYzoqC80adKd3ZG0jGzlGNuYnjO7zYrYW7Y4YHwHkJoo0iVr/w==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-1.2.0.tgz", + "integrity": "sha512-ObHmFLLee3JVWSzTTadG0L646TwEW1A1R73nnLoTIzAW6Xim6sq3yCUbNmtLZaxIBDAOr+b+mKTvfkZWro0x1A==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index 42eb6789..5be459f8 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@next/third-parties": "^16.1.6", "@paypal/react-paypal-js": "^9.1.1", "@sentry/nextjs": "^10.38.0", - "@spree/sdk": "^1.1.0", + "@spree/sdk": "^1.2.0", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "@swc/helpers": "^0.5.21", diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx index f86b85a3..79622303 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx @@ -670,7 +670,7 @@ function CheckoutPageContentInner({ )} {/* Express checkout for guests */} - {!isAuthenticated && parseFloat(cart.total) > 0 && ( + {!isAuthenticated && parseFloat(cart.total ?? "0") > 0 && (
{expressAvailable && (

diff --git a/src/app/[country]/[locale]/(storefront)/cart/page.tsx b/src/app/[country]/[locale]/(storefront)/cart/page.tsx index 10202156..2a0d137b 100644 --- a/src/app/[country]/[locale]/(storefront)/cart/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/cart/page.tsx @@ -192,7 +192,7 @@ export default function CartPage() {

- {cart.gift_card && parseFloat(cart.gift_card_total) > 0 ? ( + {cart.gift_card && parseFloat(cart.gift_card_total ?? "0") > 0 ? (
{t("giftCard")}
-{cart.display_gift_card_total}
@@ -220,7 +220,7 @@ export default function CartPage() {
- {parseFloat(cart.total) > 0 && ( + {parseFloat(cart.total ?? "0") > 0 && ( +
); } diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx index ac93f97b..3f4c1097 100644 --- a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx +++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx @@ -2,14 +2,17 @@ import type { Media, Product, Variant } from "@spree/sdk"; import { CircleCheckBig, CircleX, Loader2, ShoppingBag } from "lucide-react"; +import Link from "next/link"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; +import { HiddenPricePrompt } from "@/components/products/HiddenPricePrompt"; 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 { QuantityPicker } from "@/components/ui/quantity-picker"; import { useCart } from "@/contexts/CartContext"; +import { useHiddenPricing } from "@/contexts/HiddenPricingContext"; import { useStore } from "@/contexts/StoreContext"; import { trackAddToCart, trackViewItem } from "@/lib/analytics/gtm"; @@ -22,6 +25,11 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { const { addItem } = useCart(); const { currency } = useStore(); const t = useTranslations("products"); + const tw = useTranslations("wholesale"); + // Non-null inside a HiddenPricingProvider (wholesale `prices_hidden`, guest + // view): prices are null on purpose, and ordering is gated behind sign-in. + const hiddenPricing = useHiddenPricing(); + const pricesHidden = hiddenPricing !== null; // Filter variants list const variants = useMemo(() => { @@ -128,10 +136,12 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { {/* Price */}
- {displayPrice && ( + {displayPrice ? ( {displayPrice} + ) : ( + )} {onSale && strikethroughPrice && ( <> @@ -174,35 +184,45 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { {/* Quantity & Add to Cart */}
-
- setQuantity(Math.max(1, quantity - 1))} - onIncrement={() => setQuantity(quantity + 1)} - size="lg" - /> - - {/* Add to Cart Button */} - -
+ ) : ( +
+ setQuantity(Math.max(1, quantity - 1))} + onIncrement={() => setQuantity(quantity + 1)} + size="lg" + /> + + {/* Add to Cart Button */} + +
+ )}
{/* Description */} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleApplicationPending.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleApplicationPending.tsx new file mode 100644 index 00000000..d1ebbcb3 --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleApplicationPending.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { ArrowLeft, Clock, LogOut, Mail } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useAuth } from "@/contexts/AuthContext"; + +interface WholesaleApplicationPendingProps { + basePath: string; + customerName: string; + email: string; +} + +/** + * Shown to a signed-in customer who is not (yet) in the Wholesale group. The + * demo "approval" is an admin adding them to the group; until then they see + * what's on file and what happens next. + */ +export function WholesaleApplicationPending({ + basePath, + customerName, + email, +}: WholesaleApplicationPendingProps) { + const t = useTranslations("wholesale"); + const { logout } = useAuth(); + + return ( +
+ + +
+ +
+ {t("pending.title")} + {t("pending.description")} +
+ +
+
+
{t("pending.nameLabel")}
+
{customerName}
+
+
+
{t("pending.emailLabel")}
+
{email}
+
+
+ +
+

{t("pending.whatNext")}

+

+ + {t("pending.support")} +

+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGate.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGate.tsx new file mode 100644 index 00000000..950ddd8d --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGate.tsx @@ -0,0 +1,109 @@ +import { CartDrawer } from "@/components/cart/CartDrawer"; +import { CartProvider } from "@/contexts/CartContext"; +import { getCustomer } from "@/lib/data/customer"; +import { getWholesaleChannel } from "@/lib/data/wholesale"; +import { isWholesaleApproved } from "@/lib/wholesale"; +import { WholesaleApplicationPending } from "./WholesaleApplicationPending"; +import { WholesaleGuestBrowse } from "./WholesaleGuestBrowse"; +import { WholesaleHeader } from "./WholesaleHeader"; +import { WholesaleSignInWall } from "./WholesaleSignInWall"; + +interface WholesaleGateProps { + basePath: string; + /** + * Portal content, as a function so it is invoked only in branches that should + * render it — never for the sign-in wall or the pending state. A plain + * `children` node would be constructed regardless of the gate's decision; the + * thunk guarantees the catalog fetch runs only when the posture allows it (an + * approved buyer, or a guest on a `prices_hidden` channel — never a guest on a + * `login_required` channel, where the fetch would 401). + */ + children: () => React.ReactNode; + /** + * Whether a guest may see this page on a `prices_hidden` channel. Only the + * browse surfaces (catalog, PDP) set this — they render read-only with + * sign-in-for-pricing prompts. Ordering surfaces (cart, quick order) leave it + * false so a guest hits the sign-in wall instead of a page whose `useCart()` + * would bind to the DTC provider (guests have no wholesale cart). + */ + allowGuestBrowse?: boolean; +} + +/** + * Server-side gate for the portal (catalog, PDP, cart, quick order). Branches on + * the channel's `storefront_access` posture, the session, and Wholesale-group + * membership: + * + * - guest, `login_required` → inline sign-in / apply wall (the channel 401s the + * catalog fetch anyway, so nothing behind the wall would render) + * - guest, `prices_hidden` → the catalog renders with prices replaced by a + * "sign in for pricing" prompt; ordering is gated behind sign-in + * - authenticated, not approved → application-pending state (approval, not just + * login, unlocks trade pricing and ordering — same for both postures) + * - approved member → the portal chrome + `children`, with the wholesale cart + * bound via + * + * Runs per navigation, so a login (which triggers a server re-render) + * re-evaluates it. The apply page renders outside this gate so guests can + * reach it. + */ +export async function WholesaleGate({ + basePath, + children, + allowGuestBrowse = false, +}: WholesaleGateProps) { + const [customer, channel] = await Promise.all([ + getCustomer(), + getWholesaleChannel(), + ]); + + if (!customer) { + // On a prices-hidden channel the catalog is browsable by guests (the API + // just nulls the money fields), so render browse surfaces with + // sign-in-for-pricing prompts instead of the hard wall. Ordering surfaces + // (allowGuestBrowse=false) still wall guests off, and any other posture + // (login_required, or an unknown/unreachable channel) always walls. + if (allowGuestBrowse && channel?.storefront_access === "prices_hidden") { + return ( + + {children()} + + ); + } + + return ( + + ); + } + + if (!isWholesaleApproved(customer)) { + return ( + + ); + } + + const displayName = + [customer.first_name, customer.last_name].filter(Boolean).join(" ") || + customer.email; + + return ( + + +
{children()}
+ {/* The wholesale portal needs its own drawer bound to THIS provider — + the root layout's reads the outer DTC context, so + wholesale add-to-cart / quick-order opens would never reach it. */} + +
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGuestBrowse.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGuestBrowse.tsx new file mode 100644 index 00000000..8a2a691d --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleGuestBrowse.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { usePathname, useSearchParams } from "next/navigation"; +import { HiddenPricingProvider } from "@/contexts/HiddenPricingContext"; +import { WholesaleHeader } from "./WholesaleHeader"; + +interface WholesaleGuestBrowseProps { + basePath: string; + children: React.ReactNode; +} + +/** + * Guest view of a `prices_hidden` wholesale channel: the catalog renders, but + * money fields come back null and every price becomes a "sign in for pricing" + * prompt (via HiddenPricingProvider). Ordering is gated behind sign-in too. + * + * Client component so it can read the current path/query and build a sign-in + * link that returns the buyer here after they authenticate — matching the + * `?redirect=` contract the sign-in wall already honours. + */ +export function WholesaleGuestBrowse({ + basePath, + children, +}: WholesaleGuestBrowseProps) { + const wholesaleBase = `${basePath}/wholesale`; + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // Return the buyer to exactly where they were, query string included. + const query = searchParams.toString(); + const returnTo = query ? `${pathname}?${query}` : pathname; + const signInHref = `${wholesaleBase}?redirect=${encodeURIComponent(returnTo)}`; + + return ( + + +
{children}
+
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleHeader.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleHeader.tsx new file mode 100644 index 00000000..7544cbef --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleHeader.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { ArrowLeft, LogOut, ShoppingCart } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/AuthContext"; +import { useCart } from "@/contexts/CartContext"; + +interface WholesaleHeaderProps { + basePath: string; + /** Signed-in buyer's name. Omitted for a guest browsing a prices-hidden catalog. */ + customerName?: string; + /** + * Whether a customer is signed in. When false (a guest on a `prices_hidden` + * channel) the header swaps the name + cart + sign-out for a single sign-in + * link, since a guest has no wholesale cart and nothing to sign out of. + */ + authenticated?: boolean; + /** Sign-in destination for the guest affordance (includes `?redirect=`). */ + signInHref?: string; +} + +/** + * Portal chrome for the wholesale surface. Distinct slate trade dress with a + * "Wholesale" badge and a link back to the DTC store. For a signed-in buyer it + * also shows the wholesale cart, their name, and sign-out; for a guest browsing + * a prices-hidden catalog it shows a sign-in link instead. The cart count comes + * from the wholesale-bound the layout wraps this in. + */ +export function WholesaleHeader({ + basePath, + customerName, + authenticated = true, + signInHref, +}: WholesaleHeaderProps) { + const t = useTranslations("wholesale"); + const { itemCount } = useCart(); + const { logout } = useAuth(); + + const wholesaleBase = `${basePath}/wholesale`; + + return ( +
+
+ + {t("portalName")} + + {t("badge")} + + + + + +
+ {authenticated && customerName && ( + + {customerName} + + )} + + + + {authenticated ? ( + <> + + + + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleSignInWall.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleSignInWall.tsx new file mode 100644 index 00000000..aeb9de61 --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/_components/WholesaleSignInWall.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { Building2, CircleAlert, Eye, EyeOff } from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } 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 { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/contexts/AuthContext"; + +interface WholesaleSignInWallProps { + basePath: string; + storefrontAccess?: string; +} + +/** + * Landing shown to guests hitting the gated portal. Signs the buyer in through + * the shared login flow; on success the server layout re-renders and the gate + * re-evaluates. `?redirect=` returns the buyer to the page they were heading + * for (defaults to the portal home). + */ +export function WholesaleSignInWall({ + basePath, + storefrontAccess, +}: WholesaleSignInWallProps) { + const t = useTranslations("wholesale"); + const ta = useTranslations("account"); + const router = useRouter(); + const searchParams = useSearchParams(); + const { login } = useAuth(); + + const wholesaleBase = `${basePath}/wholesale`; + // Only follow same-origin relative paths after login. Reject absolute URLs + // and protocol-relative values ("//host") to avoid an open redirect. + const redirectParam = searchParams.get("redirect"); + const redirectUrl = + redirectParam?.startsWith("/") && !redirectParam.startsWith("//") + ? redirectParam + : wholesaleBase; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + const result = await login(email, password); + if (result.success) { + router.push(redirectUrl); + router.refresh(); + } else { + setError(result.error ?? ta("invalidCredentials")); + setLoading(false); + } + }; + + return ( +
+
+
+ + {t("badge")} +
+

+ {t("signInWall.title")} +

+

{t("signInWall.description")}

+ {storefrontAccess === "login_required" && ( +

+ {t("signInWall.gatedNotice")} +

+ )} +
+

+ {t("signInWall.noAccount")}{" "} + + {t("signInWall.applyLink")} + +

+
+
+ + + + {t("signInWall.formTitle")} + {t("signInWall.formDescription")} + + +
+ {error && ( + + + {error} + + )} + + + {ta("email")} + setEmail(e.target.value)} + required + placeholder="you@company.com" + /> + + + + + {ta("password")} + +
+ setPassword(e.target.value)} + required + placeholder="••••••••" + className="pr-10" + /> +
+ +
+
+
+ + +
+
+ +

+ + {ta("forgotPassword")} + +

+
+
+
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/apply/page.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/apply/page.tsx new file mode 100644 index 00000000..def949b8 --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/apply/page.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { + Building2, + CheckCircle2, + CircleAlert, + Eye, + EyeOff, +} 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 { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/contexts/AuthContext"; +import { extractBasePath } from "@/lib/utils/path"; + +/** + * Wholesale application form. Registers a customer via the shared register flow + * (phone is forwarded; company name persists as customer metadata, which the + * merchant sees on the admin customer record). On success the buyer has an + * account but is not yet in the Wholesale group — the demo "approval" is an + * admin adding them — so we show a received/pending confirmation rather than + * dropping them into the portal. + */ +export default function WholesaleApplyPage() { + const t = useTranslations("wholesale"); + const ta = useTranslations("account"); + const tr = useTranslations("register"); + const pathname = usePathname(); + const storeBase = extractBasePath(pathname); + const wholesaleBase = `${storeBase}/wholesale`; + const { register } = useAuth(); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [company, setCompany] = useState(""); + const [phone, setPhone] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [submitted, setSubmitted] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (password.length < 6) { + setError(tr("passwordTooShort")); + return; + } + + setSubmitting(true); + try { + const result = await register({ + email, + password, + password_confirmation: password, + ...(firstName && { first_name: firstName }), + ...(lastName && { last_name: lastName }), + ...(phone && { phone }), + // Company has no first-class customer column; persist it as metadata so + // it reaches the applicant's admin record for the merchant's review. + ...(company.trim() && { metadata: { company: company.trim() } }), + }); + if (result.success) { + setSubmitted(true); + } else { + setError(result.error ?? tr("registrationFailed")); + } + } catch { + setError(tr("unexpectedError")); + } finally { + setSubmitting(false); + } + }; + + if (submitted) { + return ( +
+ + +
+ +
+ {t("apply.receivedTitle")} + {t("apply.receivedDescription")} +
+ + + +
+
+ ); + } + + return ( +
+ + +
+ +
+ {t("apply.title")} + {t("apply.description")} +
+ + +
+ {error && ( + + + {error} + + )} + +
+ + {tr("firstName")} + setFirstName(e.target.value)} + required + placeholder={tr("firstNamePlaceholder")} + /> + + + {tr("lastName")} + setLastName(e.target.value)} + required + placeholder={tr("lastNamePlaceholder")} + /> + +
+ + + + {t("apply.companyLabel")} + + setCompany(e.target.value)} + required + placeholder={t("apply.companyPlaceholder")} + /> + + + + + {t("apply.phoneLabel")} + + setPhone(e.target.value)} + placeholder={t("apply.phonePlaceholder")} + /> + + + + {ta("email")} + setEmail(e.target.value)} + required + placeholder="you@company.com" + /> + + + + {ta("password")} +
+ setPassword(e.target.value)} + required + minLength={6} + placeholder="••••••••" + className="pr-10" + /> +
+ +
+
+
+ + +
+
+ + +

+ {t("apply.alreadyMember")}{" "} + + {t("signInWall.submit")} + +

+
+
+
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/cart/WholesaleCartView.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/cart/WholesaleCartView.tsx new file mode 100644 index 00000000..1ed1b54e --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/cart/WholesaleCartView.tsx @@ -0,0 +1,198 @@ +"use client"; + +import type { LineItem } from "@spree/sdk"; +import { ShoppingBag } 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 { ProductImage } from "@/components/ui/product-image"; +import { QuantityPicker } from "@/components/ui/quantity-picker"; +import { useCart } from "@/contexts/CartContext"; +import { extractBasePath } from "@/lib/utils/path"; +import { WHOLESALE_MIN_QUANTITY } from "@/lib/wholesale"; + +/** + * Wholesale cart view. Reads the wholesale-bound cart from useCart() (the gate + * wraps this subtree in a wholesale ). "Continue shopping" returns + * to the wholesale catalog; the checkout button hands off to the shared + * (checkout) flow, which resolves the wholesale surface from the cart id. + */ +export function WholesaleCartView() { + const { cart, loading, updateItem, removeItem } = useCart(); + const pathname = usePathname(); + // extractBasePath strips to /{country}/{locale}; the shared checkout lives there. + const storeBase = extractBasePath(pathname); + const wholesaleBase = `${storeBase}/wholesale`; + const t = useTranslations("cart"); + const tc = useTranslations("common"); + const tw = useTranslations("wholesale"); + + const handleRemove = async (item: LineItem) => { + await removeItem(item.id); + }; + + if (loading) { + return ( +
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ ); + } + + if (!cart?.items || cart.items.length === 0) { + return ( +
+
+ +

+ {t("emptyCart")} +

+

{tw("cart.emptyDescription")}

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

+ {tw("cart.title")} +

+ +
+
+
+ {cart.items.map((item) => ( +
+
+ +
+ +
+

+ {item.name} +

+ {item.options_text && ( +

+ {item.options_text} +

+ )} +

+ {item.display_price ?? "—"} +

+ {item.quantity < WHOLESALE_MIN_QUANTITY ? ( +

+ {tw("cart.unlockNudge", { + count: WHOLESALE_MIN_QUANTITY - item.quantity, + })} +

+ ) : ( +

+ {tw("cart.tradePriceApplied")} +

+ )} +
+ +
+ + updateItem(item.id, Math.max(1, item.quantity - 1)) + } + onIncrement={() => updateItem(item.id, item.quantity + 1)} + /> + +
+
+ ))} +
+
+ +
+
+

+ {tc("orderSummary")} +

+ +
+
+
{tc("subtotal")}
+
+ {cart.display_item_total ?? "—"} +
+
+ {cart.discount_total && parseFloat(cart.discount_total) < 0 && ( +
+
{tc("discount")}
+
{cart.display_discount_total}
+
+ )} + {cart.tax_total && parseFloat(cart.tax_total) > 0 && ( +
+
{tc("tax")}
+
{cart.display_tax_total}
+
+ )} +
+
+ {tc("total")} +
+
+ {cart.display_total ?? "—"} +
+
+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/cart/page.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/cart/page.tsx new file mode 100644 index 00000000..52a17a64 --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/cart/page.tsx @@ -0,0 +1,17 @@ +import { WholesaleGate } from "../_components/WholesaleGate"; +import { WholesaleCartView } from "./WholesaleCartView"; + +interface WholesaleCartPageProps { + params: Promise<{ country: string; locale: string }>; +} + +export default async function WholesaleCartPage({ + params, +}: WholesaleCartPageProps) { + const { country, locale } = await params; + return ( + + {() => } + + ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/layout.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/layout.tsx new file mode 100644 index 00000000..53ff9bc9 --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/layout.tsx @@ -0,0 +1,39 @@ +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { isWholesaleEnabled } from "@/lib/spree"; + +interface WholesaleLayoutProps { + children: React.ReactNode; + params: Promise<{ country: string; locale: string }>; +} + +/** + * Shell for the wholesale portal. The distinct slate trade dress lives here; the + * per-page owns auth branching and the portal chrome so the + * public apply page can render without the gate. + * + * Wholesale is an opt-in addon: when it's disabled every route in this group + * 404s here in one place (PLP/PDP/cart/quick-order/apply), so a DTC-only + * storefront never exposes a broken gate. + */ +export default function WholesaleLayout({ children }: WholesaleLayoutProps) { + if (!isWholesaleEnabled()) notFound(); + + return
{children}
; +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const t = await getTranslations({ + locale: locale as Locale, + namespace: "wholesale", + }); + return { + title: t("portalTitle"), + robots: { index: false, follow: false }, + }; +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/page.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/page.tsx new file mode 100644 index 00000000..b7b7d09a --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/page.tsx @@ -0,0 +1,65 @@ +import { getTranslations } from "next-intl/server"; +import { ProductListing } from "@/components/products/ProductListing"; +import { resolveCurrency } from "@/lib/data/markets"; +import { + getWholesaleProductFilters, + getWholesaleProducts, +} from "@/lib/data/wholesale"; +import { parseListingSearchParams } from "@/lib/utils/listing-search-params"; +import { WHOLESALE_MIN_QUANTITY } from "@/lib/wholesale"; +import { WholesaleGate } from "./_components/WholesaleGate"; + +interface WholesalePlpProps { + params: Promise<{ country: string; locale: string }>; + searchParams: Promise>; +} + +export default async function WholesaleProductsPage({ + params, + searchParams, +}: WholesalePlpProps) { + const { country, locale } = await params; + const rawSearchParams = await searchParams; + const basePath = `/${country}/${locale}/wholesale`; + const currency = await resolveCurrency(country); + + const listingState = parseListingSearchParams(rawSearchParams); + const query = listingState.query; + + const t = await getTranslations({ + locale: locale as Locale, + namespace: "wholesale", + }); + + return ( + + {() => ( +
+
+

+ {t("plp.title")} +

+

{t("plp.subtitle")}

+

+ {t("plp.tradePriceNote", { min: WHOLESALE_MIN_QUANTITY })} +

+
+ + +
+ )} +
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/products/[slug]/page.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/products/[slug]/page.tsx new file mode 100644 index 00000000..74a400de --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/products/[slug]/page.tsx @@ -0,0 +1,74 @@ +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { ProductDetails } from "@/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails"; +import { PRODUCT_PAGE_EXPAND } from "@/lib/data/cached"; +import { getWholesaleProduct } from "@/lib/data/wholesale"; +import { WHOLESALE_MIN_QUANTITY } from "@/lib/wholesale"; +import { WholesaleGate } from "../../_components/WholesaleGate"; + +interface WholesaleProductPageProps { + params: Promise<{ country: string; locale: string; slug: string }>; +} + +/** + * Wholesale PDP. The gate runs first (so guests see the sign-in wall rather + * than a 404 from the gated channel); the product fetch happens inside the + * gate, for an approved buyer. Reuses the storefront — its + * add-to-cart runs through useCart(), which resolves to the wholesale-bound + * provider inside this route group. Wholesale prices are resolved by the API + * via the price list on the gated channel. + */ +export default async function WholesaleProductPage({ + params, +}: WholesaleProductPageProps) { + const { country, locale, slug } = await params; + + return ( + + {() => ( + + )} + + ); +} + +async function WholesaleProductContent({ + country, + locale, + slug, +}: { + country: string; + locale: string; + slug: string; +}) { + const basePath = `/${country}/${locale}/wholesale`; + + let product; + try { + product = await getWholesaleProduct(slug, { expand: PRODUCT_PAGE_EXPAND }); + } catch { + notFound(); + } + + if (!product) notFound(); + + const t = await getTranslations({ + locale: locale as Locale, + namespace: "wholesale", + }); + + return ( + <> +
+

+ {t("pdp.tradePriceHint", { min: WHOLESALE_MIN_QUANTITY })} +

+
+ + + ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/quick-order/QuickOrderView.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/quick-order/QuickOrderView.tsx new file mode 100644 index 00000000..c0da01fd --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/quick-order/QuickOrderView.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { CheckCircle2, CircleAlert, Loader2, Plus, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useId, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useCart } from "@/contexts/CartContext"; +import { + findWholesaleVariantBySku, + type WholesaleVariantSuggestion, +} from "@/lib/data/wholesale"; +import { extractBasePath } from "@/lib/utils/path"; +import { SkuCombobox } from "./SkuCombobox"; + +type RowStatus = + | { kind: "idle" } + | { kind: "adding" } + | { kind: "added"; productName: string } + | { kind: "error"; message: string }; + +interface QuickOrderRow { + id: string; + sku: string; + quantity: number; + status: RowStatus; + /** + * Variant chosen from the autocomplete. Present rows skip the SKU lookup on + * submit; rows where the buyer typed a raw SKU resolve it the old way. + */ + selected?: WholesaleVariantSuggestion; +} + +function newRow(id: string): QuickOrderRow { + return { id, sku: "", quantity: 1, status: { kind: "idle" } }; +} + +/** Select the field's contents so typing replaces the quantity instead of appending to it. */ +function selectQuantityText(e: React.SyntheticEvent) { + e.currentTarget.select(); +} + +/** + * B2B quick-order form: rows of SKU + quantity resolved against the wholesale + * catalog, added in one action to the wholesale cart with per-row feedback. + */ +export function QuickOrderView() { + const t = useTranslations("wholesale"); + const { addItem, openCart } = useCart(); + const pathname = usePathname(); + const wholesaleBase = `${extractBasePath(pathname)}/wholesale`; + const rowIdSeed = useId(); + + const [rows, setRows] = useState([ + newRow(`${rowIdSeed}-0`), + newRow(`${rowIdSeed}-1`), + newRow(`${rowIdSeed}-2`), + ]); + const [submitting, setSubmitting] = useState(false); + const [rowSeq, setRowSeq] = useState(3); + + const updateRow = (id: string, patch: Partial) => { + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }; + + const addRow = () => { + setRows((prev) => [...prev, newRow(`${rowIdSeed}-${rowSeq}`)]); + setRowSeq((n) => n + 1); + }; + + const removeRow = (id: string) => { + setRows((prev) => + prev.length > 1 ? prev.filter((r) => r.id !== id) : prev, + ); + }; + + const handleAddAll = async () => { + const candidates = rows.filter((r) => r.sku.trim() && r.quantity > 0); + if (candidates.length === 0) return; + + setSubmitting(true); + // Mark all candidate rows as adding up front. + setRows((prev) => + prev.map((r) => + candidates.some((c) => c.id === r.id) + ? { ...r, status: { kind: "adding" as const } } + : r, + ), + ); + + let anyAdded = false; + for (const row of candidates) { + // A row picked from the autocomplete already knows its variant; a row + // where the buyer typed a raw SKU still resolves by lookup. + const resolved = row.selected + ? { + variantId: row.selected.variantId, + productName: row.selected.productName, + purchasable: row.selected.purchasable, + } + : await findWholesaleVariantBySku(row.sku).then((result) => + result.found + ? { + variantId: result.variantId, + productName: result.productName, + purchasable: result.purchasable, + } + : null, + ); + + if (!resolved) { + updateRow(row.id, { + status: { kind: "error", message: t("quickOrder.skuNotFound") }, + }); + continue; + } + if (!resolved.purchasable) { + updateRow(row.id, { + status: { kind: "error", message: t("quickOrder.notPurchasable") }, + }); + continue; + } + try { + await addItem(resolved.variantId, row.quantity); + anyAdded = true; + updateRow(row.id, { + status: { kind: "added", productName: resolved.productName }, + }); + } catch { + updateRow(row.id, { + status: { kind: "error", message: t("quickOrder.addFailed") }, + }); + } + } + + setSubmitting(false); + if (anyAdded) openCart(); + }; + + return ( +
+
+

+ {t("quickOrder.title")} +

+

{t("quickOrder.subtitle")}

+
+ +
+
+ {t("quickOrder.skuHeader")} + {t("quickOrder.qtyHeader")} + {t("quickOrder.actionsHeader")} +
+ +
+ {rows.map((row) => ( +
+
+ + updateRow(row.id, { + sku: next, + // Free-text edit invalidates any previous selection. + selected: undefined, + status: { kind: "idle" }, + }) + } + onSelect={(suggestion) => { + updateRow(row.id, { + sku: suggestion.sku, + selected: suggestion, + status: { kind: "idle" }, + }); + // Move focus to this row's quantity input. + document + .querySelector( + `[data-qty-for="${row.id}"]`, + ) + ?.focus(); + }} + caption={ + row.selected + ? [ + row.selected.productName, + row.selected.optionsText, + row.selected.displayPrice, + ] + .filter(Boolean) + .join(" · ") + : undefined + } + ariaLabel={t("quickOrder.skuHeader")} + /> + + updateRow(row.id, { + quantity: Math.max(1, Number(e.target.value) || 1), + }) + } + aria-label={t("quickOrder.qtyHeader")} + /> + +
+ + {row.status.kind === "adding" && ( +

+ + {t("quickOrder.adding")} +

+ )} + {row.status.kind === "added" && ( +

+ + {t("quickOrder.added", { name: row.status.productName })} +

+ )} + {row.status.kind === "error" && ( +

+ + {row.status.message} +

+ )} +
+ ))} +
+ +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/quick-order/SkuCombobox.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/quick-order/SkuCombobox.tsx new file mode 100644 index 00000000..29a79512 --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/quick-order/SkuCombobox.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useId, useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { + searchWholesaleVariants, + type WholesaleVariantSuggestion, +} from "@/lib/data/wholesale"; + +interface SkuComboboxProps { + value: string; + /** Free-text edits (also clears any resolved selection upstream). */ + onValueChange: (value: string) => void; + /** A variant was picked from the dropdown. */ + onSelect: (suggestion: WholesaleVariantSuggestion) => void; + /** Caption under the field once a variant is resolved. */ + caption?: string; + ariaLabel: string; +} + +/** + * Product search-and-select for a quick-order row. Buyers search by product + * name (nobody remembers raw SKUs) but the SKU stays visible, since B2B buyers + * reconcile against it on their own paperwork. Typing a full SKU and tabbing + * away still resolves directly — the dropdown is additive, not a gate. + * + * All state is instance-local, so rows never share a dropdown. + */ +export function SkuCombobox({ + value, + onValueChange, + onSelect, + caption, + ariaLabel, +}: SkuComboboxProps) { + const t = useTranslations("wholesale"); + const listboxId = useId(); + + const [suggestions, setSuggestions] = useState( + [], + ); + const [isOpen, setIsOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + + const debounceRef = useRef(null); + const blurTimeoutRef = useRef(null); + // Monotonic id so a slow earlier response can't overwrite a newer one. + const requestIdRef = useRef(0); + + const runSearch = async (query: string) => { + requestIdRef.current += 1; + const requestId = requestIdRef.current; + setLoading(true); + try { + const results = await searchWholesaleVariants(query); + if (requestIdRef.current !== requestId) return; + setSuggestions(results); + } catch { + if (requestIdRef.current !== requestId) return; + setSuggestions([]); + } finally { + if (requestIdRef.current === requestId) setLoading(false); + } + }; + + const handleChange = (next: string) => { + onValueChange(next); + setIsOpen(true); + setSelectedIndex(-1); + // Invalidate any in-flight request for the previous query. + requestIdRef.current += 1; + + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (next.trim().length >= 2) { + debounceRef.current = setTimeout(() => runSearch(next), 275); + } else { + setSuggestions([]); + setLoading(false); + } + }; + + const handleSelect = (suggestion: WholesaleVariantSuggestion) => { + onSelect(suggestion); + setIsOpen(false); + setSuggestions([]); + setSelectedIndex(-1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setIsOpen(false); + return; + } + if (!isOpen || suggestions.length === 0) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => + prev < suggestions.length - 1 ? prev + 1 : prev, + ); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)); + break; + case "Enter": + if (selectedIndex >= 0) { + e.preventDefault(); + handleSelect(suggestions[selectedIndex]); + } + break; + } + }; + + // Delay close so a click on an option lands before blur tears it down. + const handleBlur = () => { + blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 200); + }; + + const showDropdown = + isOpen && (loading || suggestions.length > 0 || value.trim().length >= 2); + + return ( +
+ handleChange(e.target.value)} + onFocus={() => setIsOpen(true)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + placeholder={t("quickOrder.skuPlaceholder")} + aria-label={ariaLabel} + role="combobox" + aria-expanded={showDropdown} + aria-controls={listboxId} + aria-autocomplete="list" + aria-activedescendant={ + selectedIndex >= 0 + ? `${listboxId}-option-${selectedIndex}` + : undefined + } + /> + + {caption && ( +

{caption}

+ )} + + {showDropdown && ( +
{ + if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); + }} + > + {loading ? ( +

+ + {t("quickOrder.searching")} +

+ ) : suggestions.length > 0 ? ( +
    + {suggestions.map((suggestion, index) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ {t("quickOrder.noResults")} +

+ )} +
+ )} +
+ ); +} diff --git a/src/app/[country]/[locale]/(wholesale)/wholesale/quick-order/page.tsx b/src/app/[country]/[locale]/(wholesale)/wholesale/quick-order/page.tsx new file mode 100644 index 00000000..dce43158 --- /dev/null +++ b/src/app/[country]/[locale]/(wholesale)/wholesale/quick-order/page.tsx @@ -0,0 +1,17 @@ +import { WholesaleGate } from "../_components/WholesaleGate"; +import { QuickOrderView } from "./QuickOrderView"; + +interface QuickOrderPageProps { + params: Promise<{ country: string; locale: string }>; +} + +export default async function WholesaleQuickOrderPage({ + params, +}: QuickOrderPageProps) { + const { country, locale } = await params; + return ( + + {() => } + + ); +} diff --git a/src/components/account/GiftCardList.tsx b/src/components/account/GiftCardList.tsx index c36f7a15..70bbcd52 100644 --- a/src/components/account/GiftCardList.tsx +++ b/src/components/account/GiftCardList.tsx @@ -132,10 +132,10 @@ function GiftCardItem({ card }: { card: GiftCard }) {
- {t("usedAmount", { amount: card.display_amount_used })} + {t("usedAmount", { amount: card.display_amount_used ?? "" })} - {t("totalAmountWithValue", { amount: card.display_amount })} + {t("totalAmountWithValue", { amount: card.display_amount ?? "" })}
diff --git a/src/components/cart/CartDrawer.tsx b/src/components/cart/CartDrawer.tsx index f5904de4..5eb81810 100644 --- a/src/components/cart/CartDrawer.tsx +++ b/src/components/cart/CartDrawer.tsx @@ -207,6 +207,7 @@ export function CartDrawer() {
{item.compare_at_amount && + item.price != null && parseFloat(item.compare_at_amount) > parseFloat(item.price) ? ( <> @@ -261,7 +262,7 @@ export function CartDrawer() { )} {/* Express Checkout — must stay mounted during processing */} - {cart && parseFloat(cart.total) > 0 && ( + {cart && parseFloat(cart.total ?? "0") > 0 && ( { - if (!isStripeConfigured) { + if (!isStripeConfigured || !payable) { onAvailabilityChange?.(false); } - }, [onAvailabilityChange]); + }, [onAvailabilityChange, payable]); - if (!isStripeConfigured) { + if (!isStripeConfigured || !payable) { return null; } diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index 19537e5f..d0036e88 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -105,9 +105,14 @@ export function PaymentSection({ paymentMethods.find((pm) => pm.id === selectedMethodId) ?? paymentMethods[0]; const effectiveSelectedMethodId = selectedMethod?.id ?? ""; - // Zero-amount check - const amountDue = parseFloat(cart.amount_due ?? cart.total); - const isZeroAmount = amountDue === 0; + // Zero-amount check. A null amount (money fields are nullable for + // prices-hidden guests) must NOT read as zero — that would complete + // checkout with no payment. Only a real numeric 0 is a free order; + // an unknown amount falls through to the normal payment path. + const rawAmountDue = cart.amount_due ?? cart.total; + const amountDue = + rawAmountDue == null ? Number.NaN : parseFloat(rawAmountDue); + const isZeroAmount = Number.isFinite(amountDue) && amountDue === 0; // Free orders are always treated as non-session (no payment needed) const isSessionBased = diff --git a/src/components/checkout/Summary.tsx b/src/components/checkout/Summary.tsx index 84fcc5f3..1a43f2fa 100644 --- a/src/components/checkout/Summary.tsx +++ b/src/components/checkout/Summary.tsx @@ -77,7 +77,7 @@ export function Summary({ cart }: SummaryProps) {
)} - {parseFloat(cart.tax_total) > 0 && ( + {parseFloat(cart.tax_total ?? "0") > 0 && (
{tc("tax")} {cart.display_tax_total} @@ -101,7 +101,7 @@ export function Summary({ cart }: SummaryProps) { {/* Gift card or store credit — shown below total, reduces amount due. Gift cards use store credits under the hood, so only show one. */} - {cart.gift_card && parseFloat(cart.gift_card_total) > 0 ? ( + {cart.gift_card && parseFloat(cart.gift_card_total ?? "0") > 0 ? (
{tc("giftCard")} diff --git a/src/components/home/WholesaleSection.tsx b/src/components/home/WholesaleSection.tsx new file mode 100644 index 00000000..a3c28230 --- /dev/null +++ b/src/components/home/WholesaleSection.tsx @@ -0,0 +1,97 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { Button } from "@/components/ui/button"; +import { isWholesaleEnabled } from "@/lib/spree"; + +interface WholesaleSectionProps { + basePath: string; + locale: string; +} + +/** + * Trade portal pitch on the homepage. Static by design — no data fetching, so + * the statically prerendered homepage stays static. The slate band matches the + * wholesale portal's chrome, tying the two surfaces together. + */ +export async function WholesaleSection({ + basePath, + locale, +}: WholesaleSectionProps) { + // Opt-in addon: no wholesale pitch on DTC-only storefronts. + if (!isWholesaleEnabled()) return null; + + const t = await getTranslations({ + locale: locale as Locale, + namespace: "home", + }); + + const benefits = [ + { + title: t("wholesaleBenefitPricingTitle"), + description: t("wholesaleBenefitPricingDescription"), + }, + { + title: t("wholesaleBenefitQuickOrderTitle"), + description: t("wholesaleBenefitQuickOrderDescription"), + }, + { + title: t("wholesaleBenefitOrdersTitle"), + description: t("wholesaleBenefitOrdersDescription"), + }, + ]; + + return ( +
+
+
+ {/* Pitch + CTAs */} +
+ + {t("wholesaleBadge")} + +

+ {t("wholesaleTitle")} +

+

{t("wholesaleDescription")}

+
+ + +
+
+ + {/* What approved buyers get — two-up on tablets so it doesn't look sparse */} +
    + {benefits.map((benefit) => ( +
  • +

    {benefit.title}

    +

    + {benefit.description} +

    +
  • + ))} +
+
+
+
+ ); +} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 0e3622a9..6b15cf48 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -2,6 +2,7 @@ import type { Category } from "@spree/sdk"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { POLICY_LINKS } from "@/lib/constants/policies"; +import { isWholesaleEnabled } from "@/lib/spree"; import { getStoreDescription, getStoreName } from "@/lib/store"; import { CurrentYear } from "./CurrentYear"; @@ -27,6 +28,7 @@ export async function Footer({ }: FooterProps) { const t = await getTranslations({ locale, namespace: "footer" }); const tp = await getTranslations({ locale, namespace: "policies" }); + const wholesaleEnabled = isWholesaleEnabled(); return (
@@ -125,6 +127,16 @@ export async function Footer({ {t("cart")} + {wholesaleEnabled && ( +
  • + + {t("wholesale")} + +
  • + )}
    diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 76c05dce..486c1f45 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -7,6 +7,7 @@ import { getTranslations } from "next-intl/server"; import { CartButton } from "@/components/layout/CartButton"; import { SearchToggle } from "@/components/layout/SearchToggle"; import { Button } from "@/components/ui/button"; +import { isWholesaleEnabled } from "@/lib/spree"; import { getStoreName } from "@/lib/store"; const LazyMobileMenu = dynamic( @@ -49,12 +50,17 @@ export async function Header({ locale, }: HeaderProps) { const t = await getTranslations({ locale, namespace: "header" }); + const wholesaleEnabled = isWholesaleEnabled(); return ( + } center={ @@ -71,7 +77,17 @@ export async function Header({ } rightStart={ -
    +
    + {/* Trade portal entry point — understated, secondary to the catalog nav. + Only shown when the wholesale addon is enabled. */} + {wholesaleEnabled && ( + + {t("wholesale")} + + )}
    } diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx index 877cf61a..1df422f9 100644 --- a/src/components/layout/MobileMenu.tsx +++ b/src/components/layout/MobileMenu.tsx @@ -34,9 +34,15 @@ type PanelType = interface MobileMenuProps { rootCategories: Category[]; basePath: string; + /** Whether the wholesale addon is enabled — gates the trade portal link. */ + wholesaleEnabled: boolean; } -export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) { +export function MobileMenu({ + rootCategories, + basePath, + wholesaleEnabled, +}: MobileMenuProps) { const t = useTranslations("header"); const [open, setOpen] = useState(false); const [hasInteracted, setHasInteracted] = useState(false); @@ -260,6 +266,20 @@ export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) { > {t("contact")} + + {/* Secondary group — kept out of the category list above. + Only shown when the wholesale addon is enabled. */} + {wholesaleEnabled && ( +
    + setOpen(false)} + className={`${linkClass} block`} + > + {t("wholesale")} + +
    + )} {/* Footer: Country switcher (mobile + tablet) + Account (mobile only) */} diff --git a/src/components/order/OrderTotals.tsx b/src/components/order/OrderTotals.tsx index 76f0c044..80b0c83b 100644 --- a/src/components/order/OrderTotals.tsx +++ b/src/components/order/OrderTotals.tsx @@ -32,7 +32,7 @@ export function OrderTotals({ order }: OrderTotalsProps) {
    )} - {Number.parseFloat(order.tax_total) > 0 && ( + {Number.parseFloat(order.tax_total ?? "0") > 0 && (
    {t("tax")} {order.display_tax_total} @@ -46,7 +46,8 @@ export function OrderTotals({ order }: OrderTotalsProps) {
    - {order.gift_card && Number.parseFloat(order.gift_card_total) > 0 ? ( + {order.gift_card && + Number.parseFloat(order.gift_card_total ?? "0") > 0 ? (
    {t("giftCard")} @@ -63,7 +64,7 @@ export function OrderTotals({ order }: OrderTotalsProps) {
    ) : null} - {Number.parseFloat(order.amount_due) > 0 && + {Number.parseFloat(order.amount_due ?? "0") > 0 && order.amount_due !== order.total && (
    diff --git a/src/components/order/PaymentInfo.tsx b/src/components/order/PaymentInfo.tsx index e7f158c2..6e327bf4 100644 --- a/src/components/order/PaymentInfo.tsx +++ b/src/components/order/PaymentInfo.tsx @@ -48,7 +48,7 @@ export function PaymentInfo({ payment, storeCreditLabel }: PaymentInfoProps) {

    {label}

    {t("storeCreditApplied", { - amount: payment.display_amount, + amount: payment.display_amount ?? "", remaining: credit.display_amount_remaining, })}

    diff --git a/src/components/products/FeaturedProducts.tsx b/src/components/products/FeaturedProducts.tsx index ac989146..1183c64c 100644 --- a/src/components/products/FeaturedProducts.tsx +++ b/src/components/products/FeaturedProducts.tsx @@ -37,6 +37,7 @@ export async function FeaturedProducts({ const productsResponse = await cachedListProducts( { limit: 8, fields: PRODUCT_CARD_FIELDS }, { locale, country }, + "dtc", userToken, ); diff --git a/src/components/products/HiddenPricePrompt.tsx b/src/components/products/HiddenPricePrompt.tsx new file mode 100644 index 00000000..042ecc33 --- /dev/null +++ b/src/components/products/HiddenPricePrompt.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Lock } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { useHiddenPricing } from "@/contexts/HiddenPricingContext"; + +/** + * Rendered in place of a price when the viewer isn't entitled to see it (a guest + * on a `prices_hidden` channel). Links to the wholesale sign-in, returning the + * buyer to the page they were on. Renders nothing outside a HiddenPricingProvider + * — so on the DTC storefront a genuinely absent price stays silent. + */ +export function HiddenPricePrompt({ className }: { className?: string }) { + const hiddenPricing = useHiddenPricing(); + const t = useTranslations("wholesale"); + + if (!hiddenPricing) return null; + + return ( + e.stopPropagation()} + > + + {t("hiddenPrice.signInForPricing")} + + ); +} diff --git a/src/components/products/ProductCard.tsx b/src/components/products/ProductCard.tsx index 4ad0406a..b41f1ec5 100644 --- a/src/components/products/ProductCard.tsx +++ b/src/components/products/ProductCard.tsx @@ -4,6 +4,7 @@ import type { Product } from "@spree/sdk"; import Link from "next/link"; import { useTranslations } from "next-intl"; import { memo } from "react"; +import { HiddenPricePrompt } from "@/components/products/HiddenPricePrompt"; import { ProductImage } from "@/components/ui/product-image"; import { trackSelectItem } from "@/lib/analytics/gtm"; @@ -90,10 +91,14 @@ export const ProductCard = memo(function ProductCard({
    - {displayPrice && ( + {displayPrice ? ( {displayPrice} + ) : ( + // Null price: a deliberate hide inside a HiddenPricingProvider + // (renders a sign-in prompt), otherwise renders nothing. + )} {onSale && strikethroughPrice && ( diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 456a8a26..82f17595 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -38,6 +38,8 @@ interface AuthContextType { password_confirmation: string; first_name?: string; last_name?: string; + phone?: string; + metadata?: Record; }) => Promise<{ success: boolean; error?: string }>; logout: () => Promise; refreshUser: () => Promise; @@ -137,6 +139,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { password_confirmation: string; first_name?: string; last_name?: string; + phone?: string; + metadata?: Record; }) => { const result = await registerAction(params); if (result.success && result.user) { diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx index 4e812a15..e65f608a 100644 --- a/src/contexts/CartContext.tsx +++ b/src/contexts/CartContext.tsx @@ -19,6 +19,7 @@ import { removeCartItem as removeCartItemAction, updateCartItem as updateCartItemAction, } from "@/lib/data/cart"; +import type { Surface } from "@/lib/spree/surface"; interface CartContextType { cart: Cart | null; @@ -36,7 +37,14 @@ interface CartContextType { const CartContext = createContext(undefined); -export function CartProvider({ children }: { children: ReactNode }) { +export function CartProvider({ + children, + surface = "dtc", +}: { + children: ReactNode; + /** Which surface's cart this provider manages. Defaults to the DTC cart. */ + surface?: Surface; +}) { const [cart, setCart] = useState(null); const [loading, setLoading] = useState(true); const [updating, setUpdating] = useState(false); @@ -50,14 +58,14 @@ export function CartProvider({ children }: { children: ReactNode }) { const refreshCart = useCallback(async () => { try { - const cartData = await getCartAction(); + const cartData = await getCartAction(undefined, surface); setCart(cartData); } catch { setCart(null); } finally { setLoading(false); } - }, []); + }, [surface]); const mutateCart = useCallback( async ( @@ -91,32 +99,32 @@ export function CartProvider({ children }: { children: ReactNode }) { const addItem = useCallback( async (variantId: string, quantity = 1) => { await mutateCart( - () => addToCartAction(variantId, quantity), + () => addToCartAction(variantId, quantity, surface), t("failedToAddItem"), () => setIsOpen(true), ); }, - [mutateCart, t], + [mutateCart, t, surface], ); const updateItem = useCallback( async (lineItemId: string, quantity: number) => { await mutateCart( - () => updateCartItemAction(lineItemId, quantity), + () => updateCartItemAction(lineItemId, quantity, surface), t("failedToUpdateItem"), ); }, - [mutateCart, t], + [mutateCart, t, surface], ); const removeItem = useCallback( async (lineItemId: string) => { await mutateCart( - () => removeCartItemAction(lineItemId), + () => removeCartItemAction(lineItemId, surface), t("failedToRemoveItem"), ); }, - [mutateCart, t], + [mutateCart, t, surface], ); // Re-fetch cart on navigation (e.g., after checkout completes, the stale diff --git a/src/contexts/HiddenPricingContext.tsx b/src/contexts/HiddenPricingContext.tsx new file mode 100644 index 00000000..15876522 --- /dev/null +++ b/src/contexts/HiddenPricingContext.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { createContext, useContext } from "react"; + +/** + * Signals to shared price-rendering components (ProductCard, ProductDetails) + * that money fields may be `null` because the current viewer isn't entitled to + * see them — a guest on a `prices_hidden` channel. When active, a null price is + * a *deliberate* hide, so those components render a sign-in prompt instead of + * silently omitting the price. + * + * Only the wholesale route group provides this. The DTC storefront never does, + * so its null-price behaviour (render nothing) is unchanged. + */ +export interface HiddenPricingValue { + /** Where the sign-in prompt links to (already includes the return `?redirect=`). */ + signInHref: string; +} + +const HiddenPricingContext = createContext(null); + +export function HiddenPricingProvider({ + value, + children, +}: { + value: HiddenPricingValue; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +/** + * Returns the hidden-pricing context, or `null` outside a provider (i.e. the + * DTC storefront). A null return means "prices aren't gated here" — components + * should keep their existing behaviour. + */ +export function useHiddenPricing(): HiddenPricingValue | null { + return useContext(HiddenPricingContext); +} diff --git a/src/contexts/__tests__/CartContext.test.tsx b/src/contexts/__tests__/CartContext.test.tsx index 70ee02d8..338243c3 100644 --- a/src/contexts/__tests__/CartContext.test.tsx +++ b/src/contexts/__tests__/CartContext.test.tsx @@ -116,7 +116,7 @@ describe("CartContext", () => { await result.current.addItem("variant-1", 2); }); - expect(mockAddToCart).toHaveBeenCalledWith("variant-1", 2); + expect(mockAddToCart).toHaveBeenCalledWith("variant-1", 2, "dtc"); expect(result.current.cart).toBe(updatedCart); expect(result.current.isOpen).toBe(true); expect(result.current.updating).toBe(false); @@ -179,7 +179,7 @@ describe("CartContext", () => { await result.current.updateItem("li-1", 5); }); - expect(mockUpdateCartItem).toHaveBeenCalledWith("li-1", 5); + expect(mockUpdateCartItem).toHaveBeenCalledWith("li-1", 5, "dtc"); expect(result.current.cart).toBe(updatedCart); expect(result.current.updating).toBe(false); }); @@ -207,7 +207,7 @@ describe("CartContext", () => { await result.current.removeItem("li-1"); }); - expect(mockRemoveCartItem).toHaveBeenCalledWith("li-1"); + expect(mockRemoveCartItem).toHaveBeenCalledWith("li-1", "dtc"); expect(result.current.cart).toBe(cartAfterRemoval); expect(result.current.itemCount).toBe(1); }); diff --git a/src/lib/data/__tests__/cart.test.ts b/src/lib/data/__tests__/cart.test.ts index 8f6baf03..fdf82a06 100644 --- a/src/lib/data/__tests__/cart.test.ts +++ b/src/lib/data/__tests__/cart.test.ts @@ -12,16 +12,34 @@ const mockClient = { delete: vi.fn(), }, }, + channel: { + get: vi.fn().mockResolvedValue({ id: "ch-dtc", code: "public" }), + }, }; +const { mockGetCartId } = vi.hoisted(() => ({ mockGetCartId: vi.fn() })); + vi.mock("@/lib/spree", () => ({ getClient: () => mockClient, + getClientForSurface: () => mockClient, + cacheTagSuffix: () => "", + DEFAULT_SURFACE: "dtc", + isWholesaleEnabled: vi.fn().mockReturnValue(false), getCartToken: vi.fn().mockResolvedValue("order-token-123"), - getCartId: vi.fn().mockResolvedValue("cart-1"), + // Surface-aware default is (re)installed in beforeEach — clearAllMocks resets + // implementations, so setting it here would not survive. + getCartId: mockGetCartId, getAccessToken: vi.fn().mockResolvedValue(undefined), getLocaleOptions: vi.fn().mockResolvedValue({ locale: "en", country: "us" }), setCartCookies: vi.fn(), clearCartCookies: vi.fn(), + // Real logic against the mocked getCartId — the DTC cookie is poisoned when + // it holds the wholesale cart's id. + isPoisonedDtcCartId: async (cartId: string, surface: string) => { + if (surface !== "dtc") return false; + const wholesaleCartId = await mockGetCartId("wholesale"); + return Boolean(wholesaleCartId) && wholesaleCartId === cartId; + }, getCartOptions: vi.fn().mockResolvedValue({ spreeToken: "order-token-123", token: undefined, @@ -54,8 +72,15 @@ const mockCart = { }; describe("cart server actions", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + // Surface-aware cart-id cookie: only DTC has one by default, so the + // cross-surface poison guard sees no wholesale cookie to collide with. + const { getCartId } = await import("@/lib/spree"); + (getCartId as ReturnType).mockImplementation( + async (surface = "dtc") => + surface === "wholesale" ? undefined : "cart-1", + ); }); describe("getCart", () => { @@ -68,6 +93,52 @@ describe("cart server actions", () => { }); expect(result).toBe(mockCart); }); + + it("drops a DTC cookie poisoned with the wholesale cart id and returns null", async () => { + const { getCartId, clearCartCookies } = await import("@/lib/spree"); + // Both surfaces' cookies point at the same cart — the pre-fix poisoning. + (getCartId as ReturnType).mockResolvedValue("cart-1"); + + const result = await getCart(undefined, "dtc"); + + expect(result).toBeNull(); + expect(mockClient.carts.get).not.toHaveBeenCalled(); + expect(clearCartCookies).toHaveBeenCalledWith("dtc"); + }); + + it("keeps the wholesale cart even when the DTC cookie collides (directional guard)", async () => { + const { getCartId } = await import("@/lib/spree"); + (getCartId as ReturnType).mockResolvedValue("cart-1"); + mockClient.carts.get.mockResolvedValue(mockCart); + + const result = await getCart(undefined, "wholesale"); + + expect(result).toBe(mockCart); + }); + + it("drops a cookie cart whose channel_id does not match the surface", async () => { + const { clearCartCookies } = await import("@/lib/spree"); + mockClient.carts.get.mockResolvedValue({ + ...mockCart, + channel_id: "ch-wholesale", + }); + // DTC surface resolves to ch-dtc (mockClient.channel.get), so ch-wholesale + // is a confirmed mismatch. + const result = await getCart(undefined, "dtc"); + + expect(result).toBeNull(); + expect(clearCartCookies).toHaveBeenCalledWith("dtc"); + }); + + it("keeps a cookie cart whose channel_id matches the surface", async () => { + mockClient.carts.get.mockResolvedValue({ + ...mockCart, + channel_id: "ch-dtc", + }); + const result = await getCart(undefined, "dtc"); + + expect(result).toMatchObject({ id: "cart-1" }); + }); }); describe("getOrCreateCart", () => { @@ -176,7 +247,10 @@ describe("cart server actions", () => { expect(mockClient.carts.items.delete).toHaveBeenCalledWith( "cart-1", "li-1", - { spreeToken: "order-token-123", token: undefined }, + { + spreeToken: "order-token-123", + token: undefined, + }, ); expect(result).toEqual({ success: true, cart: mockCart }); }); diff --git a/src/lib/data/__tests__/checkout.test.ts b/src/lib/data/__tests__/checkout.test.ts index 1f267189..847da157 100644 --- a/src/lib/data/__tests__/checkout.test.ts +++ b/src/lib/data/__tests__/checkout.test.ts @@ -15,11 +15,21 @@ const mockClient = { vi.mock("@/lib/spree", () => ({ getClient: () => mockClient, + getClientForSurface: () => mockClient, + cacheTagSuffix: () => "", + DEFAULT_SURFACE: "dtc", + isWholesaleEnabled: vi.fn().mockReturnValue(false), getCartToken: vi.fn().mockResolvedValue("order-token-123"), - getCartId: vi.fn().mockResolvedValue("order-1"), + // DTC cart only; the wholesale cart cookie is absent so poison/surface + // checks resolve to DTC. + getCartId: vi.fn((surface = "dtc") => + Promise.resolve(surface === "wholesale" ? undefined : "order-1"), + ), getAccessToken: vi.fn().mockResolvedValue(undefined), setCartCookies: vi.fn(), clearCartCookies: vi.fn(), + // No wholesale cookie in these DTC tests → never poisoned. + isPoisonedDtcCartId: vi.fn().mockResolvedValue(false), getCartOptions: vi.fn().mockResolvedValue({ spreeToken: "order-token-123", token: undefined, @@ -113,7 +123,10 @@ describe("checkout server actions", () => { expect(mockClient.carts.update).toHaveBeenCalledWith( "order-1", addresses, - { spreeToken: "order-token-123", token: undefined }, + { + spreeToken: "order-token-123", + token: undefined, + }, ); expect(result).toEqual({ success: true, cart: mockOrder }); }); @@ -358,7 +371,10 @@ describe("checkout server actions", () => { expect(mockClient.carts.discountCodes.remove).toHaveBeenCalledWith( "order-1", "SAVE10", - { spreeToken: "order-token-123", token: undefined }, + { + spreeToken: "order-token-123", + token: undefined, + }, ); expect(result).toEqual({ success: true, cart: mockOrder }); }); @@ -386,7 +402,10 @@ describe("checkout server actions", () => { expect(mockClient.carts.giftCards.remove).toHaveBeenCalledWith( "order-1", "gc_abc123", - { spreeToken: "order-token-123", token: undefined }, + { + spreeToken: "order-token-123", + token: undefined, + }, ); expect(result).toEqual({ success: true, cart: mockOrder }); }); diff --git a/src/lib/data/__tests__/customer.test.ts b/src/lib/data/__tests__/customer.test.ts index 6b4eb1a5..abb077e8 100644 --- a/src/lib/data/__tests__/customer.test.ts +++ b/src/lib/data/__tests__/customer.test.ts @@ -41,6 +41,10 @@ vi.mock("@/lib/spree", () => ({ getCartToken: vi.fn().mockResolvedValue(undefined), getCartId: vi.fn().mockResolvedValue(undefined), clearCartCookies: vi.fn(), + clearAllCartCookies: vi.fn(), + cacheTagSuffix: (surface: string) => + surface === "wholesale" ? "-wholesale" : "", + SURFACES: ["dtc", "wholesale"] as const, })); vi.mock("@spree/sdk", () => ({ @@ -301,11 +305,24 @@ describe("customer server actions", () => { it("clears cookies", async () => { await logout(); - const { clearAccessToken, clearRefreshToken, clearCartCookies } = + const { clearAccessToken, clearRefreshToken, clearAllCartCookies } = await import("@/lib/spree"); expect(clearAccessToken).toHaveBeenCalled(); expect(clearRefreshToken).toHaveBeenCalled(); - expect(clearCartCookies).toHaveBeenCalled(); + // Logout must clear every surface's cart, not just DTC. + expect(clearAllCartCookies).toHaveBeenCalled(); + }); + + it("invalidates cart and checkout caches for every surface", async () => { + const { updateTag } = await import("next/cache"); + await logout(); + + // Both surfaces, both tags — a cart-only clear would leave the previous + // buyer's checkout (address/delivery) state cached after logout. + expect(updateTag).toHaveBeenCalledWith("cart"); + expect(updateTag).toHaveBeenCalledWith("cart-wholesale"); + expect(updateTag).toHaveBeenCalledWith("checkout"); + expect(updateTag).toHaveBeenCalledWith("checkout-wholesale"); }); }); diff --git a/src/lib/data/__tests__/payment.test.ts b/src/lib/data/__tests__/payment.test.ts index 5f179058..cfffee29 100644 --- a/src/lib/data/__tests__/payment.test.ts +++ b/src/lib/data/__tests__/payment.test.ts @@ -14,6 +14,10 @@ const mockClient = { vi.mock("@/lib/spree", () => ({ getClient: () => mockClient, + getClientForSurface: () => mockClient, + cacheTagSuffix: () => "", + DEFAULT_SURFACE: "dtc", + isWholesaleEnabled: vi.fn().mockReturnValue(false), getCartToken: vi.fn().mockResolvedValue("order-token-123"), getCartId: vi.fn().mockResolvedValue("cart-1"), getAccessToken: vi.fn().mockResolvedValue(undefined), diff --git a/src/lib/data/__tests__/resolve-surface.test.ts b/src/lib/data/__tests__/resolve-surface.test.ts new file mode 100644 index 00000000..896efee0 --- /dev/null +++ b/src/lib/data/__tests__/resolve-surface.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Isolated from checkout.test.ts: this file mocks ../cart and ../wholesale so it +// can drive the wholesale lookup's outcome. checkout.test.ts exercises the real +// getCart, so the two can't share a module registry. + +// vi.mock factories are hoisted above module top-level, so the mock fns they +// close over must be created via vi.hoisted (also hoisted) to exist in time. +const { mockGetCartId, mockGetCart, mockGetWholesaleChannel } = vi.hoisted( + () => ({ + mockGetCartId: vi.fn(), + mockGetCart: vi.fn(), + mockGetWholesaleChannel: vi.fn(), + }), +); + +vi.mock("@/lib/spree", () => ({ + isWholesaleEnabled: vi.fn().mockReturnValue(true), + getCartId: (surface?: string) => mockGetCartId(surface), + cacheTagSuffix: () => "", +})); + +vi.mock("../cart", () => ({ getCart: mockGetCart })); + +vi.mock("../wholesale", () => ({ + getWholesaleChannel: mockGetWholesaleChannel, +})); + +vi.mock("next/cache", () => ({ updateTag: vi.fn() })); + +import { resolveSurfaceForCartVerified } from "@/lib/data/checkout"; +import { isWholesaleEnabled } from "@/lib/spree"; + +describe("resolveSurfaceForCartVerified", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isWholesaleEnabled).mockReturnValue(true); + // Cookie says not-wholesale by default, forcing the channel_id check. + mockGetCartId.mockResolvedValue(undefined); + }); + + it("returns dtc without any lookup when wholesale is disabled", async () => { + vi.mocked(isWholesaleEnabled).mockReturnValue(false); + + const surface = await resolveSurfaceForCartVerified("cart-x"); + + expect(surface).toBe("dtc"); + expect(mockGetCart).not.toHaveBeenCalled(); + }); + + it("resolves wholesale from the cookie without a fetch", async () => { + mockGetCartId.mockResolvedValue("cart-x"); // wholesale cookie matches + + const surface = await resolveSurfaceForCartVerified("cart-x"); + + expect(surface).toBe("wholesale"); + expect(mockGetCart).not.toHaveBeenCalled(); + }); + + it("confirms dtc when the fetched cart's channel differs from wholesale", async () => { + mockGetCart.mockResolvedValue({ id: "cart-x", channel_id: "dtc-chan" }); + mockGetWholesaleChannel.mockResolvedValue({ id: "wholesale-chan" }); + + const surface = await resolveSurfaceForCartVerified("cart-x"); + + expect(surface).toBe("dtc"); + }); + + it("resolves wholesale when the fetched cart's channel matches", async () => { + mockGetCart.mockResolvedValue({ id: "cart-x", channel_id: "ws-chan" }); + mockGetWholesaleChannel.mockResolvedValue({ id: "ws-chan" }); + + const surface = await resolveSurfaceForCartVerified("cart-x"); + + expect(surface).toBe("wholesale"); + }); + + it("fails closed (unverified) when the wholesale lookup throws", async () => { + // Transient failure — must NOT be mistaken for a confirmed DTC cart, or the + // offsite-payment path would complete a wholesale order via the DTC client. + mockGetCart.mockRejectedValue(new Error("network")); + mockGetWholesaleChannel.mockResolvedValue({ id: "ws-chan" }); + + const surface = await resolveSurfaceForCartVerified("cart-x"); + + expect(surface).toBe("unverified"); + }); + + it("fails closed (unverified) when the cart can't be fetched", async () => { + mockGetCart.mockResolvedValue(null); + mockGetWholesaleChannel.mockResolvedValue({ id: "ws-chan" }); + + const surface = await resolveSurfaceForCartVerified("cart-x"); + + expect(surface).toBe("unverified"); + }); + + it("fails closed (unverified) when the channel can't be resolved", async () => { + mockGetCart.mockResolvedValue({ id: "cart-x", channel_id: "ws-chan" }); + mockGetWholesaleChannel.mockResolvedValue(null); + + const surface = await resolveSurfaceForCartVerified("cart-x"); + + expect(surface).toBe("unverified"); + }); + + it("fails closed (unverified) when the cart has no channel_id", async () => { + mockGetCart.mockResolvedValue({ id: "cart-x", channel_id: null }); + mockGetWholesaleChannel.mockResolvedValue({ id: "ws-chan" }); + + const surface = await resolveSurfaceForCartVerified("cart-x"); + + expect(surface).toBe("unverified"); + }); +}); diff --git a/src/lib/data/cached.ts b/src/lib/data/cached.ts index db27b123..fe6c03dd 100644 --- a/src/lib/data/cached.ts +++ b/src/lib/data/cached.ts @@ -1,4 +1,5 @@ import { cache } from "react"; +import type { Surface } from "@/lib/spree"; import { getCategory } from "./categories"; import { getProduct } from "./products"; @@ -35,8 +36,9 @@ export const PRODUCT_CARD_FIELDS = [ "categories", ]; -export const getCachedProduct = cache((slugOrId: string, expand: string[]) => - getProduct(slugOrId, { expand }), +export const getCachedProduct = cache( + (slugOrId: string, expand: string[], surface: Surface = "dtc") => + getProduct(slugOrId, { expand }, surface), ); export const getCachedCategory = cache( diff --git a/src/lib/data/cart.ts b/src/lib/data/cart.ts index 3025bb86..843406e9 100644 --- a/src/lib/data/cart.ts +++ b/src/lib/data/cart.ts @@ -3,39 +3,95 @@ import type { Cart, CreateCartParams } from "@spree/sdk"; import { updateTag } from "next/cache"; import { + cacheTagSuffix, clearCartCookies, + DEFAULT_SURFACE, getAccessToken, getCartId, getCartOptions, getCartToken, - getClient, + getClientForSurface, getLocaleOptions, + isPoisonedDtcCartId, requireCartId, + type Surface, setCartCookies, } from "@/lib/spree"; import { actionResult } from "./utils"; +/** Cache tag for a surface's cart, so DTC and wholesale carts invalidate independently. */ +function cartTag(surface: Surface): string { + return `cart${cacheTagSuffix(surface)}`; +} + /** - * Get the current cart. Returns null if no cart exists. + * Whether a cart belongs to the given surface's sales channel. Cross-surface + * isolation guard: a cart carries the channel it was created on (`channel_id`), + * and each surface only shows its own channel's cart. Prevents a cart-id cookie + * that was poisoned with another surface's cart (see getCart) from resurrecting + * that cart via the intentionally cross-channel `carts.get` endpoint. + * + * Returns true when the cart's channel is unknown (null) or matches — i.e. only + * a *confirmed* mismatch rejects, so this never hides a legitimate cart. */ -export async function getCart(explicitCartId?: string): Promise { - const spreeToken = await getCartToken(); +async function cartBelongsToSurface( + cart: Cart, + surface: Surface, +): Promise { + if (!cart.channel_id) return true; // channel unknown — can't reject + + try { + const channel = await getClientForSurface(surface).channel.get(); + return channel.id === cart.channel_id; + } catch { + // Channel lookup failed (transient) — don't reject on an unknown. + return true; + } +} + +/** + * Get the current cart for a surface. Returns null if no cart exists. + */ +export async function getCart( + explicitCartId?: string, + surface: Surface = DEFAULT_SURFACE, +): Promise { + const spreeToken = await getCartToken(surface); const token = await getAccessToken(); - const cartId = explicitCartId ?? (await getCartId()); + const cartId = explicitCartId ?? (await getCartId(surface)); + const client = getClientForSurface(surface); if (!cartId && !token) return null; try { if (cartId) { - return await getClient().carts.get(cartId, { spreeToken, token }); + // Guard the cookie-derived cart against cross-surface poisoning. Skipped + // for an explicit cartId (the shared checkout resolves surface itself and + // legitimately reads a cart by id across channels). + if (!explicitCartId && (await isPoisonedDtcCartId(cartId, surface))) { + await dropSurfaceCartCookies(surface); + return null; + } + + const cart = await client.carts.get(cartId, { spreeToken, token }); + + if (!explicitCartId && !(await cartBelongsToSurface(cart, surface))) { + await dropSurfaceCartCookies(surface); + return null; + } + + return cart; } - // Authenticated user without stored cart ID — find their most recent cart + // Authenticated user without stored cart ID — find their most recent cart. + // carts.list is channel-scoped on the backend, so this only returns carts + // for the surface's channel; still verify before adopting into the cookie. if (token) { - const response = await getClient().carts.list({ token }); + const response = await client.carts.list({ token }); if (response.data.length > 0) { const cart = response.data[0]; - await setCartCookies(cart.id, cart.token); + if (!(await cartBelongsToSurface(cart, surface))) return null; + await setCartCookies(cart.id, cart.token, surface); return cart; } } @@ -46,112 +102,136 @@ export async function getCart(explicitCartId?: string): Promise { // Wrapped in try/catch because clearCartCookies sets cookies, which // is not allowed in Server Components (only in Server Actions). if (!explicitCartId) { - try { - await clearCartCookies(); - } catch { - // Ignore — cookie clearing is best-effort - } + await dropSurfaceCartCookies(surface); } return null; } } +/** Best-effort cookie clear (cookies aren't writable during a Server Component render). */ +async function dropSurfaceCartCookies(surface: Surface): Promise { + try { + await clearCartCookies(surface); + } catch { + // Ignore — cookie clearing is best-effort + } +} + /** - * Get existing cart or create a new one. + * Get existing cart or create a new one on a surface. Wholesale carts are + * created through the wholesale client so the order attributes to the wholesale + * channel and inherits its no-guest-checkout rule. */ export async function getOrCreateCart( params?: CreateCartParams, + surface: Surface = DEFAULT_SURFACE, ): Promise { - const existing = await getCart(); + const existing = await getCart(undefined, surface); if (existing) return existing; const token = await getAccessToken(); const localeOptions = await getLocaleOptions(); const cartParams = params && Object.keys(params).length > 0 ? params : undefined; - const cart = await getClient().carts.create(cartParams, { + const cart = await getClientForSurface(surface).carts.create(cartParams, { ...localeOptions, ...(token ? { token } : undefined), }); - await setCartCookies(cart.id, cart.token); + await setCartCookies(cart.id, cart.token, surface); - updateTag("cart"); + updateTag(cartTag(surface)); return cart; } -export async function clearCart() { +export async function clearCart(surface: Surface = DEFAULT_SURFACE) { return actionResult(async () => { - await clearCartCookies(); - updateTag("cart"); + await clearCartCookies(surface); + updateTag(cartTag(surface)); return {}; }, "Failed to clear cart"); } -export async function addToCart(variantId: string, quantity: number) { +export async function addToCart( + variantId: string, + quantity: number, + surface: Surface = DEFAULT_SURFACE, +) { return actionResult(async () => { - const cart = await getOrCreateCart(); - const spreeToken = await getCartToken(); + const cart = await getOrCreateCart(undefined, surface); + const spreeToken = await getCartToken(surface); const token = await getAccessToken(); - const updatedCart = await getClient().carts.items.create( + const updatedCart = await getClientForSurface(surface).carts.items.create( cart.id, { variant_id: variantId, quantity }, { spreeToken, token }, ); - updateTag("cart"); + updateTag(cartTag(surface)); return { cart: updatedCart }; }, "Failed to add item to cart"); } -export async function updateCartItem(lineItemId: string, quantity: number) { +export async function updateCartItem( + lineItemId: string, + quantity: number, + surface: Surface = DEFAULT_SURFACE, +) { return actionResult(async () => { - const options = await getCartOptions(); - const cartId = await requireCartId(); + const options = await getCartOptions(surface); + const cartId = await requireCartId(surface); - const cart = await getClient().carts.items.update( + const cart = await getClientForSurface(surface).carts.items.update( cartId, lineItemId, { quantity }, options, ); - updateTag("cart"); + updateTag(cartTag(surface)); return { cart }; }, "Failed to update cart item"); } -export async function removeCartItem(lineItemId: string) { +export async function removeCartItem( + lineItemId: string, + surface: Surface = DEFAULT_SURFACE, +) { return actionResult(async () => { - const options = await getCartOptions(); - const cartId = await requireCartId(); + const options = await getCartOptions(surface); + const cartId = await requireCartId(surface); - const cart = await getClient().carts.items.delete( + const cart = await getClientForSurface(surface).carts.items.delete( cartId, lineItemId, options, ); - updateTag("cart"); + updateTag(cartTag(surface)); return { cart }; }, "Failed to remove cart item"); } -export async function associateCartWithUser() { +export async function associateCartWithUser( + surface: Surface = DEFAULT_SURFACE, +) { return actionResult(async () => { - const spreeToken = await getCartToken(); + const spreeToken = await getCartToken(surface); const token = await getAccessToken(); - const cartId = await getCartId(); + const cartId = await getCartId(surface); if (!cartId || !token) return {}; try { - await getClient().carts.associate(cartId, { spreeToken, token }); - updateTag("cart"); + await getClientForSurface(surface).carts.associate(cartId, { + spreeToken, + token, + }); + updateTag(cartTag(surface)); } catch { // Cart might already belong to another user — clear it - await clearCartCookies(); - updateTag("cart"); + await clearCartCookies(surface); + updateTag(cartTag(surface)); } return {}; }, "Failed to associate cart"); diff --git a/src/lib/data/checkout.ts b/src/lib/data/checkout.ts index f989dfff..0d8ed357 100644 --- a/src/lib/data/checkout.ts +++ b/src/lib/data/checkout.ts @@ -3,30 +3,111 @@ import type { AddressParams, Cart } from "@spree/sdk"; import { SpreeError } from "@spree/sdk"; import { updateTag } from "next/cache"; -import { getCartOptions, getClient, requireCartId } from "@/lib/spree"; +import { + cacheTagSuffix, + getCartId, + getCartOptions, + getClientForSurface, + isWholesaleEnabled, + requireCartId, + type Surface, +} from "@/lib/spree"; import { getCart } from "./cart"; import { getOrder } from "./orders"; import { actionResult, withFallback } from "./utils"; +import { getWholesaleChannel } from "./wholesale"; + +/** + * Determine which surface a checkout belongs to by matching its cart id against + * the per-surface cart-id cookie. Cheap and correct for in-session actions, + * where the cookie is always present. For the offsite-payment return path — where + * the cookie may be gone — use {@link resolveSurfaceForCartVerified} instead. + * Defaults to DTC when it matches neither. + */ +export async function resolveSurfaceForCart(cartId: string): Promise { + if (!isWholesaleEnabled()) return "dtc"; + const wholesaleCartId = await getCartId("wholesale"); + return wholesaleCartId === cartId ? "wholesale" : "dtc"; +} + +/** + * Result of the verified surface resolution. `"unverified"` is distinct from + * `"dtc"` on purpose: it means the wholesale check couldn't run to completion + * (transient fetch/channel failure), so the surface is *unknown*, not confirmed + * DTC. Callers on the offsite-payment path must fail closed on `"unverified"` + * rather than routing a possibly-wholesale checkout through the DTC client. + */ +export type VerifiedSurface = Surface | "unverified"; + +/** + * Like {@link resolveSurfaceForCart}, but confirms an ambiguous cart against its + * own `channel_id` rather than trusting the cookie. Used on the offsite-payment + * return, where the wholesale cart cookie can be dropped during the redirect and + * a cookie-only check would route a wholesale checkout through the DTC client. + * The extra fetch only runs when wholesale is enabled and the cookie didn't + * already resolve the cart. + * + * Returns `"unverified"` when the wholesale lookup fails so the caller can fail + * closed — a transient failure must not be mistaken for a confirmed DTC cart. + */ +export async function resolveSurfaceForCartVerified( + cartId: string, +): Promise { + if (!isWholesaleEnabled()) return "dtc"; + + const wholesaleCartId = await getCartId("wholesale"); + if (wholesaleCartId === cartId) return "wholesale"; + + // Cookie says DTC or is absent — verify against the cart's channel. Only a + // *positive* signal decides the surface: a fetched cart whose channel matches + // wholesale → "wholesale"; a fetched cart whose channel differs → confirmed + // "dtc". Anything else (fetch threw, cart null, channel null) is "unverified" + // so the caller fails closed instead of defaulting to DTC. + try { + const [cart, channel] = await Promise.all([ + getCart(cartId, "wholesale"), + getWholesaleChannel(), + ]); + if (!cart || !channel) return "unverified"; + if (cart.channel_id == null) return "unverified"; + return cart.channel_id === channel.id ? "wholesale" : "dtc"; + } catch { + return "unverified"; + } +} + +/** Checkout cache tag, segmented per surface. */ +function checkoutTag(surface: Surface): string { + return `checkout${cacheTagSuffix(surface)}`; +} + +function cartTag(surface: Surface): string { + return `cart${cacheTagSuffix(surface)}`; +} export async function getCheckoutOrder(cartId: string): Promise { + const surface = await resolveSurfaceForCart(cartId); + // Try active cart first (order may still be in checkout) - const cart = await getCart(); + const cart = await getCart(undefined, surface); if (cart && cart.id === cartId) return cart; // Cart completed — fetch as completed order. return withFallback( - async () => (await getOrder(cartId)) as unknown as Cart, + async () => (await getOrder(cartId, undefined, surface)) as unknown as Cart, null, ); } export async function getCompletedOrder(cartId: string): Promise { + const surface = await resolveSurfaceForCart(cartId); + // Fetch order directly — used by the order-placed page. // Does not call getCart() first because getCart() auto-clears // the cart token cookie on failure, which breaks getOrder() // for guest users. return withFallback( - async () => (await getOrder(cartId)) as unknown as Cart, + async () => (await getOrder(cartId, undefined, surface)) as unknown as Cart, null, ); } @@ -43,10 +124,15 @@ export async function updateOrderAddresses( }, ) { return actionResult(async () => { - const options = await getCartOptions(); - const id = await requireCartId(); - const cart = await getClient().carts.update(id, addresses, options); - updateTag("checkout"); + const surface = await resolveSurfaceForCart(cartId); + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const cart = await getClientForSurface(surface).carts.update( + id, + addresses, + options, + ); + updateTag(checkoutTag(surface)); return { cart }; }, "Failed to update addresses"); } @@ -56,10 +142,15 @@ export async function updateCartMarket( params: { currency: string; locale: string }, ) { return actionResult(async () => { - const options = await getCartOptions(); - const id = await requireCartId(); - const cart = await getClient().carts.update(id, params, options); - updateTag("checkout"); + const surface = await resolveSurfaceForCart(cartId); + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const cart = await getClientForSurface(surface).carts.update( + id, + params, + options, + ); + updateTag(checkoutTag(surface)); return { cart }; }, "Failed to update order market"); } @@ -70,15 +161,16 @@ export async function selectDeliveryRate( deliveryRateId: string, ) { return actionResult(async () => { - const options = await getCartOptions(); - const id = await requireCartId(); - const cart = await getClient().carts.fulfillments.update( + const surface = await resolveSurfaceForCart(cartId); + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const cart = await getClientForSurface(surface).carts.fulfillments.update( id, fulfillmentId, { selected_delivery_rate_id: deliveryRateId }, options, ); - updateTag("checkout"); + updateTag(checkoutTag(surface)); return { cart }; }, "Failed to select delivery rate"); } @@ -88,14 +180,16 @@ export async function selectDeliveryRate( * Single input field on checkout, backend determines the type. */ export async function applyCode(cartId: string, code: string) { - const options = await getCartOptions(); - const id = await requireCartId(); + const surface = await resolveSurfaceForCart(cartId); + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const client = getClientForSurface(surface); // Try discount code first (more common) try { - const cart = await getClient().carts.discountCodes.apply(id, code, options); - updateTag("checkout"); - updateTag("cart"); + const cart = await client.carts.discountCodes.apply(id, code, options); + updateTag(checkoutTag(surface)); + updateTag(cartTag(surface)); return { success: true, cart, type: "discount" as const }; } catch (discountError) { // Only fall back to gift card if the discount code was not found (422/404). @@ -110,9 +204,9 @@ export async function applyCode(cartId: string, code: string) { // Discount code not found — try gift card try { - const cart = await getClient().carts.giftCards.apply(id, code, options); - updateTag("checkout"); - updateTag("cart"); + const cart = await client.carts.giftCards.apply(id, code, options); + updateTag(checkoutTag(surface)); + updateTag(cartTag(surface)); return { success: true, cart, type: "gift_card" as const }; } catch (giftCardError) { // Gift card also failed. If it's a specific error (expired, redeemed, etc.) @@ -139,30 +233,32 @@ function errorMessage(err: unknown): string { export async function removeDiscountCode(cartId: string, code: string) { return actionResult(async () => { - const options = await getCartOptions(); - const id = await requireCartId(); - const cart = await getClient().carts.discountCodes.remove( + const surface = await resolveSurfaceForCart(cartId); + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const cart = await getClientForSurface(surface).carts.discountCodes.remove( id, code, options, ); - updateTag("checkout"); - updateTag("cart"); + updateTag(checkoutTag(surface)); + updateTag(cartTag(surface)); return { cart }; }, "Failed to remove discount code"); } export async function removeGiftCard(cartId: string, giftCardId: string) { return actionResult(async () => { - const options = await getCartOptions(); - const id = await requireCartId(); - const cart = await getClient().carts.giftCards.remove( + const surface = await resolveSurfaceForCart(cartId); + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const cart = await getClientForSurface(surface).carts.giftCards.remove( id, giftCardId, options, ); - updateTag("checkout"); - updateTag("cart"); + updateTag(checkoutTag(surface)); + updateTag(cartTag(surface)); return { cart }; }, "Failed to remove gift card"); } diff --git a/src/lib/data/customer.ts b/src/lib/data/customer.ts index 53d232d7..f19e10e9 100644 --- a/src/lib/data/customer.ts +++ b/src/lib/data/customer.ts @@ -3,7 +3,9 @@ import type { Customer } from "@spree/sdk"; import { updateTag } from "next/cache"; import { + cacheTagSuffix, clearAccessToken, + clearAllCartCookies, clearAuthCookies, clearCartCookies, clearRefreshToken, @@ -14,6 +16,7 @@ import { getClient, getRefreshToken, isAuthError, + SURFACES, setAccessToken, setRefreshToken, withAuthRefresh, @@ -151,6 +154,9 @@ export async function register(params: { password_confirmation: string; first_name?: string; last_name?: string; + phone?: string; + /** Arbitrary key-value data stored on the customer (e.g. wholesale company). */ + metadata?: Record; }): Promise<{ success: boolean; user?: { @@ -188,9 +194,18 @@ export async function logout(): Promise { await clearAccessToken(); await clearRefreshToken(); - await clearCartCookies(); + // Clear every surface's cart — the wholesale cart lives in its own cookie + // pair and cache tag, so a DTC-only clear would leave it behind for the + // next session. + await clearAllCartCookies(); updateTag("customer"); - updateTag("cart"); + // Invalidate both the cart and the checkout (address/delivery) caches for + // every surface — checkout state is tagged separately, so a cart-only clear + // would leave the previous buyer's checkout data cached after logout. + for (const surface of SURFACES) { + updateTag(`cart${cacheTagSuffix(surface)}`); + updateTag(`checkout${cacheTagSuffix(surface)}`); + } updateTag("addresses"); updateTag("credit-cards"); } diff --git a/src/lib/data/orders.ts b/src/lib/data/orders.ts index de44d11e..951cce75 100644 --- a/src/lib/data/orders.ts +++ b/src/lib/data/orders.ts @@ -1,7 +1,14 @@ "use server"; import type { OrderListParams } from "@spree/sdk"; -import { getCartOptions, getClient, withAuthRefresh } from "@/lib/spree"; +import { + DEFAULT_SURFACE, + getCartOptions, + getClient, + getClientForSurface, + type Surface, + withAuthRefresh, +} from "@/lib/spree"; import { withFallback } from "./utils"; export async function getOrders(params?: OrderListParams) { @@ -31,10 +38,16 @@ export async function getOrders(params?: OrderListParams) { /** * Get a single order by ID or number. * Works for both authenticated users (JWT) and guests (spreeToken). + * The surface selects the cart token cookie + client so wholesale orders + * resolve through the wholesale channel. */ -export async function getOrder(id: string, params?: Record) { +export async function getOrder( + id: string, + params?: Record, + surface: Surface = DEFAULT_SURFACE, +) { return withFallback(async () => { - const options = await getCartOptions(); - return getClient().orders.get(id, params, options); + const options = await getCartOptions(surface); + return getClientForSurface(surface).orders.get(id, params, options); }, null); } diff --git a/src/lib/data/payment.ts b/src/lib/data/payment.ts index 08182699..4679b90f 100644 --- a/src/lib/data/payment.ts +++ b/src/lib/data/payment.ts @@ -2,20 +2,41 @@ import type { Order } from "@spree/sdk"; import { updateTag } from "next/cache"; -import { getCartOptions, getClient, requireCartId } from "@/lib/spree"; +import { + cacheTagSuffix, + getCartOptions, + getClientForSurface, + requireCartId, + type Surface, +} from "@/lib/spree"; import { getCart } from "./cart"; +import { + resolveSurfaceForCart, + resolveSurfaceForCartVerified, +} from "./checkout"; import { getOrder } from "./orders"; import { actionResult } from "./utils"; +function checkoutTag(surface: Surface): string { + return `checkout${cacheTagSuffix(surface)}`; +} + +function cartTag(surface: Surface): string { + return `cart${cacheTagSuffix(surface)}`; +} + export async function createCheckoutPaymentSession( cartId: string, paymentMethodId: string, externalData?: Record, ) { return actionResult(async () => { - const options = await getCartOptions(); - const id = await requireCartId(); - const session = await getClient().carts.paymentSessions.create( + const surface = await resolveSurfaceForCart(cartId); + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const session = await getClientForSurface( + surface, + ).carts.paymentSessions.create( id, { payment_method_id: paymentMethodId, @@ -23,7 +44,7 @@ export async function createCheckoutPaymentSession( }, options, ); - updateTag("checkout"); + updateTag(checkoutTag(surface)); return { session }; }, "Failed to create payment session"); } @@ -37,14 +58,15 @@ export async function createDirectPayment( paymentMethodId: string, ) { return actionResult(async () => { - const options = await getCartOptions(); - const id = await requireCartId(); - const payment = await getClient().carts.payments.create( + const surface = await resolveSurfaceForCart(cartId); + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const payment = await getClientForSurface(surface).carts.payments.create( id, { payment_method_id: paymentMethodId }, options, ); - updateTag("checkout"); + updateTag(checkoutTag(surface)); return { payment }; }, "Failed to create payment"); } @@ -55,15 +77,13 @@ export async function completeCheckoutPaymentSession( params?: { session_result?: string; external_data?: Record }, ) { return actionResult(async () => { - const options = await getCartOptions(); - const id = await requireCartId(); - const session = await getClient().carts.paymentSessions.complete( - id, - sessionId, - params, - options, - ); - updateTag("checkout"); + const surface = await resolveSurfaceForCart(cartId); + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const session = await getClientForSurface( + surface, + ).carts.paymentSessions.complete(id, sessionId, params, options); + updateTag(checkoutTag(surface)); return { session }; }, "Failed to complete payment session"); } @@ -76,12 +96,19 @@ export async function completeCheckoutPaymentSession( * When the order was already completed (403/422), fetch it from the API * so the caller always gets the order data for caching on the thank-you page. */ -export async function completeCheckoutOrder(cartId: string) { +export async function completeCheckoutOrder( + cartId: string, + knownSurface?: Surface, +) { + const surface = knownSurface ?? (await resolveSurfaceForCart(cartId)); try { - const options = await getCartOptions(); - const order: Order = await getClient().carts.complete(cartId, options); - updateTag("checkout"); - updateTag("cart"); + const options = await getCartOptions(surface); + const order: Order = await getClientForSurface(surface).carts.complete( + cartId, + options, + ); + updateTag(checkoutTag(surface)); + updateTag(cartTag(surface)); return { success: true as const, order }; } catch (error: unknown) { if (error && typeof error === "object" && "status" in error) { @@ -89,9 +116,11 @@ export async function completeCheckoutOrder(cartId: string) { if (status === 403 || status === 422) { // Order already completed — try to fetch it so the thank-you page // can cache and display it without a second round-trip. - const completedOrder = await getOrder(cartId).catch(() => null); - updateTag("checkout"); - updateTag("cart"); + const completedOrder = await getOrder(cartId, undefined, surface).catch( + () => null, + ); + updateTag(checkoutTag(surface)); + updateTag(cartTag(surface)); return { success: true as const, order: completedOrder }; } } @@ -116,13 +145,29 @@ export async function confirmPaymentAndCompleteCart( ): Promise< { success: true; order: unknown } | { success: false; error: string } > { + // Cookies may have been cleared during the offsite redirect, so verify the + // surface against the cart's own channel rather than trusting the cookie. + const verifiedSurface = await resolveSurfaceForCartVerified(cartId); + if (verifiedSurface === "unverified") { + // The wholesale check couldn't run to completion (transient fetch/channel + // failure). Fail closed rather than defaulting to DTC: completing a + // possibly-wholesale checkout through the DTC client, or reporting success + // for a cart we couldn't fetch, would be worse than asking the caller to + // retry once the backend recovers. + return { + success: false, + error: "Couldn't confirm your order yet. Please try again in a moment.", + }; + } + const surface = verifiedSurface; try { - // Use explicit cartId — cookies may have been cleared during offsite redirect - const cart = await getCart(cartId); + const cart = await getCart(cartId, surface); if (!cart) { // Cart not found — the order may already be completed (e.g. by webhook). // Try fetching it as a completed order before giving up. - const completedOrder = await getOrder(cartId).catch(() => null); + const completedOrder = await getOrder(cartId, undefined, surface).catch( + () => null, + ); return { success: true, order: completedOrder }; } @@ -131,9 +176,11 @@ export async function confirmPaymentAndCompleteCart( } if (sessionId) { - const options = await getCartOptions(); - const id = await requireCartId(); - const completeResult = await getClient().carts.paymentSessions.complete( + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const completeResult = await getClientForSurface( + surface, + ).carts.paymentSessions.complete( id, sessionId, sessionResult ? { session_result: sessionResult } : undefined, @@ -148,9 +195,11 @@ export async function confirmPaymentAndCompleteCart( } else if (redirectResult) { // Adyen redirect flow: redirectResult is appended by Adyen to the return URL. // Pass it to the backend which resolves the session and processes the redirect. - const options = await getCartOptions(); - const id = await requireCartId(); - const completeResult = await getClient().carts.paymentSessions.complete( + const options = await getCartOptions(surface); + const id = await requireCartId(surface); + const completeResult = await getClientForSurface( + surface, + ).carts.paymentSessions.complete( id, adyenSessionId ?? "", { @@ -168,7 +217,9 @@ export async function confirmPaymentAndCompleteCart( } } - const result = await completeCheckoutOrder(cartId); + // Pass the verified surface so completion doesn't re-resolve from the + // (possibly cleared) cookie. + const result = await completeCheckoutOrder(cartId, surface); if (result.success) { return { success: true, order: result.order }; } diff --git a/src/lib/data/products.ts b/src/lib/data/products.ts index bee79c3e..6bde982a 100644 --- a/src/lib/data/products.ts +++ b/src/lib/data/products.ts @@ -2,33 +2,53 @@ import type { ProductListParams } from "@spree/sdk"; import { cacheLife, cacheTag } from "next/cache"; -import { getAccessToken, getClient, getLocaleOptions } from "@/lib/spree"; +import { + cacheTagSuffix, + DEFAULT_SURFACE, + getAccessToken, + getClientForSurface, + getLocaleOptions, + type Surface, +} from "@/lib/spree"; /** * Cached product list fetch. Cache key is derived from all function * arguments by Next.js "use cache": * * - locale/country: determines language and market-specific pricing + * - surface: DTC vs wholesale — different catalog + channel pricing. Baked + * into both the cache tag and the arguments so the two never share entries. * - userToken: per-user cache segmentation (separate arg, NOT passed to * SDK). Authenticated users may see different prices (B2B, loyalty). * Each user's JWT is unique so the cache is segmented per user. - * Guest users pass undefined. + * Guest users pass undefined. On the wholesale surface the token is + * always present — the channel 401s guests. */ export async function cachedListProducts( params: ProductListParams | undefined, options: { locale?: string; country?: string }, - _userToken?: string, + surface: Surface, + userToken?: string, ) { "use cache: remote"; cacheLife("tenMinutes"); - cacheTag("products"); - return getClient().products.list(params, options); + cacheTag(`products${cacheTagSuffix(surface)}`); + return getClientForSurface(surface).products.list(params, { + ...options, + // Wholesale catalog requires the customer JWT — the channel is gated. + ...(surface === "wholesale" && userToken + ? { token: userToken } + : undefined), + }); } -export async function getProducts(params?: ProductListParams) { +export async function getProducts( + params?: ProductListParams, + surface: Surface = DEFAULT_SURFACE, +) { const options = await getLocaleOptions(); const userToken = await getAccessToken(); - return cachedListProducts(params, options, userToken); + return cachedListProducts(params, options, surface, userToken); } /** @@ -36,6 +56,7 @@ export async function getProducts(params?: ProductListParams) { * * - slugOrId, expand: identify the product and response shape * - locale/country: determines language and market-specific pricing + * - surface: DTC vs wholesale — see cachedListProducts * - userToken: per-user cache segmentation (separate arg, NOT passed to * SDK). Authenticated users may see different prices (B2B, loyalty). * Guest users pass undefined, so all guests share one entry. @@ -44,36 +65,65 @@ export async function cachedGetProduct( slugOrId: string, expand: string[], options: { locale?: string; country?: string }, - _userToken?: string, + surface: Surface, + userToken?: string, ) { "use cache: remote"; cacheLife("tenMinutes"); - cacheTag("products", `product:${slugOrId}`); - return getClient().products.get(slugOrId, { expand }, options); + cacheTag( + `products${cacheTagSuffix(surface)}`, + `product:${slugOrId}${cacheTagSuffix(surface)}`, + ); + return getClientForSurface(surface).products.get( + slugOrId, + { expand }, + { + ...options, + ...(surface === "wholesale" && userToken + ? { token: userToken } + : undefined), + }, + ); } export async function getProduct( slugOrId: string, params?: { expand?: string[] }, + surface: Surface = DEFAULT_SURFACE, ) { const options = await getLocaleOptions(); const userToken = await getAccessToken(); - return cachedGetProduct(slugOrId, params?.expand ?? [], options, userToken); + return cachedGetProduct( + slugOrId, + params?.expand ?? [], + options, + surface, + userToken, + ); } async function cachedGetProductFilters( params: Record | undefined, options: { locale?: string; country?: string }, - _userToken?: string, + surface: Surface, + userToken?: string, ) { "use cache: remote"; cacheLife("tenMinutes"); - cacheTag("product-filters"); - return getClient().products.filters(params, options); + cacheTag(`product-filters${cacheTagSuffix(surface)}`); + return getClientForSurface(surface).products.filters(params, { + ...options, + ...(surface === "wholesale" && userToken + ? { token: userToken } + : undefined), + }); } -export async function getProductFilters(params?: Record) { +export async function getProductFilters( + params?: Record, + surface: Surface = DEFAULT_SURFACE, +) { const options = await getLocaleOptions(); const userToken = await getAccessToken(); - return cachedGetProductFilters(params, options, userToken); + return cachedGetProductFilters(params, options, surface, userToken); } diff --git a/src/lib/data/wholesale.ts b/src/lib/data/wholesale.ts new file mode 100644 index 00000000..25976381 --- /dev/null +++ b/src/lib/data/wholesale.ts @@ -0,0 +1,166 @@ +"use server"; + +import type { Channel, ProductListParams } from "@spree/sdk"; +import { + getAccessToken, + getWholesaleClient, + withAuthRefresh, +} from "@/lib/spree"; +import { + getProduct as getProductBySurface, + getProductFilters as getProductFiltersBySurface, + getProducts as getProductsBySurface, +} from "./products"; +import { withFallback } from "./utils"; + +/** + * Fetch the wholesale channel's resolved configuration (access posture, guest + * checkout). Reachable without authentication even on the gated channel so the + * portal can render a sign-in wall. + */ +export async function getWholesaleChannel(): Promise { + return withFallback(async () => getWholesaleClient().channel.get(), null); +} + +// --- Surface-bound product fetchers for the wholesale PLP/PDP --- +// +// These wrap the surface-aware data functions with the `'wholesale'` surface +// pre-bound, giving the `(params) => Promise` shape that 's +// fetcher props expect. The channel 401s guests, so these must run for an +// authenticated, approved buyer. + +export async function getWholesaleProducts(params?: ProductListParams) { + return getProductsBySurface(params, "wholesale"); +} + +export async function getWholesaleProductFilters( + params?: Record, +) { + return getProductFiltersBySurface(params, "wholesale"); +} + +export async function getWholesaleProduct( + slugOrId: string, + params?: { expand?: string[] }, +) { + return getProductBySurface(slugOrId, params, "wholesale"); +} + +/** A selectable variant in the quick-order autocomplete. */ +export interface WholesaleVariantSuggestion { + variantId: string; + productName: string; + /** Variant option label ("Matte Black"), absent for single-variant products. */ + optionsText?: string; + sku: string; + displayPrice?: string; + purchasable: boolean; +} + +/** + * Search the wholesale catalog for variants matching a free-text query (product + * name or SKU), for the quick-order autocomplete. Flattens products to variants + * so buyers can pick the exact colour/size rather than just the product. + * Requires the customer JWT — the channel 401s guests. + */ +export async function searchWholesaleVariants( + query: string, + limit = 8, +): Promise { + const trimmed = query.trim(); + if (trimmed.length < 2) return []; + + const token = await getAccessToken(); + if (!token) return []; + + return withFallback(async () => { + const response = await withAuthRefresh((options) => + getWholesaleClient().products.list( + { search: trimmed, expand: ["variants"], limit }, + options, + ), + ); + + const suggestions: WholesaleVariantSuggestion[] = []; + for (const product of response.data) { + // Products always expose at least a default variant; list every variant + // so multi-variant products are individually selectable. + for (const variant of product.variants ?? []) { + if (!variant.sku) continue; + suggestions.push({ + variantId: variant.id, + productName: product.name, + optionsText: variant.options_text || undefined, + sku: variant.sku, + displayPrice: variant.price?.display_amount ?? undefined, + purchasable: variant.purchasable ?? false, + }); + if (suggestions.length >= limit) return suggestions; + } + } + return suggestions; + }, []); +} + +/** + * Resolve a SKU to a purchasable variant on the wholesale channel, for the + * quick-order form. Searches products by SKU and returns the first matching + * variant with enough detail to add it to the cart. Requires the customer JWT. + */ +export async function findWholesaleVariantBySku(sku: string): Promise< + | { + found: true; + variantId: string; + productName: string; + productSlug: string; + sku: string; + optionsText?: string; + displayPrice?: string; + purchasable: boolean; + } + | { found: false } +> { + const trimmed = sku.trim(); + if (!trimmed) return { found: false }; + + const token = await getAccessToken(); + if (!token) return { found: false }; + + return withFallback( + async () => { + const response = await withAuthRefresh((options) => + getWholesaleClient().products.list( + { + // Full-text search spans product name + SKU; we match the exact + // SKU against the returned variants below. + search: trimmed, + expand: ["variants"], + limit: 5, + }, + options, + ), + ); + + for (const product of response.data) { + const variant = product.variants?.find( + (v) => v.sku?.toLowerCase() === trimmed.toLowerCase(), + ); + if (variant) { + return { + found: true as const, + variantId: variant.id, + productName: product.name, + productSlug: product.slug, + sku: variant.sku ?? trimmed, + optionsText: variant.options_text || undefined, + displayPrice: variant.price?.display_amount ?? undefined, + purchasable: variant.purchasable ?? false, + }; + } + } + + return { found: false as const }; + }, + { found: false as const }, + ); +} diff --git a/src/lib/spree/config.ts b/src/lib/spree/config.ts index 92cecdff..86e71d4f 100644 --- a/src/lib/spree/config.ts +++ b/src/lib/spree/config.ts @@ -1,8 +1,10 @@ import { type Client, createClient } from "@spree/sdk"; +import type { Surface } from "./surface"; import type { SpreeNextConfig } from "./types"; let _client: Client | null = null; let _config: SpreeNextConfig | null = null; +let _wholesaleClient: Client | null = null; /** * Initialize the Spree Next.js integration. @@ -45,10 +47,77 @@ export function getConfig(): SpreeNextConfig { return _config!; } +/** + * The wholesale channel code the B2B portal binds to, or `null` when the + * wholesale portal is not enabled. + * + * Wholesale is an **opt-in addon**: it turns on only when + * `SPREE_WHOLESALE_CHANNEL` names the gated channel to use (e.g. `wholesale`). + * There is deliberately no default — an unset value means the storefront runs + * DTC-only, and every wholesale entry point (nav, footer, homepage section, + * the `/wholesale` routes) stays hidden. + * + * @returns the wholesale channel code, or null if wholesale is disabled + */ +export function getWholesaleChannelCode(): string | null { + return process.env.SPREE_WHOLESALE_CHANNEL?.trim() || null; +} + +/** Whether the wholesale portal addon is enabled for this storefront. */ +export function isWholesaleEnabled(): boolean { + return getWholesaleChannelCode() !== null; +} + +/** + * Get the wholesale Client instance — a channel-bound client for the gated B2B + * portal. Requests carry the wholesale channel code (so orders attribute to it + * and inherit its no-guest-checkout rule) and, if provided, a wholesale-scoped + * publishable key. + * + * Only call this when {@link isWholesaleEnabled} is true — it throws when the + * addon is off so a misconfiguration surfaces loudly instead of silently + * falling back to the DTC channel and serving the public catalog as "wholesale". + * A wholesale-scoped publishable key is optional (the `X-Spree-Channel` header + * selects the channel); when unset it falls back to the DTC publishable key. + */ +export function getWholesaleClient(): Client { + if (_wholesaleClient) return _wholesaleClient; + + const channel = getWholesaleChannelCode(); + if (!channel) { + throw new Error( + "getWholesaleClient() called but SPREE_WHOLESALE_CHANNEL is not set — the wholesale portal is disabled. Guard callers with isWholesaleEnabled().", + ); + } + + const config = getConfig(); + // Treat a blank env value as unset so it falls back to the DTC key rather + // than building a client with an empty publishable key. + const publishableKey = + process.env.SPREE_WHOLESALE_PUBLISHABLE_KEY?.trim() || + config.publishableKey; + + _wholesaleClient = createClient({ + baseUrl: config.baseUrl, + publishableKey, + channel, + }); + return _wholesaleClient; +} + +/** + * Resolve the SDK client for a storefront surface: the DTC client for the + * public storefront, the channel-bound wholesale client for the B2B portal. + */ +export function getClientForSurface(surface: Surface): Client { + return surface === "wholesale" ? getWholesaleClient() : getClient(); +} + /** * Reset the client (useful for testing). */ export function resetClient(): void { _client = null; _config = null; + _wholesaleClient = null; } diff --git a/src/lib/spree/cookies.ts b/src/lib/spree/cookies.ts index cc04e5d4..110e73a0 100644 --- a/src/lib/spree/cookies.ts +++ b/src/lib/spree/cookies.ts @@ -1,5 +1,11 @@ import { cookies } from "next/headers"; import { getConfig } from "./config"; +import { + cartCookieBaseName, + DEFAULT_SURFACE, + SURFACES, + type Surface, +} from "./surface"; const DEFAULT_CART_COOKIE = "_spree_cart_token"; const DEFAULT_ACCESS_TOKEN_COOKIE = "_spree_jwt"; @@ -24,7 +30,8 @@ export async function canPersistCookies(): Promise { } } -function getCartCookieName(): string { +function getCartCookieName(surface: Surface = DEFAULT_SURFACE): string { + if (surface === "wholesale") return cartCookieBaseName(surface); try { return getConfig().cartCookieName ?? DEFAULT_CART_COOKIE; } catch { @@ -32,8 +39,8 @@ function getCartCookieName(): string { } } -function getCartIdCookieName(): string { - return `${getCartCookieName()}_id`; +function getCartIdCookieName(surface: Surface = DEFAULT_SURFACE): string { + return `${getCartCookieName(surface)}_id`; } function getAccessTokenCookieName(): string { @@ -45,20 +52,29 @@ function getAccessTokenCookieName(): string { } // --- Cart Cookies (token + ID always managed together) --- - -export async function getCartToken(): Promise { +// +// Cart cookies are surface-scoped: the DTC and wholesale carts live in separate +// cookie pairs so a customer can hold both at once. `surface` defaults to DTC, +// preserving every existing caller. + +export async function getCartToken( + surface: Surface = DEFAULT_SURFACE, +): Promise { const cookieStore = await cookies(); - return cookieStore.get(getCartCookieName())?.value; + return cookieStore.get(getCartCookieName(surface))?.value; } -export async function getCartId(): Promise { +export async function getCartId( + surface: Surface = DEFAULT_SURFACE, +): Promise { const cookieStore = await cookies(); - return cookieStore.get(getCartIdCookieName())?.value; + return cookieStore.get(getCartIdCookieName(surface))?.value; } export async function setCartCookies( id: string, token?: string, + surface: Surface = DEFAULT_SURFACE, ): Promise { const cookieStore = await cookies(); const opts = { @@ -69,17 +85,28 @@ export async function setCartCookies( maxAge: CART_TOKEN_MAX_AGE, }; - cookieStore.set(getCartIdCookieName(), id, opts); + cookieStore.set(getCartIdCookieName(surface), id, opts); if (token) { - cookieStore.set(getCartCookieName(), token, opts); + cookieStore.set(getCartCookieName(surface), token, opts); } } -export async function clearCartCookies(): Promise { +export async function clearCartCookies( + surface: Surface = DEFAULT_SURFACE, +): Promise { const cookieStore = await cookies(); const opts = { maxAge: -1, path: "/" }; - cookieStore.set(getCartCookieName(), "", opts); - cookieStore.set(getCartIdCookieName(), "", opts); + cookieStore.set(getCartCookieName(surface), "", opts); + cookieStore.set(getCartIdCookieName(surface), "", opts); +} + +/** + * Clear the cart cookies for every surface. Used on logout / account deletion, + * where leaving a wholesale cart cookie behind would leak it into the next + * session. + */ +export async function clearAllCartCookies(): Promise { + await Promise.all(SURFACES.map((surface) => clearCartCookies(surface))); } // --- Access Token (JWT) --- @@ -140,32 +167,61 @@ export async function clearRefreshToken(): Promise { // --- Cart Options (combined cart + access tokens for cart/checkout/payment actions) --- -export async function getCartOptions(): Promise<{ +export async function getCartOptions( + surface: Surface = DEFAULT_SURFACE, +): Promise<{ spreeToken: string | undefined; token: string | undefined; }> { - const spreeToken = await getCartToken(); + const spreeToken = await getCartToken(surface); const token = await getAccessToken(); return { spreeToken, token }; } // --- Cart ID (required) --- -export async function requireCartId(): Promise { - const cartId = await getCartId(); - if (cartId) return cartId; +export async function requireCartId( + surface: Surface = DEFAULT_SURFACE, +): Promise { + const cartId = await getCartId(surface); + // Reject a cookie that was cross-written with the other surface's cart id + // (pre-channel-scoped-listing poisoning); fall through to re-resolve cleanly. + if (cartId && !(await isPoisonedDtcCartId(cartId, surface))) { + return cartId; + } - // Authenticated user without cart ID cookie — resolve via carts.list() + // Authenticated user without a (valid) cart ID cookie — resolve via + // carts.list() through the surface's client. carts.list is channel-scoped on + // the backend, so it only returns carts for this surface's channel. const token = await getAccessToken(); if (token) { - const { getClient } = await import("./config"); - const response = await getClient().carts.list({ token }); + const { getClientForSurface } = await import("./config"); + const response = await getClientForSurface(surface).carts.list({ token }); if (response.data.length > 0) { const cart = response.data[0]; - await setCartCookies(cart.id, cart.token); + await setCartCookies(cart.id, cart.token, surface); return cart.id; } } throw new Error("No cart found"); } + +/** + * Backstop for DTC cookies poisoned before channel-scoped listing existed: the + * poison only ever flowed wholesale→DTC (the DTC list fallback adopted the + * user's only cart, a wholesale one, into the DTC cookie). So on the DTC + * surface, a cart-id cookie equal to the wholesale cookie's cart id is always + * the poison and must be dropped. Directional on purpose — never drops the + * wholesale surface's legitimate cart; the channel_id guard handles the + * wholesale side. Single source of truth for this check — cart.ts and the + * requireCartId path both import it rather than re-implementing it. + */ +export async function isPoisonedDtcCartId( + cartId: string, + surface: Surface, +): Promise { + if (surface !== "dtc") return false; + const wholesaleCartId = await getCartId("wholesale"); + return Boolean(wholesaleCartId) && wholesaleCartId === cartId; +} diff --git a/src/lib/spree/index.ts b/src/lib/spree/index.ts index 7bd2a290..50f6a21c 100644 --- a/src/lib/spree/index.ts +++ b/src/lib/spree/index.ts @@ -9,11 +9,20 @@ export { type SessionState, withAuthRefresh, } from "./auth-helpers"; -export { getClient, getConfig, initSpreeNext } from "./config"; +export { + getClient, + getClientForSurface, + getConfig, + getWholesaleChannelCode, + getWholesaleClient, + initSpreeNext, + isWholesaleEnabled, +} from "./config"; // Cookie management export { canPersistCookies, clearAccessToken, + clearAllCartCookies, clearCartCookies, clearRefreshToken, getAccessToken, @@ -21,6 +30,7 @@ export { getCartOptions, getCartToken, getRefreshToken, + isPoisonedDtcCartId, requireCartId, setAccessToken, setCartCookies, @@ -30,4 +40,12 @@ export { export { decodeJwtExp, isJwtExpired } from "./jwt"; // Locale resolution (reads country/locale from cookies) export { getLocaleOptions } from "./locale"; +// Surface (DTC vs wholesale sales context) +export { + cacheTagSuffix, + cartCookieBaseName, + DEFAULT_SURFACE, + SURFACES, + type Surface, +} from "./surface"; export type { SpreeNextConfig, SpreeNextOptions } from "./types"; diff --git a/src/lib/spree/surface.ts b/src/lib/spree/surface.ts new file mode 100644 index 00000000..f4558d70 --- /dev/null +++ b/src/lib/spree/surface.ts @@ -0,0 +1,41 @@ +/** + * A storefront surface is a distinct sales context backed by its own Spree + * sales channel. The DTC surface is the public storefront; the wholesale + * surface is the gated B2B portal. + * + * The surface selects three things that must never mix between contexts: + * + * - which SDK client (channel + publishable key) requests go through + * - which cart cookies hold the surface's cart (a customer can have both an + * open DTC cart and an open wholesale cart at the same time) + * - which cache tags/keys segment cached reads + * + * The customer session (JWT) is shared across surfaces — it's the same person + * signing in — so only the cart cookies split, never the auth cookies. + */ +export type Surface = "dtc" | "wholesale"; + +export const DEFAULT_SURFACE: Surface = "dtc"; + +/** All storefront surfaces. Iterate this to act on every surface at once + * (e.g. clearing all carts on logout). */ +export const SURFACES: readonly Surface[] = ["dtc", "wholesale"]; + +/** + * Cart cookie base name for a surface. The cart-id cookie is derived by + * appending `_id` (see cookies.ts). Wholesale gets its own pair so the two + * carts coexist. + */ +export function cartCookieBaseName(surface: Surface): string { + return surface === "wholesale" + ? "_spree_wholesale_cart_token" + : "_spree_cart_token"; +} + +/** + * Suffix appended to cache tags/keys so DTC and wholesale caches are disjoint. + * DTC keeps the unsuffixed tags to preserve existing cache entries. + */ +export function cacheTagSuffix(surface: Surface): string { + return surface === "wholesale" ? "-wholesale" : ""; +} diff --git a/src/lib/utils/__tests__/express-checkout.test.ts b/src/lib/utils/__tests__/express-checkout.test.ts new file mode 100644 index 00000000..031d348c --- /dev/null +++ b/src/lib/utils/__tests__/express-checkout.test.ts @@ -0,0 +1,29 @@ +import type { Cart } from "@spree/sdk"; +import { describe, expect, it } from "vitest"; +import { hasPayableTotal } from "@/lib/utils/express-checkout"; + +// Only the money fields hasPayableTotal reads; cast keeps the fixture minimal. +const cart = (total: string | null, itemTotal: string | null = null): Cart => + ({ total, item_total: itemTotal }) as unknown as Cart; + +describe("hasPayableTotal", () => { + it("is true for a positive total", () => { + expect(hasPayableTotal(cart("19.99"))).toBe(true); + }); + + it("falls back to item_total when total is null", () => { + expect(hasPayableTotal(cart(null, "5.00"))).toBe(true); + }); + + it("is false when both total and item_total are null (prices hidden)", () => { + expect(hasPayableTotal(cart(null, null))).toBe(false); + }); + + it("is false for a zero total", () => { + expect(hasPayableTotal(cart("0.00"))).toBe(false); + }); + + it("is false for a non-numeric total", () => { + expect(hasPayableTotal(cart("abc"))).toBe(false); + }); +}); diff --git a/src/lib/utils/express-checkout.ts b/src/lib/utils/express-checkout.ts index 19f0197b..bbe0b318 100644 --- a/src/lib/utils/express-checkout.ts +++ b/src/lib/utils/express-checkout.ts @@ -46,6 +46,19 @@ export function randomSuffix(): string { return Math.random().toString(36).slice(2, 6); } +/** + * Whether an order has a positive payable total for Stripe. Express Checkout + * requires a positive `amount`; a null total (money fields hidden for a + * prices-hidden guest cart) or a zero total has no valid payable amount, so the + * caller must skip Express Checkout entirely rather than send `amount: 0`. + */ +export function hasPayableTotal(order: Cart): boolean { + const total = order.total ?? order.item_total; + if (total == null) return false; + const n = Number(total); + return Number.isFinite(n) && n > 0; +} + /** * Build the line items array for the Stripe payment sheet from a Spree order. * NOTE: Shipping is excluded because the Express Checkout Element handles it @@ -56,15 +69,23 @@ export function buildLineItems(order: Cart) { const currency = order.currency; const items: Array<{ name: string; amount: number }> = []; - const itemTotal = toCents(order.item_total, currency); - items.push({ name: "Subtotal", amount: itemTotal }); + // The subtotal anchors the breakdown. When it's unknown — money fields are + // nullable for prices-hidden carts — return no line items at all so the + // Express Checkout Element falls back to the total-only amount, rather than + // emitting orphan Discount/Tax lines against a missing subtotal. + if (order.item_total == null) return items; + + items.push({ name: "Subtotal", amount: toCents(order.item_total, currency) }); - const promoTotal = toCents(order.discount_total, currency); + const promoTotal = toCents(order.discount_total ?? "0", currency); if (promoTotal < 0) { items.push({ name: "Discount", amount: promoTotal }); } - const additionalTaxTotal = toCents(order.additional_tax_total, currency); + const additionalTaxTotal = toCents( + order.additional_tax_total ?? "0", + currency, + ); if (additionalTaxTotal > 0) { items.push({ name: "Tax", amount: additionalTaxTotal }); } diff --git a/src/lib/webhooks/handlers.ts b/src/lib/webhooks/handlers.ts index b4b44e1a..1104db4b 100644 --- a/src/lib/webhooks/handlers.ts +++ b/src/lib/webhooks/handlers.ts @@ -72,15 +72,15 @@ export async function handleOrderCompleted(event: WebhookEvent) { slug: item.slug, quantity: item.quantity, options_text: item.options_text, - display_price: item.display_price, - display_total: item.display_total, + display_price: item.display_price ?? "", + display_total: item.display_total ?? "", thumbnail_url: item.thumbnail_url, })), - displayItemTotal: order.display_item_total, - displayDeliveryTotal: order.display_delivery_total, - displayDiscountTotal: order.display_discount_total, - displayTaxTotal: order.display_tax_total, - displayTotal: order.display_total, + displayItemTotal: order.display_item_total ?? "", + displayDeliveryTotal: order.display_delivery_total ?? "", + displayDiscountTotal: order.display_discount_total ?? undefined, + displayTaxTotal: order.display_tax_total ?? "", + displayTotal: order.display_total ?? "", shippingAddress: order.shipping_address ?? undefined, billingAddress: order.billing_address ?? undefined, deliveryMethodName, @@ -112,10 +112,10 @@ export async function handleOrderCanceled(event: WebhookEvent) { slug: item.slug, quantity: item.quantity, options_text: item.options_text, - display_total: item.display_total, + display_total: item.display_total ?? "", thumbnail_url: item.thumbnail_url, })), - displayTotal: order.display_total, + displayTotal: order.display_total ?? "", }), }); @@ -160,7 +160,7 @@ export async function handleOrderShipped(event: WebhookEvent) { tracking_url: fulfillment.tracking_url, delivery_method_name: fulfillment.delivery_method?.name || "Standard Shipping", - display_cost: fulfillment.display_cost, + display_cost: fulfillment.display_cost ?? "", items: shippedItems, }; }); diff --git a/src/lib/wholesale.ts b/src/lib/wholesale.ts new file mode 100644 index 00000000..e56612c8 --- /dev/null +++ b/src/lib/wholesale.ts @@ -0,0 +1,24 @@ +import type { Customer } from "@spree/sdk"; + +/** + * Name of the customer group whose members are approved wholesale buyers. + * Approval is exactly membership in this group — the admin adds an applicant to + * it to approve them. + */ +export const WHOLESALE_GROUP_NAME = "Wholesale"; + +/** + * Minimum quantity of a single item required to unlock wholesale (trade) pricing. + * Mirrors the VolumeRule min_quantity on the seeded "Wholesale" price list + * (spree/core/db/sample_data/wholesale.rb). This is a demo constant — the + * production version would read the applicable volume rule's min_quantity from + * the API per variant. Keep in sync with the seed if the seed changes. + */ +export const WHOLESALE_MIN_QUANTITY = 10; + +/** Whether a customer is an approved wholesale buyer. */ +export function isWholesaleApproved(customer: Customer | null): boolean { + return Boolean( + customer?.customer_groups?.some((g) => g.name === WHOLESALE_GROUP_NAME), + ); +}