From 4d0f2380c28bab26babcee9390c66069f535392b Mon Sep 17 00:00:00 2001 From: hhuang2 Date: Tue, 3 Mar 2026 23:28:00 -0800 Subject: [PATCH 1/8] feat(selling-plans): integrate selling plans API and dropdown component - Added support for fetching and displaying selling plans from an external or local API. - Introduced `SellingPlanDropdown` component for selecting subscription plans in the product details. - Updated `ProductDetails` to include selected selling plan information. - Enhanced cart functionality to handle selling plan details in line items. - Updated environment configuration to include `SELLING_PLANS_API_URL`. This implementation allows users to select subscription plans for products, improving the checkout experience. --- examples/nextjs/app/store/actions.ts | 90 ++++++++++++++ .../app/store/product/[productId]/product.tsx | 25 +++- .../store/product/selling-plan-dropdown.tsx | 80 ++++++++++++ examples/nextjs/env.sample | 4 + .../checkout/line-items/line-items.tsx | 8 ++ .../react/src/components/storefront/cart.tsx | 73 +++++++---- .../storefront/hooks/use-add-to-cart.ts | 117 ++++++++++++++++-- .../components/storefront/product-details.tsx | 30 ++++- .../godaddy/orders-storefront-mutations.ts | 5 + .../lib/godaddy/orders-storefront-queries.ts | 5 + 10 files changed, 401 insertions(+), 36 deletions(-) create mode 100644 examples/nextjs/app/store/product/selling-plan-dropdown.tsx diff --git a/examples/nextjs/app/store/actions.ts b/examples/nextjs/app/store/actions.ts index 6dbe2207..030e56d6 100644 --- a/examples/nextjs/app/store/actions.ts +++ b/examples/nextjs/app/store/actions.ts @@ -3,6 +3,96 @@ import { createCheckoutSession } from '@godaddy/react/server'; import { redirect } from 'next/navigation'; +/** Selling plan from external API (or local in dev). */ +export type SellingPlanOption = { + planId: string; + name?: string; + category?: string; + [key: string]: unknown; +}; + +export type SellingPlanGroup = { + sellingPlans: SellingPlanOption[]; + [key: string]: unknown; +}; + +export type GetSellingPlansResponse = { + sellingPlanGroups?: SellingPlanGroup[]; + [key: string]: unknown; +}; + +/** + * 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: { skuIds: string[] } +): Promise { + const base = process.env.SELLING_PLANS_API_URL || 'http://localhost:8443'; // don't fucking make change + if (!base) { + return { sellingPlanGroups: [] }; + } + // 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); + } + const res = await fetch(url.toString(), { cache: 'no-store' }); + if (!res.ok) { + console.warn('getSellingPlans failed', res.status, res.statusText); + return { sellingPlanGroups: [] }; + } + const data = await res.json(); + const out = normalizeSellingPlansResponse(data); + const planCount = out.sellingPlanGroups?.flatMap(g => g.sellingPlans ?? []).length ?? 0; + if (process.env.NODE_ENV === 'development' && planCount === 0) { + const keys = typeof data === 'object' && data !== null ? Object.keys(data as object) : []; + const snippet = typeof data === 'object' ? JSON.stringify(data).slice(0, 400) : String(data); + console.warn('getSellingPlans: API returned 0 plans. Top-level keys:', keys, '| Response snippet:', snippet + (snippet.length >= 400 ? '...' : '')); + } + return out; +} + +/** Normalize API response to our shape. Supports: { groups: [ { sellingPlans: [ { planId, name, category } ] } ] } and variants. */ +function normalizeSellingPlansResponse(data: unknown): GetSellingPlansResponse { + if (Array.isArray(data)) { + return normalizeSellingPlansResponse({ groups: [{ sellingPlans: data }] }); + } + const obj = data && typeof data === 'object' ? (data as Record) : {}; + const payload = + obj.data !== undefined && typeof obj.data === 'object' && !Array.isArray(obj.data) + ? (obj.data as Record) + : obj; + + // Explicit support for API shape: { groups: [ { groupId, name, sellingPlans: [ { planId, name, category } ] } ], page? } + const rawGroups: Array> = Array.isArray(payload.groups) + ? (payload.groups as Array>) + : Array.isArray(payload.sellingPlanGroups) + ? (payload.sellingPlanGroups as Array>) + : Array.isArray(payload.plans) + ? [{ sellingPlans: payload.plans }] + : Array.isArray(payload.sellingPlans) + ? [{ sellingPlans: payload.sellingPlans }] + : []; + + const sellingPlanGroups: SellingPlanGroup[] = rawGroups.map(g => { + const rawPlans = (Array.isArray(g.sellingPlans) ? g.sellingPlans : Array.isArray(g.plans) ? g.plans : []) as Array>; + const sellingPlans: SellingPlanOption[] = rawPlans + .map((p: Record) => ({ + planId: String(p.planId ?? p.plan_id ?? p.id ?? ''), + name: (p.name as string) ?? (p.title as string), + category: (p.category as string) ?? (p.category_name as string), + })) + .filter(p => p.planId.length > 0); + return { ...g, sellingPlans }; + }); + + return { sellingPlanGroups }; +} + 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..9edc090a 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 type { SellingPlanOption } from '../../actions'; +import { SellingPlanDropdown } from '../selling-plan-dropdown'; import { useCart } from '../../layout'; 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: SellingPlanOption | null) => { + setSelectedSellingPlanId(planId); + setSelectedSellingPlan(plan); + }; return (
@@ -17,7 +27,20 @@ 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..e3acb825 --- /dev/null +++ b/examples/nextjs/app/store/product/selling-plan-dropdown.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { + getSellingPlans, + type SellingPlanOption, + type SellingPlanGroup, +} from '../actions'; + +interface SellingPlanDropdownProps { + storeId: string; + skuId: string | null; + selectedPlanId: string | null; + onSelectionChange: (planId: string | null, plan: SellingPlanOption | null) => void; +} + +export function SellingPlanDropdown({ + storeId, + skuId, + selectedPlanId, + onSelectionChange, +}: SellingPlanDropdownProps) { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(false); + + const loadPlans = useCallback(async () => { + if (!storeId || !skuId) { + setPlans([]); + return; + } + setLoading(true); + try { + const res = await getSellingPlans(storeId, { skuIds: [skuId] }); + const list = + res.sellingPlanGroups?.flatMap((g: SellingPlanGroup) => g.sellingPlans ?? []) ?? []; + setPlans(list); + if (list.length === 0) { + onSelectionChange(null, null); + } + } finally { + setLoading(false); + } + }, [storeId, skuId]); // omit onSelectionChange so we don't refetch on every parent re-render + + useEffect(() => { + loadPlans(); + }, [loadPlans]); + + if (loading || !skuId || 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..7ad9447c 100644 --- a/packages/react/src/components/storefront/cart.tsx +++ b/packages/react/src/components/storefront/cart.tsx @@ -76,33 +76,56 @@ export function Cart({ const { t } = useGoDaddyContext(); // Transform cart line items to Product format for CartLineItems component + // Selling plan comes from backend (line item metafields). 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 fromMetafield = item.details?.metafields?.find(m => m?.key === 'SELLING_PLAN'); + let sellingPlan: { name?: string; category?: string } | null = null; + if (fromMetafield?.value) { + try { + const parsed = JSON.parse(fromMetafield.value) as { + name?: string; + category?: string; + }; + sellingPlan = { + name: parsed.name, + category: parsed.category, + }; + } catch { + sellingPlan = null; + } + } + return { + 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, + })), })), - })), - })) || []; + sellingPlan: + sellingPlan?.name != null || sellingPlan?.category != null + ? { name: sellingPlan.name, category: sellingPlan.category } + : undefined, + }; + }) || []; // Calculate totals const itemCount = items.reduce((sum, item) => sum + item.quantity, 0); 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..2dd84f7d 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,30 @@ 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'; + +/** Selling plan to attach to the line item (sent to backend via details.metafields). */ +export type AddToCartSellingPlan = { + planId: string; + name?: string; + category?: string; + [key: string]: unknown; +}; export interface AddToCartInput { skuId: string; name: string; quantity: number; productAssetUrl?: string; + /** Selling plan id (for display). */ + sellingPlanId?: string | null; + /** Full selling plan; sent to backend in details.metafields and returned on line item. */ + sellingPlan?: AddToCartSellingPlan | null; } export interface UseAddToCartOptions { @@ -48,7 +65,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 mutation. Sends selling plan in details.metafields when present. const addLineItemMutation = useMutation({ mutationFn: ({ orderId, @@ -67,17 +110,36 @@ export function useAddToCart(options?: UseAddToCartOptions) { status: 'DRAFT', details: { productAssetUrl: input.productAssetUrl || undefined, + ...(input.sellingPlan + ? { + metafields: [ + { + key: 'SELLING_PLAN', + type: 'JSON', + value: JSON.stringify(input.sellingPlan), + }, + ], + } + : {}), }, }, context.storeId!, context.clientId!, context?.apiHost + // ...(input.sellingPlan + // ? { + // metafields: [ + // { + // key: 'SELLING_PLAN', + // type: 'JSON', + // value: JSON.stringify(input.sellingPlan), + // }, + // ], + // } + // : {}), ), onSuccess: () => { - // Invalidate all cart queries to refresh (queryKey prefix match) queryClient.invalidateQueries({ queryKey: ['cart-order'] }); - - // Call success callback options?.onSuccess?.(); }, onError: error => { @@ -106,14 +168,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 meta = item.details?.metafields?.find(m => m?.key === 'SELLING_PLAN'); + let existingPlanId: string | null = null; + if (meta?.value) { + try { + const parsed = JSON.parse(meta.value) 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/product-details.tsx b/packages/react/src/components/storefront/product-details.tsx index 7e072ca8..adf1180b 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'; @@ -21,12 +22,26 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { getSku, getSkuGroup } from '@/lib/godaddy/godaddy'; import type { SKUGroupAttribute, SKUGroupAttributeValue } from '@/types'; +/** Selling plan option for add-to-cart (from PDP selector). */ +export type SellingPlanSelection = { + planId: string; + name?: string; + category?: string; + [key: string]: unknown; +}; + interface ProductDetailsProps { productId: string; storeId?: string; 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; + /** Renders above the Add to Cart button; receives current skuId and storeId for selling plan dropdown. */ + childrenAboveAddToCart?: (props: { skuId: string | null; storeId: string | undefined }) => ReactNode; } // Flattened attribute structure for UI (transforms edges/node to flat array) @@ -124,6 +139,9 @@ export function ProductDetails({ clientId: clientIdProp, onAddToCartSuccess, onAddToCartError, + selectedSellingPlanId, + selectedSellingPlan, + childrenAboveAddToCart, }: ProductDetailsProps) { const context = useGoDaddyContext(); const { t } = context; @@ -402,11 +420,16 @@ 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, + }), }); }; @@ -656,6 +679,11 @@ export function ProductDetails({ + {childrenAboveAddToCart?.({ + skuId: selectedSku?.id ?? product?.skus?.edges?.[0]?.node?.id ?? null, + storeId, + })} + {/* Add to Cart Button */} + ))} + ) : ( - - -
- {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: priceCurrency, inputInMinorUnits: true })} - ${formatCurrency({ amount: priceMax, currencyCode: priceCurrency, inputInMinorUnits: true })}` - : formatCurrency({ - 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 })}` + {/* Price */} +
+ + {isPriceRange + ? `${formatCurrency({ amount: priceMin, currencyCode: priceCurrency, inputInMinorUnits: true })} - ${formatCurrency({ amount: priceMax, currencyCode: priceCurrency, inputInMinorUnits: true })}` : formatCurrency({ - amount: compareAtWhenPlan ?? compareAtMin!, + 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} - - -
-
+ - {childrenAboveAddToCart?.({ - skuId: selectedSku?.id ?? product?.skus?.edges?.[0]?.node?.id ?? null, - storeId, - })} - - {/* 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.productType} + + + {product.type} + +
+ )} + {product?.id && (
- {t.storefront.selectedSku} + {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/target/target.tsx b/packages/react/src/components/storefront/target/target.tsx new file mode 100644 index 00000000..07db52d7 --- /dev/null +++ b/packages/react/src/components/storefront/target/target.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useGoDaddyContext } from '@/godaddy-provider'; +import { cn } from '@/lib/utils'; +import { useProductDetailsContext } from '../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 } = useGoDaddyContext(); + const { targets, skuId, storeId } = useProductDetailsContext(); + + const target = targets?.[id]; + + let content: React.ReactNode = null; + if (target) { + content = target({ skuId, storeId }); + } else if (debug) { + content = {id}; + } + + return ( +
+ {content} +
+ ); +} From 4e1145425185f02393ce70ec25ea296bf14a28a4 Mon Sep 17 00:00:00 2001 From: hhuang2 Date: Thu, 2 Apr 2026 16:09:09 -0700 Subject: [PATCH 8/8] refactor(product-details): update import paths and introduce new context for targets - Refactored import paths in product-details and related components to align with the new structure. - Introduced a new context provider for managing product detail targets, enhancing the extensibility of the product details component. - Updated the ProductDetails component to utilize the new targets system, replacing deprecated props. These changes improve the organization of the codebase and facilitate easier customization of product detail rendering. --- .../product-details-context.tsx | 3 +-- .../react/src/components/storefront/index.ts | 2 +- .../components/storefront/product-details.tsx | 25 ++++--------------- .../product-details-target.tsx} | 6 ++--- 4 files changed, 10 insertions(+), 26 deletions(-) rename packages/react/src/components/storefront/{ => contexts}/product-details-context.tsx (89%) rename packages/react/src/components/storefront/{target/target.tsx => targets/product-details-target.tsx} (86%) diff --git a/packages/react/src/components/storefront/product-details-context.tsx b/packages/react/src/components/storefront/contexts/product-details-context.tsx similarity index 89% rename from packages/react/src/components/storefront/product-details-context.tsx rename to packages/react/src/components/storefront/contexts/product-details-context.tsx index 60c58e00..f08161df 100644 --- a/packages/react/src/components/storefront/product-details-context.tsx +++ b/packages/react/src/components/storefront/contexts/product-details-context.tsx @@ -2,7 +2,7 @@ import { createContext, useContext } from 'react'; import type { ReactNode } from 'react'; -import type { ProductDetailsTarget } from './target/target'; +import type { ProductDetailsTarget } from '../targets/product-details-target'; export interface ProductDetailsContextValue { targets?: Partial< @@ -12,7 +12,6 @@ export interface ProductDetailsContextValue { > >; skuId: string | null; - storeId: string | undefined; } const productDetailsContext = createContext( diff --git a/packages/react/src/components/storefront/index.ts b/packages/react/src/components/storefront/index.ts index 67b20f5e..656f3669 100644 --- a/packages/react/src/components/storefront/index.ts +++ b/packages/react/src/components/storefront/index.ts @@ -5,4 +5,4 @@ export * from './product-card'; export * from './product-details.tsx'; export * from './product-grid'; export * from './product-search'; -export type { ProductDetailsTarget } from './target/target'; +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 457fcdf3..da52e36c 100644 --- a/packages/react/src/components/storefront/product-details.tsx +++ b/packages/react/src/components/storefront/product-details.tsx @@ -21,9 +21,9 @@ 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 './product-details-context'; -import type { ProductDetailsTarget } from './target/target'; -import { ProductDetailsTargetSlot } from './target/target'; +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 = { @@ -58,11 +58,6 @@ interface ProductDetailsProps { (props: { skuId: string | null; storeId: string | undefined }) => ReactNode > >; - /** - * @deprecated Use `targets['product-details.add-to-cart.before']` instead. - * Renders above the Add to Cart button; receives current skuId and storeId for selling plan dropdown. - */ - childrenAboveAddToCart?: (props: { skuId: string | null; storeId: string | undefined }) => ReactNode; } // Flattened attribute structure for UI (transforms edges/node to flat array) @@ -162,8 +157,7 @@ export function ProductDetails({ onAddToCartError, selectedSellingPlanId, selectedSellingPlan, - targets: targetsProp, - childrenAboveAddToCart, + targets, }: ProductDetailsProps) { const context = useGoDaddyContext(); const { t } = context; @@ -325,15 +319,6 @@ export function ProductDetails({ const resolvedSkuId = selectedSku?.id ?? data?.skuGroup?.skus?.edges?.[0]?.node?.id ?? null; - const targets = useMemo(() => { - if (!childrenAboveAddToCart) return targetsProp; - if (targetsProp?.['product-details.add-to-cart.before']) return targetsProp; - return { - ...targetsProp, - 'product-details.add-to-cart.before': childrenAboveAddToCart, - } as typeof targetsProp; - }, [targetsProp, childrenAboveAddToCart]); - // Track main carousel selection and sync thumbnail carousel useEffect(() => { if (!carouselApi) return; @@ -494,7 +479,7 @@ export function ProductDetails({ }; return ( - +
{/* Product Images */} diff --git a/packages/react/src/components/storefront/target/target.tsx b/packages/react/src/components/storefront/targets/product-details-target.tsx similarity index 86% rename from packages/react/src/components/storefront/target/target.tsx rename to packages/react/src/components/storefront/targets/product-details-target.tsx index 07db52d7..0ca6eefd 100644 --- a/packages/react/src/components/storefront/target/target.tsx +++ b/packages/react/src/components/storefront/targets/product-details-target.tsx @@ -2,7 +2,7 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { cn } from '@/lib/utils'; -import { useProductDetailsContext } from '../product-details-context'; +import { useProductDetailsContext } from '../contexts/product-details-context'; export type ProductDetailsTarget = | 'product-details.before' @@ -27,8 +27,8 @@ export function ProductDetailsTargetSlot({ }: { id: ProductDetailsTarget; }) { - const { debug } = useGoDaddyContext(); - const { targets, skuId, storeId } = useProductDetailsContext(); + const { debug, storeId } = useGoDaddyContext(); + const { targets, skuId } = useProductDetailsContext(); const target = targets?.[id];