diff --git a/.changeset/cute-bees-cross.md b/.changeset/cute-bees-cross.md new file mode 100644 index 00000000..b9b4adc0 --- /dev/null +++ b/.changeset/cute-bees-cross.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Add product grid and product details components using public storefront apis diff --git a/.gitignore b/.gitignore index 53f47767..be44de1d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,7 @@ dist /blob-report/ /playwright/.cache/ -schema.graphql +*.graphql # macOS .DS_Store diff --git a/examples/nextjs/app/checkout.tsx b/examples/nextjs/app/checkout.tsx index f49875e1..0fe2ff77 100644 --- a/examples/nextjs/app/checkout.tsx +++ b/examples/nextjs/app/checkout.tsx @@ -1,8 +1,7 @@ 'use client'; import type { CheckoutFormSchema, CheckoutSession } from '@godaddy/react'; -import { Checkout, GoDaddyProvider } from '@godaddy/react'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { Checkout } from '@godaddy/react'; import { z } from 'zod'; /* Override the checkout form schema to make shippingPhone required */ @@ -12,26 +11,23 @@ const customSchema: CheckoutFormSchema = { export function CheckoutPage({ session }: { session: CheckoutSession }) { return ( - - - - + ); } diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 54e0d1ad..9ae69eb8 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -90,7 +90,7 @@ export default async function Home() { }, { auth: { - clientId: process.env.GODADDY_CLIENT_ID || '', + clientId: process.env.NEXT_PUBLIC_GODADDY_CLIENT_ID || '', clientSecret: process.env.GODADDY_CLIENT_SECRET || '', }, } diff --git a/examples/nextjs/app/providers.tsx b/examples/nextjs/app/providers.tsx index 6af0a6e0..71f3d85a 100644 --- a/examples/nextjs/app/providers.tsx +++ b/examples/nextjs/app/providers.tsx @@ -1,12 +1,37 @@ '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 Link from 'next/link'; import { useState } from 'react'; export function Providers({ children }: { children: React.ReactNode }) { - const [queryClient] = useState(() => new QueryClient()); + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, + }) + ); return ( - {children} + + {children} + + ); } diff --git a/examples/nextjs/app/store/cart/cart.tsx b/examples/nextjs/app/store/cart/cart.tsx new file mode 100644 index 00000000..87c64bb6 --- /dev/null +++ b/examples/nextjs/app/store/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/store/cart/page.tsx b/examples/nextjs/app/store/cart/page.tsx new file mode 100644 index 00000000..43b6dc39 --- /dev/null +++ b/examples/nextjs/app/store/cart/page.tsx @@ -0,0 +1,9 @@ +import Cart from './cart'; + +export default function StorePage() { + return ( +
+ +
+ ); +} diff --git a/examples/nextjs/app/store/layout.tsx b/examples/nextjs/app/store/layout.tsx new file mode 100644 index 00000000..9b003c0e --- /dev/null +++ b/examples/nextjs/app/store/layout.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Cart } from '@godaddy/react'; +import { ShoppingCart } from 'lucide-react'; +import { createContext, useContext, useState } from 'react'; + +interface CartContextType { + openCart: () => void; + closeCart: () => void; +} + +const CartContext = createContext(undefined); + +export function useCart() { + const context = useContext(CartContext); + if (!context) { + throw new Error('useCart must be used within CartProvider'); + } + return context; +} + +export default function StoreLayout({ + children, +}: { + children: React.ReactNode; +}) { + const [isCartOpen, setIsCartOpen] = useState(false); + + const openCart = () => setIsCartOpen(true); + const closeCart = () => setIsCartOpen(false); + + return ( + +
+ {/* Cart toggle button */} + + + {children} + + +
+
+ ); +} + +export { CartContext }; diff --git a/examples/nextjs/app/store/page.tsx b/examples/nextjs/app/store/page.tsx new file mode 100644 index 00000000..0b3c74c3 --- /dev/null +++ b/examples/nextjs/app/store/page.tsx @@ -0,0 +1,9 @@ +import Products from './products'; + +export default function StorePage() { + return ( +
+ +
+ ); +} diff --git a/examples/nextjs/app/store/product/[productId]/page.tsx b/examples/nextjs/app/store/product/[productId]/page.tsx new file mode 100644 index 00000000..cf30783a --- /dev/null +++ b/examples/nextjs/app/store/product/[productId]/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { use } from 'react'; +import Product from '@/app/store/product/[productId]/product'; + +export default function ProductPage({ + params, +}: { + params: Promise<{ productId: string }>; +}) { + const { productId } = use(params); + + return ( +
+ +
+ ); +} diff --git a/examples/nextjs/app/store/product/[productId]/product.tsx b/examples/nextjs/app/store/product/[productId]/product.tsx new file mode 100644 index 00000000..bce327b8 --- /dev/null +++ b/examples/nextjs/app/store/product/[productId]/product.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ProductDetails } from '@godaddy/react'; +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; + +export default function Product({ productId }: { productId: string }) { + return ( +
+ + + Back to Store + + +
+ ); +} diff --git a/examples/nextjs/app/store/products.tsx b/examples/nextjs/app/store/products.tsx new file mode 100644 index 00000000..324a4b06 --- /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 ( +
+ `/store/product/${sku}`} /> +
+ ); +} diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 0bfa947c..fed56cd8 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -1,32 +1,33 @@ { - "name": "nextjs", - "version": "0.1.1", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "biome check .", - "lint:fix": "biome check --write --unsafe ." - }, - "dependencies": { - "@godaddy/localizations": "workspace:*", - "@godaddy/react": "workspace:*", - "@tanstack/react-query": "^5.66.0", - "@tanstack/react-query-devtools": "^5.76.1", - "next": "16.0.1", - "react": "19.2.0", - "react-dom": "19.2.0", - "zod": "^3.24.1" - }, - "devDependencies": { - "@biomejs/biome": "^2", - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "biome-config-godaddy": "workspace:*", - "tailwindcss": "^4", - "typescript": "^5" - } + "name": "nextjs", + "version": "0.1.1", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@godaddy/localizations": "workspace:*", + "@godaddy/react": "workspace:*", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query-devtools": "^5.76.1", + "lucide-react": "^0.475.0", + "next": "16.0.1", + "react": "19.2.0", + "react-dom": "19.2.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@biomejs/biome": "^2", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "biome-config-godaddy": "workspace:*", + "tailwindcss": "^4", + "typescript": "^5" + } } diff --git a/package.json b/package.json index 7bc01d2c..2a361aeb 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-mocha": "^10.5.0", "eslint-plugin-react": "^7.37.5", - "react": "^19.1.0", + "react": "^19.2.0", "rimraf": "^6.0.1", "typescript": "^5.8.3" }, diff --git a/packages/react/components.json b/packages/react/components.json index bcf503b7..37e56c34 100644 --- a/packages/react/components.json +++ b/packages/react/components.json @@ -7,8 +7,7 @@ "config": "", "css": "src/index.css", "baseColor": "neutral", - "cssVariables": true, - "prefix": "gd" + "cssVariables": true }, "aliases": { "components": "@/components", diff --git a/packages/react/package.json b/packages/react/package.json index b88308fa..f4e496bf 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,121 +1,115 @@ { - "name": "@godaddy/react", - "private": false, - "version": "1.0.3", - "type": "module", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "exports": { - "./package.json": "./package.json", - "./styles.css": "./dist/index.css", - "./server": { - "types": "./dist/server.d.ts", - "import": "./dist/server.js", - "default": "./dist/server.js" - }, - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "typesVersions": { - "*": { - "server": [ - "./dist/server.d.ts" - ], - "*": [ - "./dist/index.d.ts" - ] - } - }, - "scripts": { - "dev": "tsdown --watch", - "dev:https": "tsdown --watch", - "build": "tsdown && pnpm dlx @tailwindcss/cli -i ./src/globals.css -o ./dist/index.css --minify", - "preview": "vite preview", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "biome check .", - "lint:fix": "biome check --write --unsafe ./src", - "prepublishOnly": "pnpm run build" - }, - "peerDependencies": { - "@tanstack/react-query": ">=5", - "react": "18.x || 19.x", - "react-dom": "18.x || 19.x", - "react-hook-form": ">=7" - }, - "dependencies": { - "@floating-ui/react": "^0.27.8", - "@godaddy/localizations": "workspace:*", - "@hookform/resolvers": "^4.0.0", - "@paypal/react-paypal-js": "^8.8.3", - "@radix-ui/react-accordion": "^1.2.3", - "@radix-ui/react-alert-dialog": "^1.1.6", - "@radix-ui/react-aspect-ratio": "^1.1.2", - "@radix-ui/react-avatar": "^1.1.3", - "@radix-ui/react-checkbox": "^1.1.4", - "@radix-ui/react-collapsible": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-label": "^2.1.2", - "@radix-ui/react-popover": "^1.1.6", - "@radix-ui/react-radio-group": "^1.2.3", - "@radix-ui/react-scroll-area": "^1.2.3", - "@radix-ui/react-select": "^2.1.6", - "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-switch": "^1.1.3", - "@radix-ui/react-toast": "^1.2.6", - "@radix-ui/react-toggle": "^1.1.2", - "@radix-ui/react-toggle-group": "^1.1.2", - "@radix-ui/react-tooltip": "^1.1.8", - "@stripe/react-stripe-js": "^3.7.0", - "@stripe/stripe-js": "^7.3.1", - "@tailwindcss/cli": "^4.1.10", - "@tailwindcss/vite": "^4.1.4", - "@tanstack/react-pacer": "^0.2.0", - "@tanstack/react-query": "^5.66.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "1.0.0", - "date-fns": "^4.1.0", - "date-fns-tz": "^3.2.0", - "embla-carousel-react": "^8.5.2", - "fast-deep-equal": "^3.1.3", - "gql.tada": "^1.8.10", - "graphql": "^16.10.0", - "graphql-request": "^5.2.0", - "lucide-react": "^0.475.0", - "react-day-picker": "8.10.1", - "react-phone-number-input": "^3.4.12", - "react-resizable-panels": "^2.1.7", - "tailwind-merge": "^3.0.1", - "tailwindcss": "^4.1.4", - "ulid": "^3.0.0", - "vaul": "^1.1.2", - "zod": "^3.24.1" - }, - "devDependencies": { - "@biomejs/biome": "^2.3.2", - "@types/node": "^22.13.1", - "@types/react": "^19.0.8", - "@types/react-dom": "^19.0.3", - "@vitejs/plugin-react": "^4.2.1", - "biome-config-godaddy": "workspace:*", - "globals": "^15.14.0", - "jsdom": "^26.0.0", - "react": "^19", - "react-dom": "^19", - "react-hook-form": "^7.54.2", - "tsdown": "^0.15.6", - "typescript": "~5.7.3", - "vite": "^5.1.6", - "vitest": "^3.0.6" - }, - "publishConfig": { - "registry": "https://registry.npmjs.org/" - } + "name": "@godaddy/react", + "private": false, + "version": "1.0.3", + "type": "module", + "types": "./dist/index.d.ts", + "files": ["dist"], + "exports": { + "./package.json": "./package.json", + "./styles.css": "./dist/index.css", + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js", + "default": "./dist/server.js" + }, + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "typesVersions": { + "*": { + "server": ["./dist/server.d.ts"], + "*": ["./dist/index.d.ts"] + } + }, + "scripts": { + "dev": "tsdown --watch", + "dev:https": "tsdown --watch", + "build": "tsdown && pnpm dlx @tailwindcss/cli -i ./src/globals.css -o ./dist/index.css --minify", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ./src", + "prepublishOnly": "pnpm run build" + }, + "peerDependencies": { + "@tanstack/react-query": ">=5", + "react": "18.x || 19.x", + "react-dom": "18.x || 19.x", + "react-hook-form": ">=7" + }, + "dependencies": { + "@floating-ui/react": "^0.27.8", + "@godaddy/localizations": "workspace:*", + "@hookform/resolvers": "^4.0.0", + "@paypal/react-paypal-js": "^8.8.3", + "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@stripe/react-stripe-js": "^3.7.0", + "@stripe/stripe-js": "^7.3.1", + "@tailwindcss/cli": "^4.1.10", + "@tailwindcss/vite": "^4.1.4", + "@tanstack/react-pacer": "^0.2.0", + "@tanstack/react-query": "^5.66.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "1.0.0", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "embla-carousel-react": "^8.5.2", + "fast-deep-equal": "^3.1.3", + "gql.tada": "^1.8.10", + "graphql": "^16.10.0", + "graphql-request": "^5.2.0", + "lucide-react": "^0.475.0", + "react-day-picker": "8.10.1", + "react-phone-number-input": "^3.4.12", + "react-resizable-panels": "^2.1.7", + "tailwind-merge": "^3.0.1", + "tailwindcss": "^4.1.4", + "ulid": "^3.0.0", + "vaul": "^1.1.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.2", + "@types/node": "^22.13.1", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.2.1", + "biome-config-godaddy": "workspace:*", + "globals": "^15.14.0", + "jsdom": "^26.0.0", + "react": "^19", + "react-dom": "^19", + "react-hook-form": "^7.54.2", + "tsdown": "^0.15.6", + "typescript": "~5.7.3", + "vite": "^5.1.6", + "vitest": "^3.0.6" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/" + } } diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index cdc232aa..8fbd1937 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -227,7 +227,7 @@ export function Checkout(props: CheckoutProps) { const [checkoutErrors, setCheckoutErrors] = React.useState< string[] | undefined >(undefined); - const { t } = useGoDaddyContext() + const { t } = useGoDaddyContext(); const { session, jwt, isLoading: isLoadingJWT } = useCheckoutSession(props); @@ -334,7 +334,6 @@ export function Checkout(props: CheckoutProps) { } }); }, [checkoutFormSchema, t]); - // }, [checkoutFormSchema, session?.paymentMethods]); const requiredFields = React.useMemo(() => { return getRequiredFieldsFromSchema(formSchema); diff --git a/packages/react/src/components/checkout/delivery/delivery-method.tsx b/packages/react/src/components/checkout/delivery/delivery-method.tsx index 5aa5aaa7..0861708e 100644 --- a/packages/react/src/components/checkout/delivery/delivery-method.tsx +++ b/packages/react/src/components/checkout/delivery/delivery-method.tsx @@ -82,13 +82,14 @@ export function DeliveryMethodForm() { useEffect(() => { const currentValue = form.getValues('deliveryMethod'); const isCurrentValueValid = availableMethods.some( - (method) => method.id === currentValue + method => method.id === currentValue ); if (!currentValue || !isCurrentValueValid) { - const defaultMethod = availableMethods.length === 1 - ? availableMethods[0].id - : DeliveryMethods.SHIP; + const defaultMethod = + availableMethods.length === 1 + ? availableMethods[0].id + : DeliveryMethods.SHIP; form.setValue('deliveryMethod', defaultMethod); } }, [availableMethods, form]); diff --git a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts index b235bbbf..a85cc7eb 100644 --- a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts +++ b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts @@ -1,13 +1,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ResultOf } from 'gql.tada'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-method'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; import { useGoDaddyContext } from '@/godaddy-provider'; -import type { ResultOf } from '@/gql.tada'; +import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; import { applyDiscount } from '@/lib/godaddy/godaddy'; -import type { DraftOrderQuery } from '@/lib/godaddy/queries'; import type { ApplyCheckoutSessionDiscountInput } from '@/types'; export function useDiscountApply() { diff --git a/packages/react/src/components/checkout/form/checkout-form-container.tsx b/packages/react/src/components/checkout/form/checkout-form-container.tsx index 055ae17a..bc17109d 100644 --- a/packages/react/src/components/checkout/form/checkout-form-container.tsx +++ b/packages/react/src/components/checkout/form/checkout-form-container.tsx @@ -51,13 +51,13 @@ export function CheckoutFormContainer({ [order, props.defaultValues, session?.shipping?.originAddress?.countryCode] ); - // if (!isConfirmingCheckout && !draftOrderQuery.isLoading && !order) { - // const returnUrl = session?.returnUrl; - // if (returnUrl) { - // window.location.href = returnUrl; - // return null; - // } - // } + if (!isConfirmingCheckout && !draftOrderQuery.isLoading && !order) { + const returnUrl = session?.returnUrl; + if (returnUrl) { + window.location.href = returnUrl; + return null; + } + } if (draftOrderQuery.isLoading || isLoadingJWT) { return ; diff --git a/packages/react/src/components/checkout/order/use-update-taxes.ts b/packages/react/src/components/checkout/order/use-update-taxes.ts index 2dbc88cf..90e67e48 100644 --- a/packages/react/src/components/checkout/order/use-update-taxes.ts +++ b/packages/react/src/components/checkout/order/use-update-taxes.ts @@ -1,9 +1,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ResultOf } from 'gql.tada'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useGoDaddyContext } from '@/godaddy-provider'; -import type { ResultOf } from '@/gql.tada'; +import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; import { updateDraftOrderTaxes } from '@/lib/godaddy/godaddy'; -import type { DraftOrderQuery } from '@/lib/godaddy/queries'; export function useUpdateTaxes() { const { session, jwt } = useCheckoutContext(); 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/checkout/shipping/utils/use-apply-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts index 701fe3d0..687601cd 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts @@ -1,12 +1,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ResultOf } from 'gql.tada'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useDiscountApply } from '@/components/checkout/discount'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; import { useGoDaddyContext } from '@/godaddy-provider'; -import type { ResultOf } from '@/gql.tada'; +import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; import { applyShippingMethod } from '@/lib/godaddy/godaddy'; -import type { DraftOrderQuery } from '@/lib/godaddy/queries'; import type { ApplyCheckoutSessionShippingMethodInput } from '@/types'; export function useApplyShippingMethod() { diff --git a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts index 945b0a3a..69aab45f 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts @@ -1,11 +1,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ResultOf } from 'gql.tada'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useDiscountApply } from '@/components/checkout/discount'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useGoDaddyContext } from '@/godaddy-provider'; -import type { ResultOf } from '@/gql.tada'; +import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; import { removeShippingMethod } from '@/lib/godaddy/godaddy'; -import type { DraftOrderQuery } from '@/lib/godaddy/queries'; import type { RemoveAppliedCheckoutSessionShippingMethodInput } from '@/types'; export function useRemoveShippingMethod() { 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/cart.tsx b/packages/react/src/components/storefront/cart.tsx new file mode 100644 index 00000000..58a8943e --- /dev/null +++ b/packages/react/src/components/storefront/cart.tsx @@ -0,0 +1,71 @@ +'use client'; + +import type { Product } from '@/components/checkout/line-items/line-items'; +import { CartLineItems } from '@/components/storefront/cart-line-items'; +import { CartTotals } from '@/components/storefront/cart-totals'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { useGoDaddyContext } from '@/godaddy-provider'; + +interface CartProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function Cart({ open, onOpenChange }: CartProps) { + // Mock data + const items: Product[] = [ + { + 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, + currencyCode: 'USD', + itemCount: 3, + total: 27.97, + tip: 0, + taxes: 0, + enableDiscounts: false, + enableTaxes: true, + isTaxLoading: false, + }; + + return ( + + + + Shopping Cart + +
+ + +
+
+
+ ); +} diff --git a/packages/react/src/components/storefront/index.ts b/packages/react/src/components/storefront/index.ts new file mode 100644 index 00000000..5441be69 --- /dev/null +++ b/packages/react/src/components/storefront/index.ts @@ -0,0 +1,6 @@ +export * from './cart'; +export * from './cart-line-items.tsx'; +export * from './cart-totals.tsx'; +export * from './product-card'; +export * from './product-details.tsx'; +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..676a5406 --- /dev/null +++ b/packages/react/src/components/storefront/product-card.tsx @@ -0,0 +1,100 @@ +'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 { RouterLink } from '@/components/ui/link'; +import { formatCurrency } from '@/lib/utils'; +import { SKUGroup } from '@/types.ts'; + +interface ProductCardProps { + product: SKUGroup; + href?: string; +} + +export function ProductCard({ product, href }: 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(); + }; + + const cardContent = ( + <> +
+ {isOnSale && ( + + SALE + + )} + {imageUrl ? ( + {title} + ) : ( +
+ No image +
+ )} +
+
+

