From 4d0f2380c28bab26babcee9390c66069f535392b Mon Sep 17 00:00:00 2001 From: hhuang2 Date: Tue, 3 Mar 2026 23:28:00 -0800 Subject: [PATCH 01/27] 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 23/27] 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]; From 57d611541a54999e4fae88bf018bf56e3638f4f4 Mon Sep 17 00:00:00 2001 From: Phil Bennett <114938978+pbennett1-godaddy@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:19:13 -0500 Subject: [PATCH 24/27] add commerce api skill (#1339) * add commerce api skill * add skills readme --- .changeset/petite-owls-yell.md | 5 + packages/react/README.md | 28 ++ packages/react/package.json | 8 +- packages/react/skills/commerce-api/SKILL.md | 407 ++++++++++++++++++++ 4 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 .changeset/petite-owls-yell.md create mode 100644 packages/react/skills/commerce-api/SKILL.md diff --git a/.changeset/petite-owls-yell.md b/.changeset/petite-owls-yell.md new file mode 100644 index 00000000..75be75eb --- /dev/null +++ b/.changeset/petite-owls-yell.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Add commerce api skill diff --git a/packages/react/README.md b/packages/react/README.md index 5762597d..c9a785bb 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -184,6 +184,34 @@ All fields are optional strings. Pass any subset to override the defaults. | `secondaryBackground` | Secondary background | | `secondaryForeground` | Text on secondary backgrounds | +## AI Agent Skills + +This package ships a [TanStack Intent](https://tanstack.com/intent/latest) skill that teaches AI coding agents how to connect to the GoDaddy Commerce GraphQL APIs (Orders, Catalog, Taxes, Price Adjustments). + +### Loading the skill + +Tell your agent: + +``` +Read node_modules/@godaddy/react/skills/commerce-api/SKILL.md and use it to connect to the GoDaddy Commerce APIs. +``` + +### Automatic discovery + +From your project directory, run: + +``` +npx @tanstack/intent@latest list +``` + +This will show the `commerce-api` skill and its path. To set up persistent skill-to-task mappings in your `AGENTS.md`, run: + +``` +npx @tanstack/intent@latest install +``` + +Then ask your agent to follow the instructions it outputs. + ## Codegen For now the schema will be downloaded from the order schema. diff --git a/packages/react/package.json b/packages/react/package.json index 1a34267e..8545e0d1 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -5,7 +5,8 @@ "type": "module", "types": "./dist/index.d.ts", "files": [ - "dist" + "dist", + "skills" ], "exports": { "./package.json": "./package.json", @@ -122,5 +123,8 @@ "repository": { "type": "git", "url": "git+https://github.com/godaddy/javascript.git" - } + }, + "keywords": [ + "tanstack-intent" + ] } diff --git a/packages/react/skills/commerce-api/SKILL.md b/packages/react/skills/commerce-api/SKILL.md new file mode 100644 index 00000000..755b83d5 --- /dev/null +++ b/packages/react/skills/commerce-api/SKILL.md @@ -0,0 +1,407 @@ +--- +name: commerce-api +description: > + Connect to GoDaddy Commerce Platform GraphQL APIs using OAuth2 + client credentials. Covers authentication via the /v2/oauth2/token + endpoint, GraphQL schema introspection, and usage of Orders, Catalog, + Taxes, and Price Adjustments subgraphs. Activate when an agent needs to + obtain an OAuth token with a client ID and secret, query order-subgraph, + catalog-subgraph, tax-subgraph, or price-adjustment-subgraph, calculate + taxes or price adjustments, or discover available schema fields via + introspection. +type: core +library: "@godaddy/react" +library_version: "1.0.32" +sources: + - "godaddy/javascript:packages/react/skills/commerce-api/SKILL.md" +--- + +# GoDaddy Commerce API Connection Guide + +## Setup + +Connecting to the GoDaddy Commerce Platform requires an OAuth client ID, +client secret, and a store ID (UUID). + +**Environments:** + +| Environment | API Host | Token Endpoint | +|-------------|-----------------------|-----------------------------------------------| +| ote | `api.ote-godaddy.com` | `https://api.ote-godaddy.com/v2/oauth2/token` | +| prod | `api.godaddy.com` | `https://api.godaddy.com/v2/oauth2/token` | + +**Obtain an OAuth token** using the client credentials grant with form +parameters: + +```typescript +async function getAccessToken(env: 'ote' | 'prod' = 'ote'): Promise { + const host = env === 'prod' ? 'api.godaddy.com' : 'api.ote-godaddy.com'; + const clientId = process.env.OAUTH_CLIENT_ID!; + const clientSecret = process.env.OAUTH_CLIENT_SECRET!; + + const response = await fetch(`https://${host}/v2/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'client_credentials', + scope: 'commerce.product:read commerce.product:write commerce.order:read', + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OAuth token request failed: ${response.status} — ${text}`); + } + + const data = await response.json(); + return data.access_token; // also: data.expires_in (seconds) +} +``` + +Tokens are short-lived (~1 hour). Cache and refresh before expiry. + +**GraphQL subgraph endpoints** — all four APIs share the same host: + +| API | Path | +|-------------------|-------------------------------------------------------------| +| Orders | `/v1/commerce/order-subgraph` | +| Catalog | `/v2/commerce/stores/{storeId}/catalog-subgraph` | +| Taxes | `/v2/commerce/stores/{storeId}/tax-subgraph` | +| Price Adjustments | `/v2/commerce/stores/{storeId}/price-adjustment-subgraph` | + +Full URL example (ote): `https://api.ote-godaddy.com/v2/commerce/stores/{storeId}/catalog-subgraph` + +**Required headers** for all subgraph requests: + +```typescript +const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'x-store-id': storeId, + 'user-agent': 'your-app/1.0.0 (GoDaddy Commerce Platform)', +}; +``` + +**OAuth scopes** are enforced at the operation level, not the endpoint +level. Any valid commerce token can reach any subgraph and introspect its +schema. Specific mutations/queries require the appropriate scope. + +| Scope | Purpose | +|--------------------------|----------------------------------| +| `commerce.product:read` | Read catalog/SKU data | +| `commerce.product:write` | Create and update catalog data | +| `commerce.order:read` | Read order data | + +Request only the scopes your application needs. If a scope is not +provisioned for your OAuth app, the token request returns `invalid_scope`. + +## Core Patterns + +### Introspect a GraphQL Schema + +Any valid token can introspect any subgraph to discover available types, +queries, and mutations. + +```typescript +async function introspectSchema(endpoint: string, token: string, storeId: string) { + const query = `{ + __schema { + queryType { name fields { name description } } + mutationType { name fields { name description } } + } + }`; + const res = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', + 'x-store-id': storeId, 'user-agent': 'introspection/1.0.0', + }, + body: JSON.stringify({ query }), + }); + return (await res.json()).data.__schema; +} + +const host = 'api.ote-godaddy.com'; +const schema = await introspectSchema( + `https://${host}/v2/commerce/stores/${storeId}/catalog-subgraph`, token, storeId +); +console.log('Queries:', schema.queryType.fields.map(f => f.name)); +console.log('Mutations:', schema.mutationType.fields.map(f => f.name)); +``` + +### Available Queries and Mutations (verified via introspection) + +**Orders** — 11 queries, 17 mutations: +- Queries: `orderById`, `orderByNumber`, `orderByExternalId`, `orders`, `ordersByIds`, `filterOrders`, `orderReturns`, `returnsByOrderId`, `previewReturn` +- Key mutations: `addDraftOrder`, `addLineItemBySkuId`, `updateDraftOrder`, `openOrder`, `applyDiscountCodes`, `updateOrder`, `completeOrder`, `cancelOrder`, `refundOrder` + +**Catalog** — 29 queries, 110 mutations: +- Queries: `sku`, `skus`, `skuGroup`, `skuGroups`, `attribute`, `attributes`, `list`, `lists`, `locations`, `inventoryCounts` +- Key mutations: `createSku`, `createSkuGroup`, `updateSku`, `addSkusToSkuGroup`, `createList`, `stockInventory`, `adjustInventory` + +**Taxes** — 8 queries, 39 mutations: +- Queries: `rate`, `rates`, `classification`, `classifications`, `jurisdiction`, `jurisdictions`, `override`, `overrides` +- Key mutations: `calculateTaxes`, `createRate`, `createClassification`, `createJurisdiction`, `createOverride` + +**Price Adjustments** — 8 queries, 36 mutations: +- Queries: `discount`, `discounts`, `discountCode`, `discountCodes`, `fee`, `fees`, `ruleset`, `rulesets` +- Key mutations: `calculateAdjustments`, `createDiscount`, `createDiscountCode`, `createFee`, `createRuleset` + +### Query Orders + +```typescript +import { GraphQLClient } from 'graphql-request'; + +const ordersClient = new GraphQLClient( + `https://api.ote-godaddy.com/v1/commerce/order-subgraph`, + { + headers: { + Authorization: `Bearer ${token}`, + 'X-Store-ID': storeId, + 'user-agent': 'my-app/1.0.0 (GoDaddy Commerce Platform)', + }, + } +); + +const order = await ordersClient.request(` + query OrderById($id: ID!) { + orderById(id: $id) { + id number numberDisplay + statuses { status paymentStatus fulfillmentStatus } + totals { + subTotal { value currencyCode } + shippingTotal { value currencyCode } + taxTotal { value currencyCode } + discountTotal { value currencyCode } + total { value currencyCode } + } + lineItems { edges { node { id name quantity unitPrice { value currencyCode } } } } + } + } +`, { id: orderId }); +``` + +### Query Catalog + +```typescript +const catalogClient = new GraphQLClient( + `https://api.ote-godaddy.com/v2/commerce/stores/${storeId}/catalog-subgraph`, + { + headers: { + Authorization: `Bearer ${token}`, + 'x-store-id': storeId, + 'user-agent': 'my-app/1.0.0 (GoDaddy Commerce Platform)', + }, + } +); + +const skus = await catalogClient.request(` + query GetSkus($first: Int, $after: String) { + skus(first: $first, after: $after) { + totalCount + pageInfo { hasNextPage endCursor } + edges { + node { + id name label code status + prices { edges { node { value { currencyCode value } } } } + } + } + } + } +`, { first: 20 }); +``` + +### Calculate Taxes (GraphQL) + +```typescript +const taxClient = new GraphQLClient( + `https://api.ote-godaddy.com/v2/commerce/stores/${storeId}/tax-subgraph`, + { + headers: { + Authorization: `Bearer ${token}`, + 'x-store-id': storeId, + 'user-agent': 'my-app/1.0.0 (GoDaddy Commerce Platform)', + }, + } +); + +const taxes = await taxClient.request(` + mutation CalculateTaxes($input: CalculateTaxesInput!) { + calculateTaxes(input: $input) { + totalTaxAmount { value currencyCode } + taxAmounts { totalTaxAmount { value currencyCode } rate { name label } } + lines { calculationLine { id } totalTaxAmount { value currencyCode } } + } + } +`, { + input: { + destination: { + address: { postalCode: '85001', countryCode: 'US', adminArea1: 'AZ' }, + }, + lines: [ + { id: 'line-1', type: 'SKU', quantity: 1, + subtotalPrice: { value: 1999, currencyCode: 'USD' } }, + ], + }, +}); +``` + +### Calculate Price Adjustments (GraphQL) + +```typescript +const adjustClient = new GraphQLClient( + `https://api.ote-godaddy.com/v2/commerce/stores/${storeId}/price-adjustment-subgraph`, + { + headers: { + Authorization: `Bearer ${token}`, + 'x-store-id': storeId, + 'user-agent': 'my-app/1.0.0 (GoDaddy Commerce Platform)', + }, + } +); + +const adjustments = await adjustClient.request(` + mutation CalculateAdjustments($input: CalculateAdjustmentsInput!) { + calculateAdjustments(input: $input) { + totalDiscountAmount { value currencyCode } + totalFeeAmount { value currencyCode } + lines { calculationLine { id } totalDiscountAmount { value currencyCode } } + } + } +`, { + input: { + discountCodes: ['SUMMER20'], + lines: [ + { id: 'sku-123', type: 'SKU', quantity: 2, + subtotalPrice: { value: 4998, currencyCode: 'USD' } }, + ], + }, +}); +``` + +## Common Mistakes + +### CRITICAL Missing Bearer prefix in Authorization header + +Wrong: + +```typescript +headers: { 'Authorization': token } +``` + +Correct: + +```typescript +headers: { 'Authorization': `Bearer ${token}` } +``` + +The API returns 401 if the `Bearer ` prefix is missing. This is a silent +failure when error handling swallows the status code. + +### CRITICAL Using wrong token endpoint URL + +Wrong: + +```typescript +const tokenUrl = 'https://sso.godaddy.com/v1/token'; +``` + +Correct: + +```typescript +const tokenUrl = 'https://api.godaddy.com/v2/oauth2/token'; +``` + +The OAuth token endpoint is `/v2/oauth2/token` on the **API host** +(`api.godaddy.com` or `api.ote-godaddy.com`). Using any other host or +path returns 404 or 405. + +### HIGH Subgraph URL missing storeId in path + +Wrong: + +```typescript +const url = `https://api.ote-godaddy.com/v2/commerce/stores/catalog-subgraph`; +``` + +Correct: + +```typescript +const url = `https://api.ote-godaddy.com/v2/commerce/stores/${storeId}/catalog-subgraph`; +``` + +The Catalog, Tax, and Price Adjustment subgraph URLs all require +`stores/{storeId}` in the path. Omitting it returns 404. The Orders +subgraph (`/v1/commerce/order-subgraph`) does not require storeId in +the path — it uses the `X-Store-ID` header and GraphQL variables instead. + +### HIGH Omitting scope in token request + +Wrong: + +```typescript +body: new URLSearchParams({ client_id: id, client_secret: secret, grant_type: 'client_credentials' }) +``` + +Correct: + +```typescript +body: new URLSearchParams({ + client_id: id, client_secret: secret, + grant_type: 'client_credentials', + scope: 'commerce.product:read commerce.order:read', +}) +``` + +Omitting `scope` may return a token without commerce permissions, causing +403 Forbidden on API calls despite having a valid token. Requesting a scope +not provisioned for your OAuth app returns an `invalid_scope` error. + +### MEDIUM Using expired token without refresh + +Wrong: + +```typescript +const token = await getAccessToken(); +// reuse for hours without checking expiry +``` + +Correct: + +```typescript +let cached = { token: '', expiresAt: 0 }; +async function getValidToken(): Promise { + if (Date.now() < cached.expiresAt - 60_000) return cached.token; + const res = await fetchNewToken(); + cached = { token: res.access_token, expiresAt: Date.now() + res.expires_in * 1000 }; + return cached.token; +} +``` + +Tokens expire in ~1 hour. Cache the token and refresh with a 1-minute +buffer before `expires_in` elapses. After expiry, requests fail with 401. + +### MEDIUM Not checking order status before calling draft mutations + +Wrong: + +```typescript +// Blindly calling updateDraftOrder without knowing current status +await ordersClient.request(UPDATE_DRAFT_ORDER, { input: { id: someOrderId } }); +``` + +Correct: + +```typescript +const order = await ordersClient.request(GET_ORDER, { id: orderId }); +// Verify the order is in the expected state before mutating +console.log('Order status:', order.orderById.statuses.status); +await ordersClient.request(UPDATE_DRAFT_ORDER, { input: { id: orderId } }); +``` + +Mutations like `updateDraftOrder`, `addLineItemBySkuId`, and +`deleteLineItemById` are designed for draft orders. Query the order +first to confirm its status before attempting mutations to avoid +unexpected errors. From 91b91296a9121bbf8bdec08b8597cc6453efedfd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:23:17 -0400 Subject: [PATCH 25/27] Version Packages (#1340) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/petite-owls-yell.md | 5 ----- examples/nextjs/CHANGELOG.md | 7 +++++++ examples/nextjs/package.json | 2 +- packages/react/CHANGELOG.md | 6 ++++++ packages/react/package.json | 2 +- 5 files changed, 15 insertions(+), 7 deletions(-) delete mode 100644 .changeset/petite-owls-yell.md diff --git a/.changeset/petite-owls-yell.md b/.changeset/petite-owls-yell.md deleted file mode 100644 index 75be75eb..00000000 --- a/.changeset/petite-owls-yell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@godaddy/react": patch ---- - -Add commerce api skill diff --git a/examples/nextjs/CHANGELOG.md b/examples/nextjs/CHANGELOG.md index fee6962f..0399f99a 100644 --- a/examples/nextjs/CHANGELOG.md +++ b/examples/nextjs/CHANGELOG.md @@ -1,5 +1,12 @@ # nextjs +## 0.1.32 + +### Patch Changes + +- Updated dependencies [57d6115] + - @godaddy/react@1.0.34 + ## 0.1.31 ### Patch Changes diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 152d54bb..940e7396 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "nextjs", - "version": "0.1.31", + "version": "0.1.32", "private": true, "scripts": { "dev": "next dev", diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index a9bba587..760c0d38 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,11 @@ # @godaddy/react +## 1.0.34 + +### Patch Changes + +- 57d6115: Add commerce api skill + ## 1.0.33 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index 8545e0d1..85060768 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,7 +1,7 @@ { "name": "@godaddy/react", "private": false, - "version": "1.0.33", + "version": "1.0.34", "type": "module", "types": "./dist/index.d.ts", "files": [ From 8d3f2c22058ee5a2109813cfc1f287fe94f7340c Mon Sep 17 00:00:00 2001 From: Phil Bennett <114938978+pbennett1-godaddy@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:49:40 -0500 Subject: [PATCH 26/27] Update commerce-api skill to utilize godaddy cli (#1341) * Update commerce-api skill to utilize godaddy cli * update readme --- .changeset/purple-aliens-chew.md | 5 + packages/react/README.md | 4 +- packages/react/skills/commerce-api/SKILL.md | 316 +++++++------------- 3 files changed, 111 insertions(+), 214 deletions(-) create mode 100644 .changeset/purple-aliens-chew.md diff --git a/.changeset/purple-aliens-chew.md b/.changeset/purple-aliens-chew.md new file mode 100644 index 00000000..173d570f --- /dev/null +++ b/.changeset/purple-aliens-chew.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Update commerce api skill to utilize godaddy cli as api source diff --git a/packages/react/README.md b/packages/react/README.md index c9a785bb..2047b067 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -186,14 +186,14 @@ All fields are optional strings. Pass any subset to override the defaults. ## AI Agent Skills -This package ships a [TanStack Intent](https://tanstack.com/intent/latest) skill that teaches AI coding agents how to connect to the GoDaddy Commerce GraphQL APIs (Orders, Catalog, Taxes, Price Adjustments). +This package ships a [TanStack Intent](https://tanstack.com/intent/latest) skill that teaches AI coding agents how to authenticate with the GoDaddy Commerce Platform using OAuth2 client credentials and create checkout sessions. For API discovery and testing, the skill directs agents to use [`@godaddy/cli`](https://www.npmjs.com/package/@godaddy/cli). ### Loading the skill Tell your agent: ``` -Read node_modules/@godaddy/react/skills/commerce-api/SKILL.md and use it to connect to the GoDaddy Commerce APIs. +Read node_modules/@godaddy/react/skills/commerce-api/SKILL.md and use it to authenticate with the GoDaddy Commerce APIs. ``` ### Automatic discovery diff --git a/packages/react/skills/commerce-api/SKILL.md b/packages/react/skills/commerce-api/SKILL.md index 755b83d5..c6e5e524 100644 --- a/packages/react/skills/commerce-api/SKILL.md +++ b/packages/react/skills/commerce-api/SKILL.md @@ -1,22 +1,21 @@ --- name: commerce-api description: > - Connect to GoDaddy Commerce Platform GraphQL APIs using OAuth2 - client credentials. Covers authentication via the /v2/oauth2/token - endpoint, GraphQL schema introspection, and usage of Orders, Catalog, - Taxes, and Price Adjustments subgraphs. Activate when an agent needs to - obtain an OAuth token with a client ID and secret, query order-subgraph, - catalog-subgraph, tax-subgraph, or price-adjustment-subgraph, calculate - taxes or price adjustments, or discover available schema fields via - introspection. + Authenticate with the GoDaddy Commerce Platform using OAuth2 client + credentials or JWT grants. Covers the /v2/oauth2/token endpoint, + environments (ote, prod), required headers, and scopes. For API + discovery, schema introspection, and testing use @godaddy/cli + (godaddy api list, godaddy api describe, godaddy api call). For + checkout session creation use the Checkout API. Activate when an + agent needs to obtain an OAuth token, configure commerce API auth, + create checkout sessions, or discover available commerce endpoints. type: core library: "@godaddy/react" -library_version: "1.0.32" sources: - "godaddy/javascript:packages/react/skills/commerce-api/SKILL.md" --- -# GoDaddy Commerce API Connection Guide +# GoDaddy Commerce API — Authentication & Discovery ## Setup @@ -62,18 +61,7 @@ async function getAccessToken(env: 'ote' | 'prod' = 'ote'): Promise { Tokens are short-lived (~1 hour). Cache and refresh before expiry. -**GraphQL subgraph endpoints** — all four APIs share the same host: - -| API | Path | -|-------------------|-------------------------------------------------------------| -| Orders | `/v1/commerce/order-subgraph` | -| Catalog | `/v2/commerce/stores/{storeId}/catalog-subgraph` | -| Taxes | `/v2/commerce/stores/{storeId}/tax-subgraph` | -| Price Adjustments | `/v2/commerce/stores/{storeId}/price-adjustment-subgraph` | - -Full URL example (ote): `https://api.ote-godaddy.com/v2/commerce/stores/{storeId}/catalog-subgraph` - -**Required headers** for all subgraph requests: +**Required headers** for all API requests: ```typescript const headers = { @@ -84,9 +72,9 @@ const headers = { }; ``` -**OAuth scopes** are enforced at the operation level, not the endpoint -level. Any valid commerce token can reach any subgraph and introspect its -schema. Specific mutations/queries require the appropriate scope. +**OAuth scopes** — request only the scopes your application needs. If a +scope is not provisioned for your OAuth app, the token request returns +`invalid_scope`. | Scope | Purpose | |--------------------------|----------------------------------| @@ -94,130 +82,52 @@ schema. Specific mutations/queries require the appropriate scope. | `commerce.product:write` | Create and update catalog data | | `commerce.order:read` | Read order data | -Request only the scopes your application needs. If a scope is not -provisioned for your OAuth app, the token request returns `invalid_scope`. - ## Core Patterns -### Introspect a GraphQL Schema +### Discover APIs with @godaddy/cli -Any valid token can introspect any subgraph to discover available types, -queries, and mutations. - -```typescript -async function introspectSchema(endpoint: string, token: string, storeId: string) { - const query = `{ - __schema { - queryType { name fields { name description } } - mutationType { name fields { name description } } - } - }`; - const res = await fetch(endpoint, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'x-store-id': storeId, 'user-agent': 'introspection/1.0.0', - }, - body: JSON.stringify({ query }), - }); - return (await res.json()).data.__schema; -} +Use the `@godaddy/cli` package to discover available endpoints, inspect +schemas, and test API calls. Install globally: -const host = 'api.ote-godaddy.com'; -const schema = await introspectSchema( - `https://${host}/v2/commerce/stores/${storeId}/catalog-subgraph`, token, storeId -); -console.log('Queries:', schema.queryType.fields.map(f => f.name)); -console.log('Mutations:', schema.mutationType.fields.map(f => f.name)); +```bash +npm install -g @godaddy/cli ``` -### Available Queries and Mutations (verified via introspection) - -**Orders** — 11 queries, 17 mutations: -- Queries: `orderById`, `orderByNumber`, `orderByExternalId`, `orders`, `ordersByIds`, `filterOrders`, `orderReturns`, `returnsByOrderId`, `previewReturn` -- Key mutations: `addDraftOrder`, `addLineItemBySkuId`, `updateDraftOrder`, `openOrder`, `applyDiscountCodes`, `updateOrder`, `completeOrder`, `cancelOrder`, `refundOrder` +Discover available API domains and endpoints: -**Catalog** — 29 queries, 110 mutations: -- Queries: `sku`, `skus`, `skuGroup`, `skuGroups`, `attribute`, `attributes`, `list`, `lists`, `locations`, `inventoryCounts` -- Key mutations: `createSku`, `createSkuGroup`, `updateSku`, `addSkusToSkuGroup`, `createList`, `stockInventory`, `adjustInventory` +```bash +# List all API domains +godaddy api list -**Taxes** — 8 queries, 39 mutations: -- Queries: `rate`, `rates`, `classification`, `classifications`, `jurisdiction`, `jurisdictions`, `override`, `overrides` -- Key mutations: `calculateTaxes`, `createRate`, `createClassification`, `createJurisdiction`, `createOverride` +# List endpoints in a specific domain +godaddy api list --domain catalog-products +godaddy api list --domain orders -**Price Adjustments** — 8 queries, 36 mutations: -- Queries: `discount`, `discounts`, `discountCode`, `discountCodes`, `fee`, `fees`, `ruleset`, `rulesets` -- Key mutations: `calculateAdjustments`, `createDiscount`, `createDiscountCode`, `createFee`, `createRuleset` +# Search for endpoints by keyword +godaddy api search checkout +godaddy api search tax -### Query Orders +# Describe an endpoint's schema, parameters, and scopes +godaddy api describe /location/addresses -```typescript -import { GraphQLClient } from 'graphql-request'; - -const ordersClient = new GraphQLClient( - `https://api.ote-godaddy.com/v1/commerce/order-subgraph`, - { - headers: { - Authorization: `Bearer ${token}`, - 'X-Store-ID': storeId, - 'user-agent': 'my-app/1.0.0 (GoDaddy Commerce Platform)', - }, - } -); - -const order = await ordersClient.request(` - query OrderById($id: ID!) { - orderById(id: $id) { - id number numberDisplay - statuses { status paymentStatus fulfillmentStatus } - totals { - subTotal { value currencyCode } - shippingTotal { value currencyCode } - taxTotal { value currencyCode } - discountTotal { value currencyCode } - total { value currencyCode } - } - lineItems { edges { node { id name quantity unitPrice { value currencyCode } } } } - } - } -`, { id: orderId }); +# Make an authenticated API call +godaddy api call /v1/commerce/catalog/products +godaddy api call /v1/commerce/orders -s commerce.order:read ``` -### Query Catalog +All CLI commands return structured JSON with `next_actions` that suggest +what to run next. Use `godaddy api describe` to inspect request/response +schemas and required scopes before implementing API calls in code. -```typescript -const catalogClient = new GraphQLClient( - `https://api.ote-godaddy.com/v2/commerce/stores/${storeId}/catalog-subgraph`, - { - headers: { - Authorization: `Bearer ${token}`, - 'x-store-id': storeId, - 'user-agent': 'my-app/1.0.0 (GoDaddy Commerce Platform)', - }, - } -); - -const skus = await catalogClient.request(` - query GetSkus($first: Int, $after: String) { - skus(first: $first, after: $after) { - totalCount - pageInfo { hasNextPage endCursor } - edges { - node { - id name label code status - prices { edges { node { value { currencyCode value } } } } - } - } - } - } -`, { first: 20 }); -``` +### Create and Update Checkout Sessions -### Calculate Taxes (GraphQL) +The Checkout API uses a dedicated host: `checkout.commerce.api.{host}` ```typescript -const taxClient = new GraphQLClient( - `https://api.ote-godaddy.com/v2/commerce/stores/${storeId}/tax-subgraph`, +import { GraphQLClient } from 'graphql-request'; + +const checkoutClient = new GraphQLClient( + `https://checkout.commerce.api.ote-godaddy.com/`, { headers: { Authorization: `Bearer ${token}`, @@ -227,60 +137,73 @@ const taxClient = new GraphQLClient( } ); -const taxes = await taxClient.request(` - mutation CalculateTaxes($input: CalculateTaxesInput!) { - calculateTaxes(input: $input) { - totalTaxAmount { value currencyCode } - taxAmounts { totalTaxAmount { value currencyCode } rate { name label } } - lines { calculationLine { id } totalTaxAmount { value currencyCode } } +const session = await checkoutClient.request(` + mutation CreateCheckoutSession($input: MutationCreateCheckoutSessionInput!) { + createCheckoutSession(input: $input) { + id url status expiresAt + draftOrder { + id number + totals { total { value currencyCode } } + lineItems { edges { node { id name quantity unitPrice { value currencyCode } } } } + } } } `, { input: { - destination: { - address: { postalCode: '85001', countryCode: 'US', adminArea1: 'AZ' }, - }, - lines: [ - { id: 'line-1', type: 'SKU', quantity: 1, - subtotalPrice: { value: 1999, currencyCode: 'USD' } }, + storeId, + returnUrl: 'https://example.com/cart', + successUrl: 'https://example.com/thank-you', + lineItems: [ + { skuId: 'sku-123', quantity: 1 }, ], }, }); -``` -### Calculate Price Adjustments (GraphQL) - -```typescript -const adjustClient = new GraphQLClient( - `https://api.ote-godaddy.com/v2/commerce/stores/${storeId}/price-adjustment-subgraph`, - { - headers: { - Authorization: `Bearer ${token}`, - 'x-store-id': storeId, - 'user-agent': 'my-app/1.0.0 (GoDaddy Commerce Platform)', - }, - } -); - -const adjustments = await adjustClient.request(` - mutation CalculateAdjustments($input: CalculateAdjustmentsInput!) { - calculateAdjustments(input: $input) { - totalDiscountAmount { value currencyCode } - totalFeeAmount { value currencyCode } - lines { calculationLine { id } totalDiscountAmount { value currencyCode } } +const updated = await checkoutClient.request(` + mutation UpdateCheckoutSession($id: String!, $input: MutationUpdateCheckoutSessionInput!) { + updateCheckoutSession(id: $id, input: $input) { + id status } } `, { + id: session.createCheckoutSession.id, input: { - discountCodes: ['SUMMER20'], - lines: [ - { id: 'sku-123', type: 'SKU', quantity: 2, - subtotalPrice: { value: 4998, currencyCode: 'USD' } }, - ], + enablePromotionCodes: true, + enableShipping: true, + enableTaxCollection: true, }, }); ``` +### Token Caching + +```typescript +let cached = { token: '', expiresAt: 0 }; + +async function getValidToken(env: 'ote' | 'prod' = 'ote'): Promise { + if (Date.now() < cached.expiresAt - 60_000) return cached.token; + const host = env === 'prod' ? 'api.godaddy.com' : 'api.ote-godaddy.com'; + + const response = await fetch(`https://${host}/v2/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: process.env.OAUTH_CLIENT_ID!, + client_secret: process.env.OAUTH_CLIENT_SECRET!, + grant_type: 'client_credentials', + scope: 'commerce.product:read commerce.order:read', + }), + }); + + const data = await response.json(); + cached = { + token: data.access_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; + return cached.token; +} +``` + ## Common Mistakes ### CRITICAL Missing Bearer prefix in Authorization header @@ -318,25 +241,6 @@ The OAuth token endpoint is `/v2/oauth2/token` on the **API host** (`api.godaddy.com` or `api.ote-godaddy.com`). Using any other host or path returns 404 or 405. -### HIGH Subgraph URL missing storeId in path - -Wrong: - -```typescript -const url = `https://api.ote-godaddy.com/v2/commerce/stores/catalog-subgraph`; -``` - -Correct: - -```typescript -const url = `https://api.ote-godaddy.com/v2/commerce/stores/${storeId}/catalog-subgraph`; -``` - -The Catalog, Tax, and Price Adjustment subgraph URLs all require -`stores/{storeId}` in the path. Omitting it returns 404. The Orders -subgraph (`/v1/commerce/order-subgraph`) does not require storeId in -the path — it uses the `X-Store-ID` header and GraphQL variables instead. - ### HIGH Omitting scope in token request Wrong: @@ -359,49 +263,37 @@ Omitting `scope` may return a token without commerce permissions, causing 403 Forbidden on API calls despite having a valid token. Requesting a scope not provisioned for your OAuth app returns an `invalid_scope` error. -### MEDIUM Using expired token without refresh +### HIGH Checkout API uses a different host than subgraph APIs Wrong: ```typescript -const token = await getAccessToken(); -// reuse for hours without checking expiry +const url = `https://api.ote-godaddy.com/checkout`; ``` Correct: ```typescript -let cached = { token: '', expiresAt: 0 }; -async function getValidToken(): Promise { - if (Date.now() < cached.expiresAt - 60_000) return cached.token; - const res = await fetchNewToken(); - cached = { token: res.access_token, expiresAt: Date.now() + res.expires_in * 1000 }; - return cached.token; -} +const url = `https://checkout.commerce.api.ote-godaddy.com/`; ``` -Tokens expire in ~1 hour. Cache the token and refresh with a 1-minute -buffer before `expires_in` elapses. After expiry, requests fail with 401. +The Checkout API uses a dedicated subdomain (`checkout.commerce.api.{host}`), +not a path on the standard API host. Using the wrong host returns 404. -### MEDIUM Not checking order status before calling draft mutations +### MEDIUM Using expired token without refresh Wrong: ```typescript -// Blindly calling updateDraftOrder without knowing current status -await ordersClient.request(UPDATE_DRAFT_ORDER, { input: { id: someOrderId } }); +const token = await getAccessToken(); +// reuse for hours without checking expiry ``` Correct: ```typescript -const order = await ordersClient.request(GET_ORDER, { id: orderId }); -// Verify the order is in the expected state before mutating -console.log('Order status:', order.orderById.statuses.status); -await ordersClient.request(UPDATE_DRAFT_ORDER, { input: { id: orderId } }); +const token = await getValidToken(); // see Token Caching pattern above ``` -Mutations like `updateDraftOrder`, `addLineItemBySkuId`, and -`deleteLineItemById` are designed for draft orders. Query the order -first to confirm its status before attempting mutations to avoid -unexpected errors. +Tokens expire in ~1 hour. Cache the token and refresh with a 1-minute +buffer before `expires_in` elapses. After expiry, requests fail with 401. From 52bc89b78bcfc95c1e9188678058739c80297faf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:12:30 -0400 Subject: [PATCH 27/27] Version Packages (#1342) --- .changeset/purple-aliens-chew.md | 5 ----- examples/nextjs/CHANGELOG.md | 7 +++++++ examples/nextjs/package.json | 2 +- packages/react/CHANGELOG.md | 6 ++++++ packages/react/package.json | 2 +- 5 files changed, 15 insertions(+), 7 deletions(-) delete mode 100644 .changeset/purple-aliens-chew.md diff --git a/.changeset/purple-aliens-chew.md b/.changeset/purple-aliens-chew.md deleted file mode 100644 index 173d570f..00000000 --- a/.changeset/purple-aliens-chew.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@godaddy/react": patch ---- - -Update commerce api skill to utilize godaddy cli as api source diff --git a/examples/nextjs/CHANGELOG.md b/examples/nextjs/CHANGELOG.md index 0399f99a..957ca160 100644 --- a/examples/nextjs/CHANGELOG.md +++ b/examples/nextjs/CHANGELOG.md @@ -1,5 +1,12 @@ # nextjs +## 0.1.33 + +### Patch Changes + +- Updated dependencies [8d3f2c2] + - @godaddy/react@1.0.35 + ## 0.1.32 ### Patch Changes diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 940e7396..6b0c7c7e 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "nextjs", - "version": "0.1.32", + "version": "0.1.33", "private": true, "scripts": { "dev": "next dev", diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 760c0d38..328609d8 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,11 @@ # @godaddy/react +## 1.0.35 + +### Patch Changes + +- 8d3f2c2: Update commerce api skill to utilize godaddy cli as api source + ## 1.0.34 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index 85060768..8c601f78 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,7 +1,7 @@ { "name": "@godaddy/react", "private": false, - "version": "1.0.34", + "version": "1.0.35", "type": "module", "types": "./dist/index.d.ts", "files": [