Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions examples/nextjs/app/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@
import { createCheckoutSession } from '@godaddy/react/server';
import { redirect } from 'next/navigation';

/**
* Fetches selling plans for given SKU IDs from the external (or local) selling-plans API.
* Only runs when SELLING_PLANS_API_URL is set (e.g. in .env.local). If unset, returns
* empty so no request is made and no 404 is logged.
*/
export async function getSellingPlans(storeId: string, options: any) {
const base = process.env.SELLING_PLANS_API_URL;
if (!base) {
return null;
}
// API shape: GET /api/v1/selling-plans/{storeId}/groups?skuIds=...
const path = `/api/v1/selling-plans/${encodeURIComponent(storeId)}/groups`
const url = new URL(path, base);
for (const id of options.skuIds ?? []) {
url.searchParams.append('skuIds', id);
}
for (const id of options.skuGroupIds ?? []) {
url.searchParams.append('skuGroupIds', id);
}
url.searchParams.append('includes', 'catalogPrices');
url.searchParams.append('includes', 'allocations');
// url.searchParams.append('planStatus', 'ACTIVE'); // uncomment it when push to repo
try {
const res = await fetch(url.toString(), { cache: 'no-store' });
if (!res.ok) {
return null;
}
const data = await res.json();
return data.groups;
} catch {
// Network/socket errors (e.g. selling-plans API not running): fail silently so product page still works
return null;
}
}

