From a9f676bb8561c2d70e483d070403642aaae0376d Mon Sep 17 00:00:00 2001 From: Wes Cole Date: Thu, 6 Nov 2025 23:29:37 -0500 Subject: [PATCH 01/21] Add storefront product grid with image support - Add ProductGrid and ProductCard components for displaying SKU groups - Add Badge and Card UI components - Update GraphQL queries to fetch SKU groups with mediaObjects - Display product images from mediaObjects (IMAGE type) - Add formatCurrency utility function - Create example store page in Next.js app - Add providers setup for React Query Amp-Thread-ID: https://ampcode.com/threads/T-65ac7bb2-a629-4360-a457-dddb0eb691c8 Co-authored-by: Amp --- examples/nextjs/app/layout.tsx | 3 +- examples/nextjs/app/providers.tsx | 24 + examples/nextjs/app/store/page.tsx | 9 + examples/nextjs/app/store/products.tsx | 11 + .../checkout-buttons/credit-card/stripe.tsx | 2 + .../react/src/components/storefront/index.ts | 2 + .../components/storefront/product-card.tsx | 107 ++ .../components/storefront/product-grid.tsx | 44 + packages/react/src/components/ui/badge.tsx | 35 + packages/react/src/components/ui/card.tsx | 85 ++ packages/react/src/index.ts | 1 + packages/react/src/lib/godaddy/godaddy.ts | 27 + packages/react/src/lib/godaddy/graphql-env.ts | 1021 ++++++++++++++++- packages/react/src/lib/godaddy/queries.ts | 43 + packages/react/src/lib/utils.ts | 13 + 15 files changed, 1403 insertions(+), 24 deletions(-) create mode 100644 examples/nextjs/app/providers.tsx create mode 100644 examples/nextjs/app/store/page.tsx create mode 100644 examples/nextjs/app/store/products.tsx create mode 100644 packages/react/src/components/storefront/index.ts create mode 100644 packages/react/src/components/storefront/product-card.tsx create mode 100644 packages/react/src/components/storefront/product-grid.tsx create mode 100644 packages/react/src/components/ui/badge.tsx create mode 100644 packages/react/src/components/ui/card.tsx diff --git a/examples/nextjs/app/layout.tsx b/examples/nextjs/app/layout.tsx index d1cb7eeb..95e2a478 100644 --- a/examples/nextjs/app/layout.tsx +++ b/examples/nextjs/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; import '@godaddy/react/styles.css'; +import { Providers } from './providers'; const geistSans = Geist({ variable: '--font-geist-sans', @@ -28,7 +29,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/examples/nextjs/app/providers.tsx b/examples/nextjs/app/providers.tsx new file mode 100644 index 00000000..63430e8b --- /dev/null +++ b/examples/nextjs/app/providers.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState } from 'react'; + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, + }) + ); + + return ( + + {children} + + ); +} diff --git a/examples/nextjs/app/store/page.tsx b/examples/nextjs/app/store/page.tsx new file mode 100644 index 00000000..06f1babe --- /dev/null +++ b/examples/nextjs/app/store/page.tsx @@ -0,0 +1,9 @@ +import Products from './products'; + +export default function StorePage() { + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/examples/nextjs/app/store/products.tsx b/examples/nextjs/app/store/products.tsx new file mode 100644 index 00000000..189b7d67 --- /dev/null +++ b/examples/nextjs/app/store/products.tsx @@ -0,0 +1,11 @@ +'use client' + +import { ProductGrid } from '@godaddy/react'; + +export default function ProductsPage() { + return ( +
+ +
+ ) +} diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx index 3c489b98..1ab2a598 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; diff --git a/packages/react/src/components/storefront/index.ts b/packages/react/src/components/storefront/index.ts new file mode 100644 index 00000000..56d46cfd --- /dev/null +++ b/packages/react/src/components/storefront/index.ts @@ -0,0 +1,2 @@ +export * from './product-card'; +export * from './product-grid'; diff --git a/packages/react/src/components/storefront/product-card.tsx b/packages/react/src/components/storefront/product-card.tsx new file mode 100644 index 00000000..01e4cd1a --- /dev/null +++ b/packages/react/src/components/storefront/product-card.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { ChevronRight, ShoppingBag } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { formatCurrency } from '@/lib/utils'; + +interface SkuGroup { + id: string; + name?: string; + label?: string; + description?: string; + htmlDescription?: string; + status?: string; + type?: string; + priceRange?: { + min?: number; + max?: number; + }; + compareAtPriceRange?: { + min?: number; + max?: number; + }; + mediaObjects?: { + edges?: Array<{ + node?: { + url?: string; + type?: string; + }; + }>; + }; +} + +interface ProductCardProps { + product: SkuGroup; +} + +export function ProductCard({ product }: ProductCardProps) { + const title = product.label || product.name || 'Product'; + const description = product.description || ''; + const priceMin = product.priceRange?.min || 0; + const priceMax = product.priceRange?.max || priceMin; + const compareAtMin = product.compareAtPriceRange?.min; + const isOnSale = compareAtMin && compareAtMin > priceMin; + const hasOptions = false; + const isPriceRange = priceMin !== priceMax; + + const imageUrl = product.mediaObjects?.edges?.find( + edge => edge?.node?.type === 'IMAGE' + )?.node?.url; + + const handleAddToCart = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + return ( + +
+ {isOnSale && ( + + SALE + + )} + {imageUrl ? ( + {title} + ) : ( +
+ No image +
+ )} +
+
+

+ {title} +

+

+ {description} +

+
+ + {isPriceRange + ? `${formatCurrency(priceMin)} - ${formatCurrency(priceMax)}` + : formatCurrency(priceMin)} + + {hasOptions ? ( + + ) : ( + + )} +
+
+
+ ); +} + +export type { ProductCardProps }; diff --git a/packages/react/src/components/storefront/product-grid.tsx b/packages/react/src/components/storefront/product-grid.tsx new file mode 100644 index 00000000..f209fe82 --- /dev/null +++ b/packages/react/src/components/storefront/product-grid.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { getSkuGroups } from '@/lib/godaddy/godaddy'; +import { ProductCard } from './product-card'; + +interface ProductGridProps { + storeId: string; + clientId: string; + first?: number; +} + +export function ProductGrid({ + storeId, + clientId, + first = 100, +}: ProductGridProps) { + const { data, isLoading, error } = useQuery({ + queryKey: ['sku-groups', { storeId, clientId, first }], + queryFn: () => getSkuGroups(storeId, clientId, { first }), + enabled: !!storeId && !!clientId, + }); + + if (isLoading) { + return
Loading products...
; + } + + if (error) { + return
Error loading products: {error.message}
; + } + + const skuGroups = data?.catalog?.skuGroups?.edges; + + return ( +
+ {skuGroups?.map(edge => { + const group = edge?.node; + if (!group) return null; + + return ; + })} +
+ ); +} diff --git a/packages/react/src/components/ui/badge.tsx b/packages/react/src/components/ui/badge.tsx new file mode 100644 index 00000000..19b5addc --- /dev/null +++ b/packages/react/src/components/ui/badge.tsx @@ -0,0 +1,35 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/packages/react/src/components/ui/card.tsx b/packages/react/src/components/ui/card.tsx new file mode 100644 index 00000000..c6571aac --- /dev/null +++ b/packages/react/src/components/ui/card.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5481b773..b91e446a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -15,5 +15,6 @@ export { DraftOrderTotals, type DraftOrderTotalsProps, } from './components/checkout/totals/totals'; +export * from './components/storefront'; export * from './godaddy-provider'; export * from './types'; diff --git a/packages/react/src/lib/godaddy/godaddy.ts b/packages/react/src/lib/godaddy/godaddy.ts index 96584331..b4516924 100644 --- a/packages/react/src/lib/godaddy/godaddy.ts +++ b/packages/react/src/lib/godaddy/godaddy.ts @@ -38,6 +38,7 @@ import { DraftOrderShippingRatesQuery, DraftOrderSkusQuery, DraftOrderTaxesQuery, + SkuGroupsQuery, } from './queries'; // Type for createCheckoutSession input with kebab-case appearance @@ -463,3 +464,29 @@ export function applyFulfillmentLocation( } ); } + +export function getSkuGroups( + storeId: string, + clientId: string, + { + first, + after, + ids, + }: { + first?: number; + after?: string; + ids?: string[]; + } = {} +) { + const _GODADDY_HOST = getHostByEnvironment(); + + return graphqlRequestWithErrors>( + 'http://localhost:3000', + SkuGroupsQuery, + { first, after, ids }, + { + 'X-Store-ID': storeId, + 'X-Client-ID': clientId, + } + ); +} diff --git a/packages/react/src/lib/godaddy/graphql-env.ts b/packages/react/src/lib/godaddy/graphql-env.ts index c0817bc8..4e226f79 100644 --- a/packages/react/src/lib/godaddy/graphql-env.ts +++ b/packages/react/src/lib/godaddy/graphql-env.ts @@ -1014,6 +1014,747 @@ const introspection = { ], interfaces: [], }, + { + kind: 'OBJECT', + name: 'Catalog', + fields: [ + { + name: 'sku', + type: { + kind: 'OBJECT', + name: 'CatalogSku', + }, + args: [ + { + name: 'id', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'ID', + }, + }, + }, + ], + isDeprecated: false, + }, + { + name: 'skuGroup', + type: { + kind: 'OBJECT', + name: 'CatalogSkuGroup', + }, + args: [ + { + name: 'id', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'ID', + }, + }, + }, + ], + isDeprecated: false, + }, + { + name: 'skuGroups', + type: { + kind: 'OBJECT', + name: 'CatalogSkuGroupConnection', + }, + args: [ + { + name: 'after', + type: { + kind: 'SCALAR', + name: 'String', + }, + }, + { + name: 'first', + type: { + kind: 'SCALAR', + name: 'Int', + }, + }, + { + name: 'ids', + type: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'ID', + }, + }, + }, + }, + ], + isDeprecated: false, + }, + { + name: 'skus', + type: { + kind: 'OBJECT', + name: 'CatalogSkuConnection', + }, + args: [ + { + name: 'after', + type: { + kind: 'SCALAR', + name: 'String', + }, + }, + { + name: 'first', + type: { + kind: 'SCALAR', + name: 'Int', + }, + }, + { + name: 'ids', + type: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'ID', + }, + }, + }, + }, + ], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogAttribute', + fields: [ + { + name: 'id', + type: { + kind: 'SCALAR', + name: 'ID', + }, + args: [], + isDeprecated: false, + }, + { + name: 'label', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'name', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'values', + type: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'CatalogAttributeValue', + }, + }, + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogAttributeValue', + fields: [ + { + name: 'id', + type: { + kind: 'SCALAR', + name: 'ID', + }, + args: [], + isDeprecated: false, + }, + { + name: 'label', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'name', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogMedia', + fields: [ + { + name: 'id', + type: { + kind: 'SCALAR', + name: 'ID', + }, + args: [], + isDeprecated: false, + }, + { + name: 'label', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'name', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'type', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'url', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogPrice', + fields: [ + { + name: 'currencyCode', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'value', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogPriceRange', + fields: [ + { + name: 'max', + type: { + kind: 'SCALAR', + name: 'Float', + }, + args: [], + isDeprecated: false, + }, + { + name: 'min', + type: { + kind: 'SCALAR', + name: 'Float', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogSKUMediaObjectsConnection', + fields: [ + { + name: 'edges', + type: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'CatalogSKUMediaObjectsConnectionEdge', + }, + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'pageInfo', + type: { + kind: 'OBJECT', + name: 'SKUMediaObjectsPageInfo', + }, + args: [], + isDeprecated: false, + }, + { + name: 'totalCount', + type: { + kind: 'SCALAR', + name: 'Int', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogSKUMediaObjectsConnectionEdge', + fields: [ + { + name: 'cursor', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'node', + type: { + kind: 'OBJECT', + name: 'CatalogMedia', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogSku', + fields: [ + { + name: 'attributes', + type: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'CatalogAttribute', + }, + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'code', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'description', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'htmlDescription', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'id', + type: { + kind: 'SCALAR', + name: 'ID', + }, + args: [], + isDeprecated: false, + }, + { + name: 'label', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'mediaObjects', + type: { + kind: 'OBJECT', + name: 'CatalogSKUMediaObjectsConnection', + }, + args: [], + isDeprecated: false, + }, + { + name: 'name', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'prices', + type: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'CatalogPrice', + }, + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'status', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogSkuConnection', + fields: [ + { + name: 'edges', + type: { + kind: 'LIST', + ofType: { + kind: 'OBJECT', + name: 'CatalogSkuEdge', + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'pageInfo', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'PageInfo', + }, + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogSkuEdge', + fields: [ + { + name: 'cursor', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'node', + type: { + kind: 'OBJECT', + name: 'CatalogSku', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogSkuGroup', + fields: [ + { + name: 'compareAtPriceRange', + type: { + kind: 'OBJECT', + name: 'CatalogPriceRange', + }, + args: [], + isDeprecated: false, + }, + { + name: 'description', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'htmlDescription', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'id', + type: { + kind: 'SCALAR', + name: 'ID', + }, + args: [], + isDeprecated: false, + }, + { + name: 'label', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'name', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'priceRange', + type: { + kind: 'OBJECT', + name: 'CatalogPriceRange', + }, + args: [], + isDeprecated: false, + }, + { + name: 'status', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'type', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogSkuGroupConnection', + fields: [ + { + name: 'edges', + type: { + kind: 'LIST', + ofType: { + kind: 'OBJECT', + name: 'CatalogSkuGroupEdge', + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'pageInfo', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'OBJECT', + name: 'PageInfo', + }, + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CatalogSkuGroupEdge', + fields: [ + { + name: 'cursor', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'node', + type: { + kind: 'OBJECT', + name: 'CatalogSkuGroup', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, + { + kind: 'OBJECT', + name: 'CheckoutAuthToken', + fields: [ + { + name: 'expiresAt', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'DateTime', + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'expiresIn', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'Int', + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'jwt', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + args: [], + isDeprecated: false, + }, + { + name: 'sessionId', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, { kind: 'OBJECT', name: 'CheckoutSession', @@ -1167,6 +1908,15 @@ const introspection = { args: [], isDeprecated: false, }, + { + name: 'enableShipping', + type: { + kind: 'SCALAR', + name: 'Boolean', + }, + args: [], + isDeprecated: false, + }, { name: 'enableShippingAddressCollection', type: { @@ -2339,6 +3089,33 @@ const introspection = { ], isOneOf: false, }, + { + kind: 'INPUT_OBJECT', + name: 'CheckoutSessionLineItemInput', + inputFields: [ + { + name: 'quantity', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'Int', + }, + }, + }, + { + name: 'skuId', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + }, + ], + isOneOf: false, + }, { kind: 'OBJECT', name: 'CheckoutSessionLocalDeliveryRule', @@ -3139,6 +3916,43 @@ const introspection = { ], isOneOf: false, }, + { + kind: 'OBJECT', + name: 'CheckoutTokenValidation', + fields: [ + { + name: 'expiresAt', + type: { + kind: 'SCALAR', + name: 'DateTime', + }, + args: [], + isDeprecated: false, + }, + { + name: 'sessionId', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'valid', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'Boolean', + }, + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, { kind: 'INPUT_OBJECT', name: 'ConfirmCheckoutBillingInfoInput', @@ -5085,6 +5899,33 @@ const introspection = { ], interfaces: [], }, + { + kind: 'INPUT_OBJECT', + name: 'ExchangeCheckoutTokenInput', + inputFields: [ + { + name: 'sessionId', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + }, + { + name: 'token', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + }, + ], + isOneOf: false, + }, { kind: 'INPUT_OBJECT', name: 'ExternalIdsInput', @@ -6300,6 +7141,26 @@ const introspection = { ], isDeprecated: false, }, + { + name: 'exchangeCheckoutToken', + type: { + kind: 'OBJECT', + name: 'CheckoutAuthToken', + }, + args: [ + { + name: 'input', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'INPUT_OBJECT', + name: 'MutationExchangeCheckoutTokenInput', + }, + }, + }, + ], + isDeprecated: false, + }, { name: 'expireCheckoutSession', type: { @@ -6320,6 +7181,15 @@ const introspection = { ], isDeprecated: false, }, + { + name: 'refreshCheckoutToken', + type: { + kind: 'OBJECT', + name: 'CheckoutAuthToken', + }, + args: [], + isDeprecated: false, + }, { name: 'removeAppliedCheckoutSessionShippingMethod', type: { @@ -6610,11 +7480,8 @@ const introspection = { { name: 'draftOrderId', type: { - kind: 'NON_NULL', - ofType: { - kind: 'SCALAR', - name: 'String', - }, + kind: 'SCALAR', + name: 'String', }, }, { @@ -6666,6 +7533,13 @@ const introspection = { name: 'Boolean', }, }, + { + name: 'enableShipping', + type: { + kind: 'SCALAR', + name: 'Boolean', + }, + }, { name: 'enableShippingAddressCollection', type: { @@ -6741,6 +7615,19 @@ const introspection = { name: 'DateTime', }, }, + { + name: 'lineItems', + type: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + kind: 'INPUT_OBJECT', + name: 'CheckoutSessionLineItemInput', + }, + }, + }, + }, { name: 'locations', type: { @@ -6836,6 +7723,33 @@ const introspection = { ], isOneOf: false, }, + { + kind: 'INPUT_OBJECT', + name: 'MutationExchangeCheckoutTokenInput', + inputFields: [ + { + name: 'sessionId', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + }, + { + name: 'token', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + }, + ], + isOneOf: false, + }, { kind: 'INPUT_OBJECT', name: 'MutationUpdateCheckoutSessionDraftOrderInput', @@ -7087,6 +8001,13 @@ const introspection = { name: 'Boolean', }, }, + { + name: 'enableShipping', + type: { + kind: 'SCALAR', + name: 'Boolean', + }, + }, { name: 'enableShippingAddressCollection', type: { @@ -7893,6 +8814,15 @@ const introspection = { kind: 'OBJECT', name: 'Query', fields: [ + { + name: 'catalog', + type: { + kind: 'OBJECT', + name: 'Catalog', + }, + args: [], + isDeprecated: false, + }, { name: 'checkoutSession', type: { @@ -7902,6 +8832,26 @@ const introspection = { args: [], isDeprecated: false, }, + { + name: 'validateCheckoutToken', + type: { + kind: 'OBJECT', + name: 'CheckoutTokenValidation', + }, + args: [ + { + name: 'token', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + }, + }, + }, + ], + isDeprecated: false, + }, ], interfaces: [], }, @@ -8041,24 +8991,6 @@ const introspection = { args: [], isDeprecated: false, }, - { - name: 'mediaUrls', - type: { - kind: 'NON_NULL', - ofType: { - kind: 'LIST', - ofType: { - kind: 'NON_NULL', - ofType: { - kind: 'SCALAR', - name: 'String', - }, - }, - }, - }, - args: [], - isDeprecated: false, - }, { name: 'metafields', type: { @@ -8288,6 +9220,49 @@ const introspection = { ], interfaces: [], }, + { + kind: 'OBJECT', + name: 'SKUMediaObjectsPageInfo', + fields: [ + { + name: 'endCursor', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + { + name: 'hasNextPage', + type: { + kind: 'SCALAR', + name: 'Boolean', + }, + args: [], + isDeprecated: false, + }, + { + name: 'hasPreviousPage', + type: { + kind: 'SCALAR', + name: 'Boolean', + }, + args: [], + isDeprecated: false, + }, + { + name: 'startCursor', + type: { + kind: 'SCALAR', + name: 'String', + }, + args: [], + isDeprecated: false, + }, + ], + interfaces: [], + }, { kind: 'OBJECT', name: 'SKUMetafield', diff --git a/packages/react/src/lib/godaddy/queries.ts b/packages/react/src/lib/godaddy/queries.ts index d341621b..7243c886 100644 --- a/packages/react/src/lib/godaddy/queries.ts +++ b/packages/react/src/lib/godaddy/queries.ts @@ -265,6 +265,49 @@ export const DraftOrderSkusQuery = graphql(` } `); +export const SkuGroupsQuery = graphql(` + query SkuGroups($first: Int, $after: String, $ids: [ID!]) { + catalog { + skuGroups(first: $first, after: $after, ids: $ids) { + edges { + cursor + node { + id + name + label + description + htmlDescription + status + type + priceRange { + min + max + } + compareAtPriceRange { + min + max + } + mediaObjects(first: 1) { + edges { + node { + url + type + } + } + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + } +`); + export const DraftOrderTaxesQuery = graphql(` query Taxes($destination: TaxDestinationAddressInput, $lines: [TaxLineInput!]) { checkoutSession { diff --git a/packages/react/src/lib/utils.ts b/packages/react/src/lib/utils.ts index 9ad0df42..71b0fa59 100644 --- a/packages/react/src/lib/utils.ts +++ b/packages/react/src/lib/utils.ts @@ -4,3 +4,16 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function formatCurrency( + value: number, + currencyCode = 'USD', + locale = 'en-US', + valueInCents = true +): string { + const amount = valueInCents ? value / 100 : value; + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: currencyCode, + }).format(amount); +} From d9a5aef5c368da242cf07132e5266d349c05057b Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Fri, 7 Nov 2025 10:28:07 -0600 Subject: [PATCH 02/21] load styles from provider for exported components --- examples/nextjs/app/cart/cart.tsx | 62 ++++++++++ examples/nextjs/app/cart/page.tsx | 9 ++ examples/nextjs/app/checkout.tsx | 49 +++----- examples/nextjs/app/providers.tsx | 14 ++- examples/nextjs/app/store/page.tsx | 12 +- .../components/storefront/cart-line-items.tsx | 8 ++ .../src/components/storefront/cart-totals.tsx | 10 ++ .../react/src/components/storefront/index.ts | 2 + .../components/storefront/product-card.tsx | 43 ++----- .../components/storefront/product-grid.tsx | 19 ++- packages/react/src/godaddy-provider.tsx | 74 ++++++++++++ packages/react/src/hooks/use-theme.tsx | 4 +- packages/react/src/hooks/use-variables.tsx | 108 +++++++++++------- packages/react/src/lib/godaddy/graphql-env.ts | 38 ++++++ packages/react/src/lib/godaddy/queries.ts | 1 - packages/react/src/types.ts | 11 +- 16 files changed, 344 insertions(+), 120 deletions(-) create mode 100644 examples/nextjs/app/cart/cart.tsx create mode 100644 examples/nextjs/app/cart/page.tsx create mode 100644 packages/react/src/components/storefront/cart-line-items.tsx create mode 100644 packages/react/src/components/storefront/cart-totals.tsx diff --git a/examples/nextjs/app/cart/cart.tsx b/examples/nextjs/app/cart/cart.tsx new file mode 100644 index 00000000..13efb67a --- /dev/null +++ b/examples/nextjs/app/cart/cart.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { CartLineItems, CartTotals } from '@godaddy/react'; + +export default function CartPage() { + const items = [ + { + id: 'LineItem_2y0l7o6Oi4BW6fpSiKPX1hhBccU', + name: 'Box of cookies', + image: + 'https://isteam.dev-wsimg.com/ip/2f2e05ec-de6f-4a89-90f2-038c749655b0/cookies.webp', + quantity: 2, + originalPrice: 10.99, + price: 10.99, + notes: [], + }, + { + id: 'LineItem_2y0l9FykA04qp2pC6y3YZ0TbZFD', + name: 'Cupcakes', + image: + 'https://isteam.dev-wsimg.com/ip/2f2e05ec-de6f-4a89-90f2-038c749655b0/cupcakes.webp/:/rs=w:600,h:600', + quantity: 1, + originalPrice: 5.99, + price: 5.99, + notes: [], + }, + ]; + + const totals = { + subtotal: 27.97, + discount: 0, + shipping: 0, + currency: 'USD', + itemCount: 3, + total: 27.97, + tip: 0, + taxes: 0, + enableDiscounts: false, + enableTaxes: true, + isTaxLoading: false, + }; + + return ( +
+

