diff --git a/examples/nextjs/app/store/actions.ts b/examples/nextjs/app/store/actions.ts index 6dbe2207..47f5fe65 100644 --- a/examples/nextjs/app/store/actions.ts +++ b/examples/nextjs/app/store/actions.ts @@ -1,8 +1,75 @@ 'use server'; +import https from 'node:https'; import { createCheckoutSession } from '@godaddy/react/server'; import { redirect } from 'next/navigation'; +export async function getSellingSellingPlans( + storeId: string, + options: { skuIds?: string[]; skuGroupIds?: string[] } = {} +) { + const baseUrl = ( + process.env.SELLING_PLANS_API_URL?.trim() || 'https://localhost:8443' + ).replace(/\/$/, ''); + const url = new URL(`${baseUrl}/api/v1/selling-plans/${storeId}/groups`); + + if (options.skuIds?.length) { + for (const id of options.skuIds) { + url.searchParams.append('skuIds', id); + } + } + + if (options.skuGroupIds?.length) { + for (const id of options.skuGroupIds) { + url.searchParams.append('skuGroupIds', id); + } + } + + const isLocalHttps = + url.protocol === 'https:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1'); + + try { + let res: Response; + if (isLocalHttps && process.env.NODE_ENV === 'development') { + const { body, status } = await new Promise<{ body: string; status: number }>( + (resolve, reject) => { + https + .get(url.toString(), { rejectUnauthorized: false }, (r) => { + let data = ''; + r.on('data', (chunk) => (data += chunk)); + r.on('end', () => + resolve({ body: data, status: r.statusCode ?? 0 }) + ); + r.on('error', reject); + }) + .on('error', reject); + } + ); + res = new Response(body, { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } else { + res = await fetch(url.toString(), { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }); + } + if (!res.ok) { + throw new Error( + `Selling plans API error: ${res.status} ${res.statusText}` + ); + } + return res.json(); + } catch (err) { + if (process.env.NODE_ENV === 'development') { + return { groups: [] }; + } + throw err; + } +} + 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..ae31ff9b 100644 --- a/examples/nextjs/app/store/product/[productId]/product.tsx +++ b/examples/nextjs/app/store/product/[productId]/product.tsx @@ -3,10 +3,15 @@ import { ProductDetails } from '@godaddy/react'; import { ArrowLeft } from 'lucide-react'; import Link from 'next/link'; +import { useState } from 'react'; import { useCart } from '../../layout'; +import { SellingPlanDropdown } from '../selling-plan-dropdown'; + +const storeId = process.env.NEXT_PUBLIC_GODADDY_STORE_ID ?? ''; export default function Product({ productId }: { productId: string }) { const { openCart } = useCart(); + const [selectedPlanId, setSelectedPlanId] = useState(null); return (
@@ -17,7 +22,21 @@ export default function Product({ productId }: { productId: string }) { Back to Store - + + skuId && storeId ? ( + + ) : null + } + />
); } 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..c73e9416 --- /dev/null +++ b/examples/nextjs/app/store/product/selling-plan-dropdown.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { getSellingSellingPlans } from '../actions'; + +interface SellingPlanDropdownProps { + storeId: string; + /** SKU id (in this app the product page URL productId is the SKU id) */ + skuId: string; + selectedPlanId: string | null; + onSelectionChange: (planId: string | null) => void; + disabled?: boolean; +} + +export function SellingPlanDropdown({ + storeId, + skuId, + selectedPlanId, + onSelectionChange, + disabled = false, +}: SellingPlanDropdownProps) { + const [options, setOptions] = useState>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchOptions = useCallback(async () => { + if (!storeId || !skuId) { + setLoading(false); + setOptions([]); + return; + } + setLoading(true); + setError(null); + try { + const data = await getSellingSellingPlans(storeId, { skuIds: [skuId] }); + const list: Array<{ planId: string; name: string; category?: string }> = []; + const groups = Array.isArray(data) + ? data + : data?.groups ?? data?.sellingGroups ?? []; + for (const group of groups) { + const plans = group?.sellingPlans ?? group?.selling_plans ?? []; + if (Array.isArray(plans) && plans.length) { + for (const plan of plans) { + const planId = plan?.planId ?? plan?.id ?? plan?.plan_id; + const name = plan?.name ?? plan?.displayName ?? ''; + if (planId && name) { + list.push({ + planId: String(planId), + name: String(name), + category: plan?.category ?? plan?.planCategory, + }); + } + } + } + } + setOptions(list); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load selling plans'); + setOptions([]); + } finally { + setLoading(false); + } + }, [storeId, skuId]); + + useEffect(() => { + fetchOptions(); + }, [fetchOptions]); + + // Reset selection when SKU changes (e.g. variant change) so we don't carry over a plan from another variant + useEffect(() => { + onSelectionChange(null); + }, [skuId]); // eslint-disable-line react-hooks/exhaustive-deps -- only reset when skuId changes + + const handleChange = (e: React.ChangeEvent) => { + const v = e.target.value; + const next = v === '' ? null : v; + if (next === selectedPlanId) return; + onSelectionChange(next); + }; + + // Hide while loading, on error, or when API returns no plans + if (loading || error || options.length === 0) { + return null; + } + + return ( +
+

+ Selling plan options +

+

+ Default is one-time purchase. Optionally choose a subscription plan. +

+ +
+ ); +} diff --git a/examples/nextjs/app/store/products.tsx b/examples/nextjs/app/store/products.tsx index dadce850..2aaf2d4f 100644 --- a/examples/nextjs/app/store/products.tsx +++ b/examples/nextjs/app/store/products.tsx @@ -2,6 +2,7 @@ import { ProductGrid, ProductSearch } from '@godaddy/react'; import { useCart } from './layout'; +import { getSellingSellingPlans } from './actions'; export default function ProductsPage() { const { openCart } = useCart(); @@ -14,6 +15,9 @@ export default function ProductsPage() { `/store/product/${sku}`} + getSellingPlans={(storeId, skuIds) => + getSellingSellingPlans(storeId, { skuIds }) + } onAddToCartSuccess={openCart} /> 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..40dde4fa 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 @@ -8,6 +8,7 @@ export interface AddToCartInput { name: string; quantity: number; productAssetUrl?: string; + sellingPlanId?: string | null; } export interface UseAddToCartOptions { @@ -67,7 +68,10 @@ export function useAddToCart(options?: UseAddToCartOptions) { status: 'DRAFT', details: { productAssetUrl: input.productAssetUrl || undefined, - }, + ...(input.sellingPlanId && { + sellingPlanId: input.sellingPlanId, + }), + } as Parameters[0]['details'], }, context.storeId!, context.clientId!, diff --git a/packages/react/src/components/storefront/product-card.tsx b/packages/react/src/components/storefront/product-card.tsx index 0829d9b5..10098d4e 100644 --- a/packages/react/src/components/storefront/product-card.tsx +++ b/packages/react/src/components/storefront/product-card.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { ChevronRight, Loader2, ShoppingBag } from 'lucide-react'; import type React from 'react'; +import { useState } from 'react'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; import { useAddToCart } from '@/components/storefront/hooks/use-add-to-cart'; import { Badge } from '@/components/ui/badge'; @@ -14,6 +15,12 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { getSkuGroup } from '@/lib/godaddy/godaddy'; import { SKUGroup } from '@/types.ts'; +/** Response shape: { groups?: Array<{ sellingPlans?: Array<{ planId?, id?, name?, displayName?, category? }> }> } */ +export type GetSellingPlansFn = ( + storeId: string, + skuIds: string[] +) => Promise<{ groups?: unknown[] }>; + interface ProductCardProps { product?: SKUGroup; productId?: string; @@ -21,10 +28,35 @@ interface ProductCardProps { clientId?: string; href?: string; getProductHref?: (productId: string) => string; + /** When provided, fetches selling plans for this card's SKU and shows a dropdown if any exist */ + getSellingPlans?: GetSellingPlansFn; onAddToCartSuccess?: () => void; onAddToCartError?: (error: Error) => void; } +function parseSellingPlansFromResponse(data: { groups?: unknown[] }): Array<{ planId: string; name: string; category?: string }> { + const list: Array<{ planId: string; name: string; category?: string }> = []; + const groups = Array.isArray(data?.groups) ? data.groups : []; + for (const group of groups) { + const g = group as { sellingPlans?: unknown[]; selling_plans?: unknown[] }; + const plans = g?.sellingPlans ?? g?.selling_plans ?? []; + if (!Array.isArray(plans)) continue; + for (const plan of plans) { + const p = plan as { planId?: string; id?: string; plan_id?: string; name?: string; displayName?: string; category?: string; planCategory?: string }; + const planId = p?.planId ?? p?.id ?? p?.plan_id; + const name = p?.name ?? p?.displayName ?? ''; + if (planId && name) { + list.push({ + planId: String(planId), + name: String(name), + category: p?.category ?? p?.planCategory, + }); + } + } + } + return list; +} + export function ProductCard({ product: productProp, productId, @@ -32,6 +64,7 @@ export function ProductCard({ clientId: clientIdProp, href: hrefProp, getProductHref, + getSellingPlans, onAddToCartSuccess, onAddToCartError, }: ProductCardProps) { @@ -41,6 +74,7 @@ export function ProductCard({ const formatCurrency = useFormatCurrency(); const storeId = storeIdProp || context.storeId; const clientId = clientIdProp || context.clientId; + const [selectedPlanId, setSelectedPlanId] = useState(null); // Fetch product by ID if productId is provided const { @@ -54,15 +88,23 @@ export function ProductCard({ enabled: !!productId && !!storeId && !!clientId && !productProp, }); - // Use shared add to cart hook + const product = productProp || fetchedProductData?.skuGroup; + const sellingPlanSkuId = + product?.skus?.edges?.[0]?.node?.id ?? product?.id ?? productId ?? ''; + + const { data: sellingPlansData } = useQuery({ + queryKey: ['selling-plans', storeId, sellingPlanSkuId], + queryFn: () => getSellingPlans!(storeId!, [sellingPlanSkuId]), + enabled: !!getSellingPlans && !!storeId && !!sellingPlanSkuId, + }); + + const sellingPlanOptions = sellingPlansData ? parseSellingPlansFromResponse(sellingPlansData) : []; + const { addToCart, isLoading: isAddingToCart } = useAddToCart({ onSuccess: onAddToCartSuccess, onError: onAddToCartError, }); - // Use fetched product or prop product - const product = productProp || fetchedProductData?.skuGroup; - // Compute href with priority: explicit href > getProductHref > no link const resolvedProductId = product?.id || productId; const href = @@ -141,6 +183,7 @@ export function ProductCard({ name: title, quantity: 1, productAssetUrl: imageUrl || undefined, + sellingPlanId: selectedPlanId ?? undefined, }); }; @@ -223,6 +266,32 @@ export function ProductCard({

{description}

+ {sellingPlanOptions.length > 0 && ( +
+ + +
+ )}
{/* TODO: Use dynamic currency from store/product when available instead of hardcoded USD */} diff --git a/packages/react/src/components/storefront/product-details.tsx b/packages/react/src/components/storefront/product-details.tsx index 7e072ca8..f2fc9404 100644 --- a/packages/react/src/components/storefront/product-details.tsx +++ b/packages/react/src/components/storefront/product-details.tsx @@ -27,6 +27,10 @@ interface ProductDetailsProps { clientId?: string; onAddToCartSuccess?: () => void; onAddToCartError?: (error: Error) => void; + /** Renders above the Add to Cart button; receives current matched SKU id (null when no single variant is selected). */ + childrenAboveAddToCart?: (props: { skuId: string | null }) => React.ReactNode; + /** When set, the selected selling plan id is sent with add-to-cart so the line item is bundled with that plan (e.g. subscription). Omit or null for one-time purchase. */ + selectedSellingPlanId?: string | null; } // Flattened attribute structure for UI (transforms edges/node to flat array) @@ -124,6 +128,8 @@ export function ProductDetails({ clientId: clientIdProp, onAddToCartSuccess, onAddToCartError, + childrenAboveAddToCart, + selectedSellingPlanId, }: ProductDetailsProps) { const context = useGoDaddyContext(); const { t } = context; @@ -407,6 +413,7 @@ export function ProductDetails({ name: title, quantity, productAssetUrl: images[0] || undefined, + sellingPlanId: selectedSellingPlanId ?? undefined, }); }; @@ -656,6 +663,14 @@ export function ProductDetails({
+ {childrenAboveAddToCart != null && ( +
+ {childrenAboveAddToCart({ + skuId: selectedSku?.id ?? matchedSkuId ?? null, + })} +
+ )} + {/* Add to Cart Button */}