diff --git a/examples/nextjs/app/store/actions.ts b/examples/nextjs/app/store/actions.ts index 6dbe2207..49c523ec 100644 --- a/examples/nextjs/app/store/actions.ts +++ b/examples/nextjs/app/store/actions.ts @@ -3,6 +3,41 @@ import { createCheckoutSession } from '@godaddy/react/server'; import { redirect } from 'next/navigation'; +/** + * Fetches selling plans for given SKU IDs from the external (or local) selling-plans API. + * Only runs when SELLING_PLANS_API_URL is set (e.g. in .env.local). If unset, returns + * empty so no request is made and no 404 is logged. + */ +export async function getSellingPlans(storeId: string, options: any) { + const base = process.env.SELLING_PLANS_API_URL; + if (!base) { + return null; + } + // API shape: GET /api/v1/selling-plans/{storeId}/groups?skuIds=... + const path = `/api/v1/selling-plans/${encodeURIComponent(storeId)}/groups` + const url = new URL(path, base); + for (const id of options.skuIds ?? []) { + url.searchParams.append('skuIds', id); + } + for (const id of options.skuGroupIds ?? []) { + url.searchParams.append('skuGroupIds', id); + } + url.searchParams.append('includes', 'catalogPrices'); + url.searchParams.append('includes', 'allocations'); + // url.searchParams.append('planStatus', 'ACTIVE'); // uncomment it when push to repo + try { + const res = await fetch(url.toString(), { cache: 'no-store' }); + if (!res.ok) { + return null; + } + const data = await res.json(); + return data.groups; + } catch { + // Network/socket errors (e.g. selling-plans API not running): fail silently so product page still works + return null; + } +} + export async function checkoutWithOrder(orderId: string) { const session = await createCheckoutSession( { diff --git a/examples/nextjs/app/store/product/[productId]/product.tsx b/examples/nextjs/app/store/product/[productId]/product.tsx index 9b2453e4..23cb2845 100644 --- a/examples/nextjs/app/store/product/[productId]/product.tsx +++ b/examples/nextjs/app/store/product/[productId]/product.tsx @@ -3,10 +3,20 @@ import { ProductDetails } from '@godaddy/react'; import { ArrowLeft } from 'lucide-react'; import Link from 'next/link'; +import { useState } from 'react'; +import { SellingPlanDropdown } from '../selling-plan-dropdown'; import { useCart } from '../../layout'; +/** Route param productId is the SKU Group id (product id from catalog skuGroups). */ export default function Product({ productId }: { productId: string }) { const { openCart } = useCart(); + const [selectedSellingPlanId, setSelectedSellingPlanId] = useState(null); + const [selectedSellingPlan, setSelectedSellingPlan] = useState(null); + + const handleSellingPlanChange = (planId: string | null, plan: any) => { + setSelectedSellingPlanId(planId); + setSelectedSellingPlan(plan); + }; return (
@@ -17,7 +27,23 @@ export default function Product({ productId }: { productId: string }) { Back to Store - + ( + + ), + }} + />
); } diff --git a/examples/nextjs/app/store/product/selling-plan-dropdown.tsx b/examples/nextjs/app/store/product/selling-plan-dropdown.tsx new file mode 100644 index 00000000..63bd1c56 --- /dev/null +++ b/examples/nextjs/app/store/product/selling-plan-dropdown.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { getSellingPlans } from '../actions'; + +/** + * Resolve checkout price for the current SKU from the plan's catalogPrices. + * API shape: plan.catalogPrices[] has { skuId, checkoutPrices: [{ value, currency }] }. + * Picks the entry matching skuId and returns checkoutPrices[0] as { value, currencyCode }. + * Value is in minor units (cents). + */ +function normalizeCheckoutPrice( + plan: any, + skuId: string | null +): { value: number; currencyCode?: string } | undefined { + if (plan.checkoutPrice?.value != null) { + return { + value: Number(plan.checkoutPrice.value), + currencyCode: plan.checkoutPrice.currencyCode ?? plan.checkoutPrice.currency, + }; + } + if (skuId && Array.isArray(plan.catalogPrices)) { + const forSku = plan.catalogPrices.find((c: any) => c.skuId === skuId); + const checkout = forSku?.checkoutPrices?.[0]; + if (checkout?.value != null) { + return { + value: Number(checkout.value), + currencyCode: checkout.currencyCode ?? checkout.currency, + }; + } + } + const first = plan.catalogPrices?.[0]; + const checkoutFirst = first?.checkoutPrices?.[0]; + if (checkoutFirst?.value != null) { + return { + value: Number(checkoutFirst.value), + currencyCode: checkoutFirst.currencyCode ?? checkoutFirst.currency, + }; + } + if (plan.priceAtCheckout?.value != null) { + return { + value: Number(plan.priceAtCheckout.value), + currencyCode: plan.priceAtCheckout.currencyCode ?? plan.priceAtCheckout.currency, + }; + } + return undefined; +} + +export function SellingPlanDropdown({ + storeId, + skuId, + skuGroupId, + selectedPlanId, + onSelectionChange, +}: { + storeId: string; + skuId: string | null; + skuGroupId: string | null; + selectedPlanId: string | null; + onSelectionChange: (planId: string | null, plan: any) => void; +}) { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(false); + + /** + * Loads selling plans from the selling-plans API for the current skuId/skuGroupId. + * + * Production note: This triggers one API call per product (or per PDP visit). To reduce + * requests, consider a strategy to preload selling groups on the store list page: fetch + * groups for all visible skuIds and skuGroupIds once, pass the result (e.g. via context + * or cache) into this component. + */ + const loadPlans = useCallback(async () => { + if (!storeId || (!skuId && !skuGroupId)) { + setPlans([]); + return; + } + setLoading(true); + try { + const groups: any = await getSellingPlans(storeId, { + skuIds: skuId ? [skuId] : [], + skuGroupIds: skuGroupId ? [skuGroupId] : [], + }) ?? []; + // 1. Max 2 selling groups: one for skuId, one for skuGroupId + // 2. Each group has sellingPlans and allocations + // 3. Each allocation has resourceType (SKU | SKU_GROUP) and resourceId + // 4. Prefer group that matches skuId; else group that matches skuGroupId; else [] + const forSku = groups?.find((g: any) => + (g.allocations ?? []).some( + (a: any) => a.resourceType === 'SKU' && a.resourceId === skuId + ) + ); + const forSkuGroup = groups?.find((g: any) => + (g.allocations ?? []).some( + (a: any) => + a.resourceType === 'SKU_GROUP' && a.resourceId === skuGroupId + ) + ); + const sellingPlans = (forSku ?? forSkuGroup)?.sellingPlans ?? []; + setPlans(sellingPlans); + if (sellingPlans.length === 0) { + onSelectionChange(null, null); + } + } finally { + setLoading(false); + } + }, [storeId, skuId, skuGroupId, onSelectionChange]); + + useEffect(() => { + loadPlans(); + }, [loadPlans]); + + if (loading || plans.length === 0) { + return null; + } + + const value = selectedPlanId ?? ''; + + return ( +
+ + +
+ ); +} diff --git a/examples/nextjs/env.sample b/examples/nextjs/env.sample index abd13fa6..87ed4b2a 100644 --- a/examples/nextjs/env.sample +++ b/examples/nextjs/env.sample @@ -25,3 +25,7 @@ NEXT_PUBLIC_PAYPAL_CLIENT_ID= # MercadoPago Credentials NEXT_PUBLIC_MERCADOPAGO_PUBLIC_KEY= NEXT_PUBLIC_MERCADOPAGO_COUNTRY=AR + +# Selling plans API (external or local in dev) +# In local development set to your local API base URL (e.g. http://localhost:8443) +SELLING_PLANS_API_URL= diff --git a/packages/react/src/components/checkout/line-items/line-items.tsx b/packages/react/src/components/checkout/line-items/line-items.tsx index 196e9315..224fc2fc 100644 --- a/packages/react/src/components/checkout/line-items/line-items.tsx +++ b/packages/react/src/components/checkout/line-items/line-items.tsx @@ -52,6 +52,8 @@ export type Product = Partial & { addons?: SelectedAddon[]; selectedOptions?: SelectedOption[]; discounts?: ProductDiscount[]; + /** Selling plan (subscription) from PDP selection; shown in cart. */ + sellingPlan?: { name?: string; category?: string }; }; export interface DraftOrderLineItemsProps { @@ -109,6 +111,12 @@ export function DraftOrderLineItems({ ) : null} + {item.sellingPlan?.name ? ( + + {item.sellingPlan.name} + {item.sellingPlan.category ? ` · ${item.sellingPlan.category}` : ''} + + ) : null} {item?.addons?.map((addon: SelectedAddon, index: number) => ( diff --git a/packages/react/src/components/storefront/cart.tsx b/packages/react/src/components/storefront/cart.tsx index a5021d53..ed81367b 100644 --- a/packages/react/src/components/storefront/cart.tsx +++ b/packages/react/src/components/storefront/cart.tsx @@ -75,43 +75,91 @@ export function Cart({ const { t } = useGoDaddyContext(); - // Transform cart line items to Product format for CartLineItems component + // Transform cart line items to Product format for CartLineItems component. + // Selling plan JSON lives on line metafields (SELLING_PLAN); use its checkout price for display + // when present so the cart matches PDP, which may differ from draft line totals.subTotal. const items: Product[] = - order?.lineItems?.map(item => ({ - id: item.id, - name: item.name || t.storefront.product, - image: item.details?.productAssetUrl || '', - quantity: item.quantity || 0, - originalPrice: (item.totals?.subTotal?.value || 0) / (item.quantity || 1), - price: (item.totals?.subTotal?.value || 0) / (item.quantity || 1), - selectedOptions: - item?.details?.selectedOptions?.map(option => ({ - attribute: option.attribute || '', - values: option.values || [], - })) || [], - addons: item.details?.selectedAddons?.map(addon => ({ - attribute: addon.attribute || '', - sku: addon.sku || '', - values: addon.values?.map(value => ({ - costAdjustment: value.costAdjustment - ? { - currencyCode: value.costAdjustment.currencyCode ?? undefined, - value: value.costAdjustment.value ?? undefined, - } - : undefined, - name: value.name ?? undefined, + order?.lineItems?.map(item => { + const rawPlan = item.metafields?.find(m => m?.key === 'SELLING_PLAN')?.value; + let sellingPlan: { name?: string; category?: string } | null = null; + let checkoutPriceMinor: number | undefined; + if (rawPlan) { + try { + const parsed = JSON.parse(rawPlan) as { + name?: string; + category?: string; + catalogPrices?: Array<{ + skuId?: string; + checkoutPrices?: Array<{ value?: number }>; + }>; + checkoutPrice?: { value?: number }; + }; + sellingPlan = { name: parsed.name, category: parsed.category }; + const skuId = item.skuId; + const forSku = skuId + ? parsed.catalogPrices?.find(c => c.skuId === skuId) + : undefined; + const cpFromCatalog = forSku?.checkoutPrices?.[0]?.value; + checkoutPriceMinor = + cpFromCatalog != null + ? Number(cpFromCatalog) + : parsed.checkoutPrice?.value != null + ? Number(parsed.checkoutPrice.value) + : undefined; + } catch { + sellingPlan = null; + } + } + const qty = item.quantity || 1; + const apiUnit = + qty > 0 ? (item.totals?.subTotal?.value || 0) / qty : 0; + const unitPrice = checkoutPriceMinor != null ? checkoutPriceMinor : apiUnit; + return { + id: item.id, + name: item.name || t.storefront.product, + image: item.details?.productAssetUrl || '', + quantity: item.quantity || 0, + originalPrice: unitPrice, + price: unitPrice, + selectedOptions: + item?.details?.selectedOptions?.map(option => ({ + attribute: option.attribute || '', + values: option.values || [], + })) || [], + addons: item.details?.selectedAddons?.map(addon => ({ + attribute: addon.attribute || '', + sku: addon.sku || '', + values: addon.values?.map(value => ({ + costAdjustment: value.costAdjustment + ? { + currencyCode: value.costAdjustment.currencyCode ?? undefined, + value: value.costAdjustment.value ?? undefined, + } + : undefined, + name: value.name ?? undefined, + })), })), - })), - })) || []; + sellingPlan: + sellingPlan?.name != null || sellingPlan?.category != null + ? { name: sellingPlan.name, category: sellingPlan.category } + : undefined, + }; + }) || []; - // Calculate totals + // Calculate totals: subtotal matches sum of displayed line amounts (selling-plan metafield prices when set). const itemCount = items.reduce((sum, item) => sum + item.quantity, 0); const currencyCode = order?.totals?.total?.currencyCode || 'USD'; - const subtotal = order?.totals?.subTotal?.value || 0; + const apiSubtotal = order?.totals?.subTotal?.value || 0; + const lineSubtotalSum = items.reduce( + (sum, item) => sum + item.originalPrice * (item.quantity || 0), + 0 + ); + const subtotal = lineSubtotalSum; const shipping = order?.totals?.shippingTotal?.value || 0; const taxes = order?.totals?.taxTotal?.value || 0; const discount = order?.totals?.discountTotal?.value || 0; - const total = order?.totals?.total?.value || 0; + const apiTotal = order?.totals?.total?.value || 0; + const total = apiTotal + (lineSubtotalSum - apiSubtotal); const totals = { subtotal, diff --git a/packages/react/src/components/storefront/contexts/product-details-context.tsx b/packages/react/src/components/storefront/contexts/product-details-context.tsx new file mode 100644 index 00000000..f08161df --- /dev/null +++ b/packages/react/src/components/storefront/contexts/product-details-context.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; +import type { ProductDetailsTarget } from '../targets/product-details-target'; + +export interface ProductDetailsContextValue { + targets?: Partial< + Record< + ProductDetailsTarget, + (props: { skuId: string | null; storeId: string | undefined }) => ReactNode + > + >; + skuId: string | null; +} + +const productDetailsContext = createContext( + null +); + +export const ProductDetailsProvider = productDetailsContext.Provider; + +export function useProductDetailsContext(): ProductDetailsContextValue { + const ctx = useContext(productDetailsContext); + if (!ctx) { + throw new Error( + 'useProductDetailsContext must be used within a component' + ); + } + return ctx; +} diff --git a/packages/react/src/components/storefront/hooks/use-add-to-cart.ts b/packages/react/src/components/storefront/hooks/use-add-to-cart.ts index 43cd8b8d..614f6149 100644 --- a/packages/react/src/components/storefront/hooks/use-add-to-cart.ts +++ b/packages/react/src/components/storefront/hooks/use-add-to-cart.ts @@ -1,13 +1,27 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useGoDaddyContext } from '@/godaddy-provider'; import { getCartOrderId, setCartOrderId } from '@/lib/cart-storage'; -import { addCartLineItem, createCartOrder } from '@/lib/godaddy/godaddy'; +import { + addCartLineItem, + createCartOrder, + getCartOrder, + updateCartLineItem, +} from '@/lib/godaddy/godaddy'; + +export type AddToCartSellingPlan = { + planId: string; + name?: string; + category?: string; + [key: string]: unknown; +}; export interface AddToCartInput { skuId: string; name: string; quantity: number; productAssetUrl?: string; + sellingPlanId?: string | null; + sellingPlan?: AddToCartSellingPlan | null; } export interface UseAddToCartOptions { @@ -48,7 +62,33 @@ export function useAddToCart(options?: UseAddToCartOptions) { }, }); - // Add line item mutation + // Update line item quantity (merge when same sku + same selling plan) + const updateLineItemMutation = useMutation({ + mutationFn: ({ + orderId, + lineItemId, + newQuantity, + }: { + orderId: string; + lineItemId: string; + newQuantity: number; + }) => + updateCartLineItem( + { id: lineItemId, orderId, quantity: newQuantity }, + context.storeId!, + context.clientId!, + context?.apiHost + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cart-order'] }); + options?.onSuccess?.(); + }, + onError: error => { + options?.onError?.(error as Error); + }, + }); + + // Add line item: optional top-level metafields (SELLING_PLAN JSON) — API-supported way to attach the plan. const addLineItemMutation = useMutation({ mutationFn: ({ orderId, @@ -68,16 +108,24 @@ export function useAddToCart(options?: UseAddToCartOptions) { details: { productAssetUrl: input.productAssetUrl || undefined, }, + ...(input.sellingPlan + ? { + metafields: [ + { + key: 'SELLING_PLAN', + type: 'JSON', + value: JSON.stringify(input.sellingPlan), + }, + ], + } + : {}), }, context.storeId!, context.clientId!, context?.apiHost ), onSuccess: () => { - // Invalidate all cart queries to refresh (queryKey prefix match) queryClient.invalidateQueries({ queryKey: ['cart-order'] }); - - // Call success callback options?.onSuccess?.(); }, onError: error => { @@ -106,14 +154,51 @@ export function useAddToCart(options?: UseAddToCartOptions) { } } - // Add line item to cart - await addLineItemMutation.mutateAsync({ orderId: cartOrderId, input }); + // Fetch current cart to check for matching line (same sku + same selling plan = merge quantity) + const order = await getCartOrder( + cartOrderId, + context.storeId!, + context.clientId!, + context?.apiHost + ).then(data => data?.orderById); + + const inputPlanId = input.sellingPlan?.planId ?? null; + const matchingItem = order?.lineItems?.find(item => { + if (item.skuId !== input.skuId) return false; + const metaValue = item.metafields?.find(m => m?.key === 'SELLING_PLAN')?.value; + let existingPlanId: string | null = null; + if (metaValue) { + try { + const parsed = JSON.parse(metaValue) as { planId?: string }; + existingPlanId = parsed?.planId ?? null; + } catch { + existingPlanId = null; + } + } + return existingPlanId === inputPlanId; + }); + + if (matchingItem?.id && matchingItem.quantity != null) { + await updateLineItemMutation.mutateAsync({ + orderId: cartOrderId, + lineItemId: matchingItem.id, + newQuantity: matchingItem.quantity + input.quantity, + }); + } else { + await addLineItemMutation.mutateAsync({ + orderId: cartOrderId, + input, + }); + } }; return { addToCart, - isLoading: createCartMutation.isPending || addLineItemMutation.isPending, + isLoading: + createCartMutation.isPending || + addLineItemMutation.isPending || + updateLineItemMutation.isPending, isCreatingCart: createCartMutation.isPending, - isAddingItem: addLineItemMutation.isPending, + isAddingItem: addLineItemMutation.isPending || updateLineItemMutation.isPending, }; } diff --git a/packages/react/src/components/storefront/index.ts b/packages/react/src/components/storefront/index.ts index 02bebd62..656f3669 100644 --- a/packages/react/src/components/storefront/index.ts +++ b/packages/react/src/components/storefront/index.ts @@ -5,3 +5,4 @@ export * from './product-card'; export * from './product-details.tsx'; export * from './product-grid'; export * from './product-search'; +export type { ProductDetailsTarget } from './targets/product-details-target'; diff --git a/packages/react/src/components/storefront/product-details.tsx b/packages/react/src/components/storefront/product-details.tsx index 7e072ca8..da52e36c 100644 --- a/packages/react/src/components/storefront/product-details.tsx +++ b/packages/react/src/components/storefront/product-details.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Loader2, Minus, Plus, ShoppingCart } from 'lucide-react'; +import type { ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; import { useAddToCart } from '@/components/storefront/hooks/use-add-to-cart'; @@ -20,6 +21,25 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useGoDaddyContext } from '@/godaddy-provider'; import { getSku, getSkuGroup } from '@/lib/godaddy/godaddy'; import type { SKUGroupAttribute, SKUGroupAttributeValue } from '@/types'; +import { ProductDetailsProvider } from './contexts/product-details-context'; +import type { ProductDetailsTarget } from './targets/product-details-target'; +import { ProductDetailsTargetSlot } from './targets/product-details-target'; + +/** Price at checkout (e.g. subscription price). Value in minor units (cents). */ +export type SellingPlanCheckoutPrice = { + value: number; + currencyCode?: string; +}; + +/** Selling plan option for add-to-cart (from PDP selector). */ +export type SellingPlanSelection = { + planId: string; + name?: string; + category?: string; + /** When set, product details shows this as the main price (checkout/subscription price). Value in minor units. */ + checkoutPrice?: SellingPlanCheckoutPrice; + [key: string]: unknown; +}; interface ProductDetailsProps { productId: string; @@ -27,6 +47,17 @@ interface ProductDetailsProps { clientId?: string; onAddToCartSuccess?: () => void; onAddToCartError?: (error: Error) => void; + /** Selected selling plan id (from dropdown). */ + selectedSellingPlanId?: string | null; + /** Full selected selling plan for display and cart metafield. */ + selectedSellingPlan?: SellingPlanSelection | null; + /** Extensibility slots — keyed render functions injected at predefined locations. */ + targets?: Partial< + Record< + ProductDetailsTarget, + (props: { skuId: string | null; storeId: string | undefined }) => ReactNode + > + >; } // Flattened attribute structure for UI (transforms edges/node to flat array) @@ -124,6 +155,9 @@ export function ProductDetails({ clientId: clientIdProp, onAddToCartSuccess, onAddToCartError, + selectedSellingPlanId, + selectedSellingPlan, + targets, }: ProductDetailsProps) { const context = useGoDaddyContext(); const { t } = context; @@ -282,6 +316,9 @@ export function ProductDetails({ // Use individual SKU data if available, otherwise use SKU Group data const selectedSku = individualSkuData?.sku; + const resolvedSkuId = + selectedSku?.id ?? data?.skuGroup?.skus?.edges?.[0]?.node?.id ?? null; + // Track main carousel selection and sync thumbnail carousel useEffect(() => { if (!carouselApi) return; @@ -340,16 +377,42 @@ export function ProductDetails({ // Use SKU-specific pricing if available, otherwise fall back to SKU Group pricing const skuPrice = selectedSku?.prices?.edges?.[0]?.node; - const priceMin = skuPrice?.value?.value ?? product?.priceRange?.min ?? 0; - const priceMax = selectedSku - ? priceMin - : (product?.priceRange?.max ?? priceMin); + const catalogPriceMin = skuPrice?.value?.value ?? product?.priceRange?.min ?? 0; + const catalogPriceMax = selectedSku + ? catalogPriceMin + : (product?.priceRange?.max ?? catalogPriceMin); const compareAtMin = skuPrice?.compareAtValue?.value ?? product?.compareAtPriceRange?.min; const compareAtMax = selectedSku ? compareAtMin : product?.compareAtPriceRange?.max; - const isOnSale = compareAtMin && compareAtMin > priceMin; + + // When a selling plan is selected, use checkout price for the current SKU (from plan.checkoutPrice or plan.catalogPrices[skuId].checkoutPrices[0]) + const resolvedCheckoutPrice = (() => { + if (!selectedSellingPlan) return undefined; + const currentSkuId = selectedSku?.id; + const catalogPrices = selectedSellingPlan.catalogPrices as Array<{ skuId?: string; checkoutPrices?: Array<{ value?: number; currency?: string; currencyCode?: string }> }> | undefined; + if (currentSkuId && Array.isArray(catalogPrices)) { + const forSku = catalogPrices.find(c => c.skuId === currentSkuId); + const checkout = forSku?.checkoutPrices?.[0]; + if (checkout?.value != null) { + return { value: Number(checkout.value), currencyCode: checkout.currencyCode ?? checkout.currency }; + } + } + const cp = selectedSellingPlan.checkoutPrice; + if (cp?.value != null) return { value: Number(cp.value), currencyCode: cp.currencyCode }; + return undefined; + })(); + const sellingPlanCheckoutPrice = resolvedCheckoutPrice?.value; + const hasCheckoutPrice = sellingPlanCheckoutPrice != null; + const priceMin = hasCheckoutPrice ? sellingPlanCheckoutPrice : catalogPriceMin; + const priceMax = hasCheckoutPrice ? sellingPlanCheckoutPrice : catalogPriceMax; + const priceCurrency = (resolvedCheckoutPrice?.currencyCode as string) || 'USD'; + const compareAtWhenPlan = + hasCheckoutPrice ? catalogPriceMin : undefined; + const isOnSale = + (compareAtMin && compareAtMin > priceMin) || + (compareAtWhenPlan != null && compareAtWhenPlan > priceMin); const isPriceRange = priceMin !== priceMax; const isCompareAtPriceRange = compareAtMin && compareAtMax && compareAtMin !== compareAtMax; @@ -402,338 +465,364 @@ export function ProductDetails({ return; } + const skuId = selectedSku?.id || product?.skus?.edges?.[0]?.node?.id || ''; await addToCart({ - skuId: selectedSku?.id || product?.skus?.edges?.[0]?.node?.id || '', + skuId, name: title, quantity, productAssetUrl: images[0] || undefined, + ...(selectedSellingPlan && { + sellingPlanId: selectedSellingPlanId ?? selectedSellingPlan.planId, + sellingPlan: selectedSellingPlan, + }), }); }; return ( -
- {/* Product Images */} -
- {/* Main Image Carousel */} -
- {isOnSale && ( - + +
+ {/* Product Images */} +
+ + + {/* Main Image Carousel */} +
+ {isOnSale && ( + + {t.storefront.sale} + + )} + - {t.storefront.sale} - - )} - - - {images.length > 0 ? ( - images.map((image: string, index: number) => ( - - + + {images.length > 0 ? ( + images.map((image: string, index: number) => ( + + + {`${title} + + + )) + ) : ( + + +
+ {t.storefront.noImageAvailable} +
+
+
+ )} +
+ {images.length > 1 && ( + <> + + + + )} +
+
+ + {/* Thumbnail Grid or Carousel */} + {images.length > 1 && ( + <> + {images.length <= 4 ? ( + // Simple grid for 4 or fewer images +
+ {images.map((image: string, index: number) => ( + + ))} +
) : ( - - -
- {t.storefront.noImageAvailable} -
-
-
+ // Carousel for more than 4 images + + + {images.map((image: string, index: number) => ( + +
+ +
+
+ ))} +
+ + +
)} - - {images.length > 1 && ( - <> - - - - )} - + + )} + +
- {/* Thumbnail Grid or Carousel */} - {images.length > 1 && ( - <> - {images.length <= 4 ? ( - // Simple grid for 4 or fewer images -
- {images.map((image: string, index: number) => ( - - ))} -
- ) : ( - // Carousel for more than 4 images - - - {images.map((image: string, index: number) => ( - -
- -
-
- ))} -
- - -
- )} - - )} -
+ {/* Product Information */} +
+ +
+

{title}

- {/* Product Information */} -
-
-

{title}

- - {/* Price */} -
- - {isPriceRange - ? `${formatCurrency({ amount: priceMin, currencyCode: 'USD', inputInMinorUnits: true })} - ${formatCurrency({ amount: priceMax, currencyCode: 'USD', inputInMinorUnits: true })}` - : formatCurrency({ - amount: priceMin, - currencyCode: 'USD', - inputInMinorUnits: true, - })} - - {isOnSale && compareAtMin && ( - - {isCompareAtPriceRange - ? `${formatCurrency({ amount: compareAtMin, currencyCode: 'USD', inputInMinorUnits: true })} - ${formatCurrency({ amount: compareAtMax!, currencyCode: 'USD', inputInMinorUnits: true })}` + {/* Price */} +
+ + {isPriceRange + ? `${formatCurrency({ amount: priceMin, currencyCode: priceCurrency, inputInMinorUnits: true })} - ${formatCurrency({ amount: priceMax, currencyCode: priceCurrency, inputInMinorUnits: true })}` : formatCurrency({ - amount: compareAtMin, - currencyCode: 'USD', + amount: priceMin, + currencyCode: priceCurrency, inputInMinorUnits: true, })} - )} + {isOnSale && (compareAtMin || compareAtWhenPlan != null) && ( + + {isCompareAtPriceRange + ? `${formatCurrency({ amount: compareAtMin!, currencyCode: priceCurrency, inputInMinorUnits: true })} - ${formatCurrency({ amount: compareAtMax!, currencyCode: priceCurrency, inputInMinorUnits: true })}` + : formatCurrency({ + amount: compareAtWhenPlan ?? compareAtMin!, + currencyCode: priceCurrency, + inputInMinorUnits: true, + })} + + )} +
-
+ + + + {/* Description */} + {htmlDescription || description ? ( +
+ {htmlDescription ? ( +
+ ) : ( +

{description}

+ )} +
+ ) : null} + + + + {/* Product Attributes (Size, Color, etc.) */} + {attributes.length > 0 && ( +
+ {attributes.map(attribute => ( +
+ +
+ {attribute.values.map(value => ( + + ))} +
+
+ ))} + + {/* SKU Match Status */} + {selectedAttributeValues.length > 0 && ( +
+ {isSkuLoading && ( +
+
+ {t.storefront.loadingVariantDetails} +
+ )} + {!isSkuLoading && matchedSkus.length === 0 && ( +
+ {t.storefront.combinationNotAvailable} +
+ )} + {!isSkuLoading && matchedSkus.length > 1 && ( +
+ {matchedSkus.length} {t.storefront.variantsMatch} +
+ )} +
+ )} +
+ )} + - {/* Description */} - {htmlDescription || description ? ( + + {/* Quantity Selector */}
- {htmlDescription ? ( -
- ) : ( -

{description}

- )} -
- ) : null} - - {/* Product Attributes (Size, Color, etc.) */} - {attributes.length > 0 && ( -
- {attributes.map(attribute => ( -
- -
- {attribute.values.map(value => ( - - ))} -
-
- ))} - - {/* SKU Match Status */} - {selectedAttributeValues.length > 0 && ( -
- {isSkuLoading && ( -
-
- {t.storefront.loadingVariantDetails} -
- )} - {!isSkuLoading && matchedSkus.length === 0 && ( -
- {t.storefront.combinationNotAvailable} -
- )} - {!isSkuLoading && matchedSkus.length > 1 && ( -
- {matchedSkus.length} {t.storefront.variantsMatch} -
- )} -
- )} + +
+ + + {quantity} + + +
- )} + - {/* Quantity Selector */} -
- -
- - - {quantity} - - -
-
+ - {/* Add to Cart Button */} - + {/* Add to Cart Button */} + + - {/* Additional Product Information */} -
- {product?.type && ( -
- - {t.storefront.productType} - - - {product.type} - -
- )} - {product?.id && ( -
- - {t.storefront.productId} - - - {product.id} - -
- )} - {selectedSku && ( - <> + + {/* Additional Product Information */} +
+ {product?.type && (
- {t.storefront.selectedSku} + {t.storefront.productType} + + + {product.type} + +
+ )} + {product?.id && ( +
+ + {t.storefront.productId} - {selectedSku.code} + {product.id}
- {selectedSku.inventoryCounts?.edges && - selectedSku.inventoryCounts.edges.length > 0 && ( -
- - {t.storefront.stockStatus} - - - {(() => { - const availableCount = - selectedSku.inventoryCounts.edges.find( - edge => edge?.node?.type === 'AVAILABLE' - )?.node?.quantity ?? 0; - if (availableCount === 0) - return t.storefront.outOfStock; - if (availableCount < 10) - return `${t.storefront.lowStock} (${availableCount})`; - return t.storefront.inStock; - })()} - -
- )} - - )} + )} + {selectedSku && ( + <> +
+ + {t.storefront.selectedSku} + + + {selectedSku.code} + +
+ {selectedSku.inventoryCounts?.edges && + selectedSku.inventoryCounts.edges.length > 0 && ( +
+ + {t.storefront.stockStatus} + + + {(() => { + const availableCount = + selectedSku.inventoryCounts.edges.find( + edge => edge?.node?.type === 'AVAILABLE' + )?.node?.quantity ?? 0; + if (availableCount === 0) + return t.storefront.outOfStock; + if (availableCount < 10) + return `${t.storefront.lowStock} (${availableCount})`; + return t.storefront.inStock; + })()} + +
+ )} + + )} +
+
-
+ + ); } diff --git a/packages/react/src/components/storefront/targets/product-details-target.tsx b/packages/react/src/components/storefront/targets/product-details-target.tsx new file mode 100644 index 00000000..0ca6eefd --- /dev/null +++ b/packages/react/src/components/storefront/targets/product-details-target.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useGoDaddyContext } from '@/godaddy-provider'; +import { cn } from '@/lib/utils'; +import { useProductDetailsContext } from '../contexts/product-details-context'; + +export type ProductDetailsTarget = + | 'product-details.before' + | 'product-details.after' + | 'product-details.media.before' + | 'product-details.media.after' + | 'product-details.title.before' + | 'product-details.title.after' + | 'product-details.description.before' + | 'product-details.description.after' + | 'product-details.attributes.before' + | 'product-details.attributes.after' + | 'product-details.quantity.before' + | 'product-details.quantity.after' + | 'product-details.add-to-cart.before' + | 'product-details.add-to-cart.after' + | 'product-details.metadata.before' + | 'product-details.metadata.after'; + +export function ProductDetailsTargetSlot({ + id, +}: { + id: ProductDetailsTarget; +}) { + const { debug, storeId } = useGoDaddyContext(); + const { targets, skuId } = useProductDetailsContext(); + + const target = targets?.[id]; + + let content: React.ReactNode = null; + if (target) { + content = target({ skuId, storeId }); + } else if (debug) { + content = {id}; + } + + return ( +
+ {content} +
+ ); +} diff --git a/packages/react/src/lib/godaddy/orders-storefront-mutations.ts b/packages/react/src/lib/godaddy/orders-storefront-mutations.ts index f485ce0f..9c10377d 100644 --- a/packages/react/src/lib/godaddy/orders-storefront-mutations.ts +++ b/packages/react/src/lib/godaddy/orders-storefront-mutations.ts @@ -134,6 +134,11 @@ export const AddLineItemBySkuIdMutation = graphql(` } createdAt updatedAt + metafields { + key + type + value + } } } `); diff --git a/packages/react/src/lib/godaddy/orders-storefront-queries.ts b/packages/react/src/lib/godaddy/orders-storefront-queries.ts index 4a4dcf4c..2f482021 100644 --- a/packages/react/src/lib/godaddy/orders-storefront-queries.ts +++ b/packages/react/src/lib/godaddy/orders-storefront-queries.ts @@ -18,6 +18,11 @@ export const GetCartOrderQuery = graphql(` skuId type fulfillmentMode + metafields { + key + type + value + } details { productAssetUrl sku