export async function checkoutWithOrder(orderId: string) {
const session = await createCheckoutSession(
{
Expand Down
28 changes: 27 additions & 1 deletion examples/nextjs/app/store/product/[productId]/product.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@
import { ProductDetails } from '@godaddy/react';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { useState } from 'react';
import { SellingPlanDropdown } from '../selling-plan-dropdown';
import { useCart } from '../../layout';

/** Route param productId is the SKU Group id (product id from catalog skuGroups). */
export default function Product({ productId }: { productId: string }) {
const { openCart } = useCart();
const [selectedSellingPlanId, setSelectedSellingPlanId] = useState<string | null>(null);
const [selectedSellingPlan, setSelectedSellingPlan] = useState<any>(null);

const handleSellingPlanChange = (planId: string | null, plan: any) => {
setSelectedSellingPlanId(planId);
setSelectedSellingPlan(plan);
};

return (
<div className='container mx-auto'>
Expand All @@ -17,7 +27,23 @@ export default function Product({ productId }: { productId: string }) {
<ArrowLeft className='h-4 w-4' />
Back to Store
</Link>
<ProductDetails productId={productId} onAddToCartSuccess={openCart} />
<ProductDetails
productId={productId}
onAddToCartSuccess={openCart}
selectedSellingPlanId={selectedSellingPlanId}
selectedSellingPlan={selectedSellingPlan}
targets={{
'product-details.add-to-cart.before': ({ skuId, storeId }) => (
<SellingPlanDropdown
storeId={storeId ?? process.env.NEXT_PUBLIC_GODADDY_STORE_ID ?? ''}
skuId={skuId}
skuGroupId={productId}
selectedPlanId={selectedSellingPlanId}
onSelectionChange={handleSellingPlanChange}
/>
),
}}
/>
</div>
);
}
150 changes: 150 additions & 0 deletions examples/nextjs/app/store/product/selling-plan-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
'use client';

import { useCallback, useEffect, useState } from 'react';
import { getSellingPlans } from '../actions';

/**
* Resolve checkout price for the current SKU from the plan's catalogPrices.
* API shape: plan.catalogPrices[] has { skuId, checkoutPrices: [{ value, currency }] }.
* Picks the entry matching skuId and returns checkoutPrices[0] as { value, currencyCode }.
* Value is in minor units (cents).
*/
function normalizeCheckoutPrice(
plan: any,
skuId: string | null
): { value: number; currencyCode?: string } | undefined {
if (plan.checkoutPrice?.value != null) {
return {
value: Number(plan.checkoutPrice.value),
currencyCode: plan.checkoutPrice.currencyCode ?? plan.checkoutPrice.currency,
};
}
if (skuId && Array.isArray(plan.catalogPrices)) {
const forSku = plan.catalogPrices.find((c: any) => c.skuId === skuId);
const checkout = forSku?.checkoutPrices?.[0];
if (checkout?.value != null) {
return {
value: Number(checkout.value),
currencyCode: checkout.currencyCode ?? checkout.currency,
};
}
}
const first = plan.catalogPrices?.[0];
const checkoutFirst = first?.checkoutPrices?.[0];
if (checkoutFirst?.value != null) {
return {
value: Number(checkoutFirst.value),
currencyCode: checkoutFirst.currencyCode ?? checkoutFirst.currency,
};
}
if (plan.priceAtCheckout?.value != null) {
return {
value: Number(plan.priceAtCheckout.value),
currencyCode: plan.priceAtCheckout.currencyCode ?? plan.priceAtCheckout.currency,
};
}
return undefined;
}

export function SellingPlanDropdown({
storeId,
skuId,
skuGroupId,
selectedPlanId,
onSelectionChange,
}: {
storeId: string;
skuId: string | null;
skuGroupId: string | null;
selectedPlanId: string | null;
onSelectionChange: (planId: string | null, plan: any) => void;
}) {
const [plans, setPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(false);

/**
* Loads selling plans from the selling-plans API for the current skuId/skuGroupId.
*
* Production note: This triggers one API call per product (or per PDP visit). To reduce
* requests, consider a strategy to preload selling groups on the store list page: fetch
* groups for all visible skuIds and skuGroupIds once, pass the result (e.g. via context
* or cache) into this component.
*/
const loadPlans = useCallback(async () => {
if (!storeId || (!skuId && !skuGroupId)) {
setPlans([]);
return;
}
setLoading(true);
try {
const groups: any = await getSellingPlans(storeId, {
skuIds: skuId ? [skuId] : [],
skuGroupIds: skuGroupId ? [skuGroupId] : [],
}) ?? [];
// 1. Max 2 selling groups: one for skuId, one for skuGroupId
// 2. Each group has sellingPlans and allocations
// 3. Each allocation has resourceType (SKU | SKU_GROUP) and resourceId
// 4. Prefer group that matches skuId; else group that matches skuGroupId; else []
const forSku = groups?.find((g: any) =>
(g.allocations ?? []).some(
(a: any) => a.resourceType === 'SKU' && a.resourceId === skuId
)
);
const forSkuGroup = groups?.find((g: any) =>
(g.allocations ?? []).some(
(a: any) =>
a.resourceType === 'SKU_GROUP' && a.resourceId === skuGroupId
)
);
const sellingPlans = (forSku ?? forSkuGroup)?.sellingPlans ?? [];
setPlans(sellingPlans);
if (sellingPlans.length === 0) {
onSelectionChange(null, null);
}
} finally {
setLoading(false);
}
}, [storeId, skuId, skuGroupId, onSelectionChange]);

useEffect(() => {
loadPlans();
}, [loadPlans]);

if (loading || plans.length === 0) {
return null;
}

const value = selectedPlanId ?? '';

return (
<div className='space-y-2'>
<label className='text-sm font-medium text-foreground' htmlFor='selling-plan'>
Subscription
</label>
<select
id='selling-plan'
className='flex h-10 w-full items-center justify-between rounded-md border border-border bg-input px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring'
value={value}
onChange={e => {
const val = e.target.value;
const raw = val ? plans.find(p => p.planId === val) ?? null : null;
const plan = raw
? {
...raw,
checkoutPrice: normalizeCheckoutPrice(raw, skuId),
}
: null;
onSelectionChange(val || null, plan);
}}
>
<option value=''>One-time purchase</option>
{plans.map(p => (
<option key={p.planId} value={p.planId}>
{p.name ?? p.planId}
{p.category ? ` · ${p.category}` : ''}
</option>
))}
</select>
</div>
);
}
4 changes: 4 additions & 0 deletions examples/nextjs/env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type Product = Partial<SKUProduct> & {
addons?: SelectedAddon[];
selectedOptions?: SelectedOption[];
discounts?: ProductDiscount[];
/** Selling plan (subscription) from PDP selection; shown in cart. */
sellingPlan?: { name?: string; category?: string };
};

export interface DraftOrderLineItemsProps {
Expand Down Expand Up @@ -109,6 +111,12 @@ export function DraftOrderLineItems({
</span>
) : null}
</span>
{item.sellingPlan?.name ? (
<span className='text-xs text-muted-foreground'>
{item.sellingPlan.name}
{item.sellingPlan.category ? ` · ${item.sellingPlan.category}` : ''}
</span>
) : null}
<span className='text-xs grid'>
{item?.addons?.map((addon: SelectedAddon, index: number) => (
<span key={`addon-${index}`} className='text-xs'>
Expand Down
106 changes: 77 additions & 29 deletions packages/react/src/components/storefront/cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,43 +75,91 @@ export function Cart({

const { t } = useGoDaddyContext();

// Transform cart line items to Product format for CartLineItems component
// Transform cart line items to Product format for CartLineItems component.
// Selling plan JSON lives on line metafields (SELLING_PLAN); use its checkout price for display
// when present so the cart matches PDP, which may differ from draft line totals.subTotal.
const items: Product[] =
order?.lineItems?.map(item => ({
id: item.id,
name: item.name || t.storefront.product,
image: item.details?.productAssetUrl || '',
quantity: item.quantity || 0,
originalPrice: (item.totals?.subTotal?.value || 0) / (item.quantity || 1),
price: (item.totals?.subTotal?.value || 0) / (item.quantity || 1),
selectedOptions:
item?.details?.selectedOptions?.map(option => ({
attribute: option.attribute || '',
values: option.values || [],
})) || [],
addons: item.details?.selectedAddons?.map(addon => ({
attribute: addon.attribute || '',
sku: addon.sku || '',
values: addon.values?.map(value => ({
costAdjustment: value.costAdjustment
? {
currencyCode: value.costAdjustment.currencyCode ?? undefined,
value: value.costAdjustment.value ?? undefined,
}
: undefined,
name: value.name ?? undefined,
order?.lineItems?.map(item => {
const rawPlan = item.metafields?.find(m => m?.key === 'SELLING_PLAN')?.value;
let sellingPlan: { name?: string; category?: string } | null = null;
let checkoutPriceMinor: number | undefined;
if (rawPlan) {
try {
const parsed = JSON.parse(rawPlan) as {
name?: string;
category?: string;
catalogPrices?: Array<{
skuId?: string;
checkoutPrices?: Array<{ value?: number }>;
}>;
checkoutPrice?: { value?: number };
};
sellingPlan = { name: parsed.name, category: parsed.category };
const skuId = item.skuId;
const forSku = skuId
? parsed.catalogPrices?.find(c => c.skuId === skuId)
: undefined;
const cpFromCatalog = forSku?.checkoutPrices?.[0]?.value;
checkoutPriceMinor =
cpFromCatalog != null
? Number(cpFromCatalog)
: parsed.checkoutPrice?.value != null
? Number(parsed.checkoutPrice.value)
: undefined;
} catch {
sellingPlan = null;
}
}
const qty = item.quantity || 1;
const apiUnit =
qty > 0 ? (item.totals?.subTotal?.value || 0) / qty : 0;
const unitPrice = checkoutPriceMinor != null ? checkoutPriceMinor : apiUnit;
return {
id: item.id,
name: item.name || t.storefront.product,
image: item.details?.productAssetUrl || '',
quantity: item.quantity || 0,
originalPrice: unitPrice,
price: unitPrice,
selectedOptions:
item?.details?.selectedOptions?.map(option => ({
attribute: option.attribute || '',
values: option.values || [],
})) || [],
addons: item.details?.selectedAddons?.map(addon => ({
attribute: addon.attribute || '',
sku: addon.sku || '',
values: addon.values?.map(value => ({
costAdjustment: value.costAdjustment
? {
currencyCode: value.costAdjustment.currencyCode ?? undefined,
value: value.costAdjustment.value ?? undefined,
}
: undefined,
name: value.name ?? undefined,
})),
})),
})),
})) || [];
sellingPlan:
sellingPlan?.name != null || sellingPlan?.category != null
? { name: sellingPlan.name, category: sellingPlan.category }
: undefined,
};
}) || [];

// Calculate totals
// Calculate totals: subtotal matches sum of displayed line amounts (selling-plan metafield prices when set).
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
const currencyCode = order?.totals?.total?.currencyCode || 'USD';
const subtotal = order?.totals?.subTotal?.value || 0;
const apiSubtotal = order?.totals?.subTotal?.value || 0;
const lineSubtotalSum = items.reduce(
(sum, item) => sum + item.originalPrice * (item.quantity || 0),
0
);
const subtotal = lineSubtotalSum;
const shipping = order?.totals?.shippingTotal?.value || 0;
const taxes = order?.totals?.taxTotal?.value || 0;
const discount = order?.totals?.discountTotal?.value || 0;
const total = order?.totals?.total?.value || 0;
const apiTotal = order?.totals?.total?.value || 0;
const total = apiTotal + (lineSubtotalSum - apiSubtotal);

const totals = {
subtotal,
Expand Down
Loading
Loading