+ {title} +

+

+ {description} +

+
+ + {isPriceRange + ? `${formatCurrency(priceMin)} - ${formatCurrency(priceMax)}` + : formatCurrency(priceMin)} + + {hasOptions ? ( + + ) : ( + + )} +
+
+ + ); + + if (href) { + return ( + + + {cardContent} + + + ); + } + + return ( + + {cardContent} + + ); +} + +export type { ProductCardProps }; diff --git a/packages/react/src/components/storefront/product-details.tsx b/packages/react/src/components/storefront/product-details.tsx new file mode 100644 index 00000000..fe50faf5 --- /dev/null +++ b/packages/react/src/components/storefront/product-details.tsx @@ -0,0 +1,684 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { Minus, Plus, ShoppingCart } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { getSku, getSkuGroup } from '@/lib/godaddy/godaddy'; +import { formatCurrency } from '@/lib/utils'; +import type { SKUGroupAttribute, SKUGroupAttributeValue } from '@/types'; + +interface ProductDetailsProps { + productId: string; + storeId?: string; + clientId?: string; +} + +// Flattened attribute structure for UI (transforms edges/node to flat array) +type Attribute = { + id: NonNullable['id']; + name: NonNullable['name']; + label: NonNullable['label']; + values: NonNullable[]; +}; + +/** + * TODO: Product Details Enhancements + * + * 1. Variant SKU Management + * - [ ] Fetch individual variant SKUs based on selected attribute combinations + * - [ ] Query SKU-level data when attributes change (size, color, etc.) + * - [ ] Update product price and images when variant changes + * - [ ] Handle variant-specific media objects + * + * 2. Inventory Management + * - [ ] Check real-time inventory levels for the selected variant + * - [ ] Display available quantity or low stock warnings + * - [ ] Integrate inventory API calls + * - [ ] Cache inventory data with appropriate TTL + * + * 3. Out of Stock UI + * - [ ] Add "Out of Stock" badge when inventory is depleted + * - [ ] Disable "Add to Cart" button for out-of-stock items + * - [ ] Show "Notify When Available" option for out-of-stock products + * - [ ] Display estimated restock date if available + * + * 4. Unavailable Variant UI + * - [ ] Disable attribute options that result in unavailable variants + * - [ ] Show strikethrough or greyed-out style for unavailable options + * - [ ] Display tooltip explaining why option is unavailable + * - [ ] Prevent selection of invalid attribute combinations + * - [ ] Show nearest available alternative when variant is unavailable + */ + +function ProductDetailsSkeleton() { + return ( +
+
+ {/* Main Image Skeleton */} +
+ + + +
+ + {/* Thumbnail Skeletons */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ ))} +
+
+
+
+ + + +
+
+ + + +
+ {/* Add to Cart Button Skeleton */} + + {/* Additional Product Information Skeleton */} +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +export function ProductDetails({ + productId, + storeId: storeIdProp, + clientId: clientIdProp, +}: ProductDetailsProps) { + const context = useGoDaddyContext(); + + // Props take priority over context values + const storeId = storeIdProp || context.storeId; + const clientId = clientIdProp || context.clientId; + + const [quantity, setQuantity] = useState(1); + const [carouselApi, setCarouselApi] = useState(); + const [thumbnailApi, setThumbnailApi] = useState(); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + + // Read query params from URL - framework agnostic + const [variantParams, setVariantParamsState] = useState< + Record + >(() => { + if (typeof window === 'undefined') return {}; + const params = new URLSearchParams(window.location.search); + const result: Record = {}; + params.forEach((value, key) => { + result[key] = value; + }); + return result; + }); + + // Update URL when variant params change + const setVariantParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(window.location.search); + + Object.entries(updates).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, '', newUrl); + + setVariantParamsState(prev => { + const next = { ...prev }; + Object.entries(updates).forEach(([key, value]) => { + if (value) { + next[key] = value; + } else { + delete next[key]; + } + }); + return next; + }); + }, + [] + ); + + // Sync state with URL changes (e.g., browser back/forward) + useEffect(() => { + const handlePopState = () => { + const params = new URLSearchParams(window.location.search); + const result: Record = {}; + params.forEach((value, key) => { + result[key] = value; + }); + setVariantParamsState(result); + }; + + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + const selectedAttributes = useMemo(() => { + const attrs: Record = {}; + Object.entries(variantParams).forEach(([key, value]) => { + if (value) attrs[key] = value; + }); + return attrs; + }, [variantParams]); + + // Convert to array for GraphQL query + const selectedAttributeValues = useMemo(() => { + return Object.values(selectedAttributes).filter(Boolean); + }, [selectedAttributes]); + + // Single query - refetches when selectedAttributeValues changes + const { data, isLoading, error } = useQuery({ + queryKey: [ + 'sku-group', + storeId, + clientId, + productId, + ...selectedAttributeValues.sort(), // Sort for stable key + ], + queryFn: () => + getSkuGroup( + { id: productId!, attributeValues: selectedAttributeValues }, + storeId!, + clientId!, + context?.apiHost + ), + enabled: !!storeId && !!clientId && !!productId, + placeholderData: previousData => previousData, // Keep previous data while refetching + }); + + // Get product attributes from the query response + const attributesEdges = data?.skuGroup?.attributes?.edges || []; + const attributes: Attribute[] = useMemo(() => { + return attributesEdges.map((edge: any) => { + const attributeNode = edge?.node; + const valuesEdges = attributeNode?.values?.edges || []; + + return { + id: attributeNode?.id || '', + name: attributeNode?.name || '', + label: attributeNode?.label || attributeNode?.name || '', + values: valuesEdges.map((valueEdge: any) => { + const valueNode = valueEdge?.node; + return { + id: valueNode?.id || '', + name: valueNode?.name || '', + label: valueNode?.label || valueNode?.name || '', + }; + }), + }; + }); + }, [attributesEdges]); + + // Get the matched SKUs based on selected attributes + const matchedSkus = data?.skuGroup?.skus?.edges || []; + const matchedSkuId = + matchedSkus.length === 1 ? matchedSkus[0]?.node?.id : null; + + // Query individual SKU details when exactly one SKU matches + const { data: individualSkuData, isLoading: isSkuLoading } = useQuery({ + queryKey: ['individual-sku', storeId, clientId, matchedSkuId], + queryFn: () => + getSku({ id: matchedSkuId! }, storeId!, clientId!, context?.apiHost), + enabled: !!storeId && !!clientId && !!matchedSkuId, + }); + + // Use individual SKU data if available, otherwise use SKU Group data + const selectedSku = individualSkuData?.sku; + + // Track main carousel selection and sync thumbnail carousel + useEffect(() => { + if (!carouselApi) return; + + const onSelect = () => { + const index = carouselApi.selectedScrollSnap(); + setCurrentImageIndex(index); + + // Sync thumbnail carousel to show the selected thumbnail + if (thumbnailApi) { + thumbnailApi.scrollTo(index); + } + }; + + // Set initial index + onSelect(); + + // Listen for selection changes + carouselApi.on('select', onSelect); + + // Cleanup + return () => { + carouselApi.off('select', onSelect); + }; + }, [carouselApi, thumbnailApi]); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+
+ Error loading product: {error.message} +
+
+ ); + } + + const product = data?.skuGroup; + + if (!product) { + return ( +
+
+ Product not found +
+
+ ); + } + + const title = product?.label || product?.name || 'Product'; + const description = product?.description || ''; + const htmlDescription = product?.htmlDescription || ''; + + // Use SKU-specific pricing if available, otherwise fall back to SKU Group pricing + const skuPrice = selectedSku?.prices?.edges?.[0]?.node; + const priceMin = skuPrice?.value?.value ?? product?.priceRange?.min ?? 0; + const priceMax = selectedSku + ? priceMin + : (product?.priceRange?.max ?? priceMin); + const compareAtMin = + skuPrice?.compareAtValue?.value ?? product?.compareAtPriceRange?.min; + const compareAtMax = selectedSku + ? compareAtMin + : product?.compareAtPriceRange?.max; + const isOnSale = compareAtMin && compareAtMin > priceMin; + const isPriceRange = priceMin !== priceMax; + const isCompareAtPriceRange = + compareAtMin && compareAtMax && compareAtMin !== compareAtMax; + + // Get all media objects (images) - prefer SKU-specific images if available + const skuMediaObjects = selectedSku?.mediaObjects?.edges || []; + const productMediaObjects = product?.mediaObjects?.edges || []; + const mediaObjects = + skuMediaObjects.length > 0 ? skuMediaObjects : productMediaObjects; + + const images = mediaObjects + .filter((edge: any) => edge?.node?.type === 'IMAGE') + .map((edge: any) => edge?.node?.url) + .filter(Boolean); + + // const images = [...imagesSrc, ...imagesSrc, ...imagesSrc]; + + const handleAttributeChange = (attributeName: string, valueName: string) => { + // Update the URL query params with the new attribute value (using name instead of ID) + setVariantParams({ + [attributeName]: valueName, + }); + }; + + const handleQuantityChange = (change: number) => { + setQuantity(prev => Math.max(1, prev + change)); + }; + + const handleThumbnailClick = (index: number) => { + // Scroll both carousels to the selected index + carouselApi?.scrollTo(index); + thumbnailApi?.scrollTo(index); + }; + + // Check if product is available for purchase + const isOutOfStock = selectedSku + ? (() => { + const availableCount = + selectedSku.inventoryCounts?.edges?.find( + edge => edge?.node?.type === 'AVAILABLE' + )?.node?.quantity ?? 0; + return availableCount === 0; + })() + : false; + + const canAddToCart = !isOutOfStock && (!attributes.length || selectedSku); + + const handleAddToCart = () => { + // Placeholder for add to cart functionality + }; + + return ( +
+ {/* Product Images */} +
+ {/* Main Image Carousel */} +
+ {isOnSale && ( + + SALE + + )} + + + {images.length > 0 ? ( + images.map((image: string, index: number) => ( + + + {`${title} + + + )) + ) : ( + + +
+ No image available +
+
+
+ )} +
+ {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}

+ + {/* Price */} +
+ + {isPriceRange + ? `${formatCurrency(priceMin)} - ${formatCurrency(priceMax)}` + : formatCurrency(priceMin)} + + {isOnSale && compareAtMin && ( + + {isCompareAtPriceRange + ? `${formatCurrency(compareAtMin)} - ${formatCurrency(compareAtMax!)}` + : formatCurrency(compareAtMin)} + + )} +
+
+ + {/* 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 && ( +
+
+ Loading variant details... +
+ )} + {!isSkuLoading && matchedSkus.length === 0 && ( +
+ This combination is not available. Please select different + options. +
+ )} + {!isSkuLoading && matchedSkus.length > 1 && ( +
+ {matchedSkus.length} variants match your selection. Select + more attributes to narrow down. +
+ )} +
+ )} +
+ )} + + {/* Quantity Selector */} +
+ +
+ + + {quantity} + + +
+
+ + {/* Add to Cart Button */} + + + {/* Additional Product Information */} +
+ {product?.type && ( +
+ Product Type: + + {product.type} + +
+ )} + {product?.id && ( +
+ Product ID: + + {product.id} + +
+ )} + {selectedSku && ( + <> +
+ Selected SKU: + + {selectedSku.code} + +
+ {selectedSku.inventoryCounts?.edges && + selectedSku.inventoryCounts.edges.length > 0 && ( +
+ Stock Status: + + {(() => { + const availableCount = + selectedSku.inventoryCounts.edges.find( + edge => edge?.node?.type === 'AVAILABLE' + )?.node?.quantity ?? 0; + if (availableCount === 0) return 'Out of Stock'; + if (availableCount < 10) + return `Low Stock (${availableCount})`; + return 'In Stock'; + })()} + +
+ )} + + )} +
+
+
+ ); +} + +export type { ProductDetailsProps }; 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..dc0fe042 --- /dev/null +++ b/packages/react/src/components/storefront/product-grid.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { getSkuGroups } from '@/lib/godaddy/godaddy'; +import { ProductCard } from './product-card'; + +interface ProductGridProps { + storeId?: string; + clientId?: string; + first?: number; + getProductHref?: (productId: string) => string; +} + +function ProductGridSkeleton({ count = 6 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +export function ProductGrid({ + storeId: storeIdProp, + clientId: clientIdProp, + first = 100, + getProductHref, +}: ProductGridProps) { + const context = useGoDaddyContext(); + const storeId = storeIdProp || context.storeId; + const clientId = clientIdProp || context.clientId; + + const { data, isLoading, error } = useQuery({ + queryKey: ['sku-groups', { storeId, clientId, first }], + queryFn: () => + getSkuGroups({ first }, storeId!, clientId!, context?.apiHost), + enabled: !!storeId && !!clientId, + }); + + if (isLoading || !data) { + return ; + } + + if (error) { + return
Error loading products: {error.message}
; + } + + const skuGroups = data?.skuGroups?.edges; + + return ( +
+ {skuGroups?.map(edge => { + const group = edge?.node; + if (!group?.id) return null; + + const href = getProductHref?.(group.id); + 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/components/ui/carousel.tsx b/packages/react/src/components/ui/carousel.tsx new file mode 100644 index 00000000..df109b6f --- /dev/null +++ b/packages/react/src/components/ui/carousel.tsx @@ -0,0 +1,238 @@ +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from 'embla-carousel-react'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: 'horizontal' | 'vertical'; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error('useCarousel must be used within a '); + } + + return context; +} + +function Carousel({ + orientation = 'horizontal', + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<'div'> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === 'horizontal' ? 'x' : 'y', + }, + plugins + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((carouselApi: CarouselApi) => { + if (!carouselApi) return; + setCanScrollPrev(carouselApi.canScrollPrev()); + setCanScrollNext(carouselApi.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollPrev(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext] + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on('reInit', onSelect); + api.on('select', onSelect); + + return () => { + api?.off('select', onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = 'outline', + size = 'icon', + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = 'outline', + size = 'icon', + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}; diff --git a/packages/react/src/components/ui/link.tsx b/packages/react/src/components/ui/link.tsx new file mode 100644 index 00000000..b0062e7a --- /dev/null +++ b/packages/react/src/components/ui/link.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { LinkComponentProps, useGoDaddyContext } from '@/godaddy-provider'; + +/** + * Default Link implementation that falls back to a regular anchor tag + * if no custom Link component is provided via GoDaddyProvider + */ +const DefaultLink = React.forwardRef( + ({ href, children, ...props }, ref) => { + return ( + + {children} + + ); + } +); +DefaultLink.displayName = 'DefaultLink'; + +/** + * RouterLink component that uses the Link component from GoDaddyProvider context + * or falls back to a default anchor implementation + */ +export const RouterLink = React.forwardRef< + HTMLAnchorElement, + LinkComponentProps +>((props, ref) => { + const { Link } = useGoDaddyContext(); + const LinkComponent = Link || DefaultLink; + + return ; +}); +RouterLink.displayName = 'RouterLink'; diff --git a/packages/react/src/godaddy-provider.tsx b/packages/react/src/godaddy-provider.tsx index 4cb1519f..f39e3bed 100644 --- a/packages/react/src/godaddy-provider.tsx +++ b/packages/react/src/godaddy-provider.tsx @@ -1,3 +1,5 @@ +'use client'; + import { enUs } from '@godaddy/localizations'; import { QueryClient, @@ -5,6 +7,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({ @@ -66,6 +71,16 @@ interface GoDaddyContextValue { appearance?: GoDaddyAppearance; debug?: boolean; apiHost?: string; + clientId?: string; + storeId?: string; + Link?: React.ComponentType; +} + +export interface LinkComponentProps { + href: string; + children: React.ReactNode; + className?: string; + [key: string]: any; } const godaddyContext = React.createContext({ @@ -89,7 +104,10 @@ export interface GoDaddyProviderProps { * - "https://checkout.commerce.api.test-godaddy.com" for TEST environment */ apiHost?: string; + clientId?: string; + storeId?: string; queryClient?: QueryClient; + Link?: React.ComponentType; children: QueryClientProviderProps['children']; } @@ -98,7 +116,10 @@ export function GoDaddyProvider({ appearance, debug, apiHost, + clientId, + storeId, queryClient: providedQueryClient, + Link, children, }: GoDaddyProviderProps) { // Create a new QueryClient per component instance for SSR safety @@ -120,6 +141,67 @@ 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(/data:/gi, '') // Remove data: protocol + .replace(/vbscript:/gi, '') // Remove vbscript: 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 && ( +