Cart

+
+
+
+

Your Items

+ +
+
+
+
+

Order Summary

+ +
+
+
+
+ ); +} diff --git a/examples/nextjs/app/cart/page.tsx b/examples/nextjs/app/cart/page.tsx new file mode 100644 index 00000000..43b6dc39 --- /dev/null +++ b/examples/nextjs/app/cart/page.tsx @@ -0,0 +1,9 @@ +import Cart from './cart'; + +export default function StorePage() { + return ( +
+ +
+ ); +} diff --git a/examples/nextjs/app/checkout.tsx b/examples/nextjs/app/checkout.tsx index aca19ce5..0fe2ff77 100644 --- a/examples/nextjs/app/checkout.tsx +++ b/examples/nextjs/app/checkout.tsx @@ -1,10 +1,7 @@ 'use client'; import type { CheckoutFormSchema, CheckoutSession } from '@godaddy/react'; -import { Checkout, GoDaddyProvider } from '@godaddy/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { useState } from 'react'; +import { Checkout } from '@godaddy/react'; import { z } from 'zod'; /* Override the checkout form schema to make shippingPhone required */ @@ -13,32 +10,24 @@ const customSchema: CheckoutFormSchema = { }; export function CheckoutPage({ session }: { session: CheckoutSession }) { - const [queryClient] = useState(() => new QueryClient()); - return ( - - - - - - + ); } diff --git a/examples/nextjs/app/providers.tsx b/examples/nextjs/app/providers.tsx index 63430e8b..62c250b9 100644 --- a/examples/nextjs/app/providers.tsx +++ b/examples/nextjs/app/providers.tsx @@ -1,6 +1,8 @@ 'use client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { GoDaddyProvider } from '@godaddy/react'; +import { QueryClient } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useState } from 'react'; export function Providers({ children }: { children: React.ReactNode }) { @@ -17,8 +19,14 @@ export function Providers({ children }: { children: React.ReactNode }) { ); return ( - + {children} - + + ); } diff --git a/examples/nextjs/app/store/page.tsx b/examples/nextjs/app/store/page.tsx index 06f1babe..0b3c74c3 100644 --- a/examples/nextjs/app/store/page.tsx +++ b/examples/nextjs/app/store/page.tsx @@ -1,9 +1,9 @@ import Products from './products'; export default function StorePage() { - return ( -
- -
- ) -} \ No newline at end of file + return ( +
+ +
+ ); +} diff --git a/packages/react/src/components/storefront/cart-line-items.tsx b/packages/react/src/components/storefront/cart-line-items.tsx new file mode 100644 index 00000000..2e4acd97 --- /dev/null +++ b/packages/react/src/components/storefront/cart-line-items.tsx @@ -0,0 +1,8 @@ +import { + DraftOrderLineItems, + DraftOrderLineItemsProps, +} from '@/components/checkout/line-items/line-items'; + +export function CartLineItems({ ...props }: DraftOrderLineItemsProps) { + return ; +} diff --git a/packages/react/src/components/storefront/cart-totals.tsx b/packages/react/src/components/storefront/cart-totals.tsx new file mode 100644 index 00000000..6f9d3f6c --- /dev/null +++ b/packages/react/src/components/storefront/cart-totals.tsx @@ -0,0 +1,10 @@ +import { + DraftOrderTotals, + DraftOrderTotalsProps, +} from '@/components/checkout/totals/totals.tsx'; + +export function CartTotals({ + ...props +}: Omit) { + return ; +} diff --git a/packages/react/src/components/storefront/index.ts b/packages/react/src/components/storefront/index.ts index 56d46cfd..bd7c8bbd 100644 --- a/packages/react/src/components/storefront/index.ts +++ b/packages/react/src/components/storefront/index.ts @@ -1,2 +1,4 @@ +export * from './cart-line-items.tsx'; +export * from './cart-totals.tsx'; export * from './product-card'; export * from './product-grid'; diff --git a/packages/react/src/components/storefront/product-card.tsx b/packages/react/src/components/storefront/product-card.tsx index 01e4cd1a..cc713e20 100644 --- a/packages/react/src/components/storefront/product-card.tsx +++ b/packages/react/src/components/storefront/product-card.tsx @@ -5,48 +5,23 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { formatCurrency } from '@/lib/utils'; - -interface SkuGroup { - id: string; - name?: string; - label?: string; - description?: string; - htmlDescription?: string; - status?: string; - type?: string; - priceRange?: { - min?: number; - max?: number; - }; - compareAtPriceRange?: { - min?: number; - max?: number; - }; - mediaObjects?: { - edges?: Array<{ - node?: { - url?: string; - type?: string; - }; - }>; - }; -} +import { SKUGroup } from '@/types.ts'; interface ProductCardProps { - product: SkuGroup; + product: SKUGroup; } export function ProductCard({ product }: ProductCardProps) { - const title = product.label || product.name || 'Product'; - const description = product.description || ''; - const priceMin = product.priceRange?.min || 0; - const priceMax = product.priceRange?.max || priceMin; - const compareAtMin = product.compareAtPriceRange?.min; + const title = product?.label || product?.name || 'Product'; + const description = product?.description || ''; + const priceMin = product?.priceRange?.min || 0; + const priceMax = product?.priceRange?.max || priceMin; + const compareAtMin = product?.compareAtPriceRange?.min; const isOnSale = compareAtMin && compareAtMin > priceMin; const hasOptions = false; const isPriceRange = priceMin !== priceMax; - const imageUrl = product.mediaObjects?.edges?.find( + const imageUrl = product?.mediaObjects?.edges?.find( edge => edge?.node?.type === 'IMAGE' )?.node?.url; @@ -82,7 +57,7 @@ export function ProductCard({ product }: ProductCardProps) { {description}

- + {isPriceRange ? `${formatCurrency(priceMin)} - ${formatCurrency(priceMax)}` : formatCurrency(priceMin)} diff --git a/packages/react/src/components/storefront/product-grid.tsx b/packages/react/src/components/storefront/product-grid.tsx index f209fe82..6c6bf863 100644 --- a/packages/react/src/components/storefront/product-grid.tsx +++ b/packages/react/src/components/storefront/product-grid.tsx @@ -1,6 +1,7 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; +import { Skeleton } from '@/components/ui/skeleton'; import { getSkuGroups } from '@/lib/godaddy/godaddy'; import { ProductCard } from './product-card'; @@ -10,6 +11,22 @@ interface ProductGridProps { first?: number; } +function ProductGridSkeleton({ count = 6 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + export function ProductGrid({ storeId, clientId, @@ -22,7 +39,7 @@ export function ProductGrid({ }); if (isLoading) { - return
Loading products...
; + return ; } if (error) { diff --git a/packages/react/src/godaddy-provider.tsx b/packages/react/src/godaddy-provider.tsx index 3d920d31..a0782354 100644 --- a/packages/react/src/godaddy-provider.tsx +++ b/packages/react/src/godaddy-provider.tsx @@ -5,6 +5,9 @@ import { type QueryClientProviderProps, } from '@tanstack/react-query'; import React from 'react'; +import { convertCamelCaseToKebabCase } from '@/components/checkout/utils/case-conversion'; +import { themes, useTheme } from '@/hooks/use-theme.tsx'; +import { useVariables } from '@/hooks/use-variables.tsx'; function createQueryClient() { return new QueryClient({ @@ -108,10 +111,81 @@ export function GoDaddyProvider({ }; }, [appearance]); + const inlineStyles = React.useMemo(() => { + if (!processedAppearance?.variables) return ''; + + const rawVars = + 'checkout' in processedAppearance.variables + ? processedAppearance.variables.checkout + : processedAppearance.variables; + + // Check if variables need kebab-case conversion + const needsConversion = !Object.keys(rawVars).some(key => + key.includes('-') + ); + const vars = needsConversion + ? convertCamelCaseToKebabCase(rawVars as Record) + : rawVars; + + const sanitizeCSSValue = (value: string): string => { + return value + .replace(/[<>{}]/g, '') // Remove characters that could close tags + .replace(/javascript:/gi, '') // Remove javascript: protocol + .replace(/expression\(/gi, ''); // Remove IE expression() + }; + + const cssVars = Object.entries(vars) + .filter(([_, value]) => value != null) + .map(([key, value]) => { + const sanitizedValue = sanitizeCSSValue(String(value)); + return `--gd-${key}: ${sanitizedValue};`; + }) + .join(' '); + + return cssVars ? `:root { ${cssVars} }` : ''; + }, [processedAppearance]); + + // Generate inline script to apply theme class before hydration + const themeScript = React.useMemo(() => { + const theme = processedAppearance?.theme; + if (!theme || theme === 'base') return null; + + const themeValues = Object.values(themes).map(t => t.value); + const themeClass = themes[theme]?.value; + + if (!themeClass) return null; + + // Script that runs synchronously before React hydration + // Using JSON.stringify to prevent any injection attacks + return ` + (function() { + var themeClasses = ${JSON.stringify(themeValues)}; + var root = document.documentElement; + themeClasses.forEach(function(t) { root.classList.remove(t); }); + root.classList.add(${JSON.stringify(themeClass)}); + })(); + `; + }, [processedAppearance?.theme]); + + useVariables(processedAppearance?.variables); + useTheme(processedAppearance?.theme); + return ( + {inlineStyles && ( +