create-nymbal-app 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/bin/create-nymbal-app.mjs +9 -0
  2. package/dist/index.d.ts +37 -0
  3. package/dist/index.js +419 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +41 -7
  6. package/scaffold/project/astro/src/pages/index.astro +57 -0
  7. package/scaffold/templates/astro/README.md +26 -0
  8. package/scaffold/templates/astro/_dot.env.example +6 -0
  9. package/scaffold/templates/astro/_dot.gitignore +16 -0
  10. package/scaffold/templates/astro/_dot.node-version +1 -0
  11. package/scaffold/templates/astro/_dot.npmrc +1 -0
  12. package/scaffold/templates/astro/astro.config.mjs +14 -0
  13. package/scaffold/templates/astro/package.json +59 -0
  14. package/scaffold/templates/astro/src/components/AddressForm.astro +107 -0
  15. package/scaffold/templates/astro/src/components/Breadcrumb.astro +20 -0
  16. package/scaffold/templates/astro/src/components/CategoryNav.astro +27 -0
  17. package/scaffold/templates/astro/src/components/CheckoutSteps.astro +29 -0
  18. package/scaffold/templates/astro/src/components/Footer.astro +108 -0
  19. package/scaffold/templates/astro/src/components/Header.astro +192 -0
  20. package/scaffold/templates/astro/src/components/HeroSection.astro +17 -0
  21. package/scaffold/templates/astro/src/components/ImageGallery.astro +42 -0
  22. package/scaffold/templates/astro/src/components/NewsletterSignup.astro +39 -0
  23. package/scaffold/templates/astro/src/components/OrderSummary.astro +53 -0
  24. package/scaffold/templates/astro/src/components/Pagination.astro +13 -0
  25. package/scaffold/templates/astro/src/components/PriceDisplay.astro +25 -0
  26. package/scaffold/templates/astro/src/components/ProductCard.astro +44 -0
  27. package/scaffold/templates/astro/src/components/RelatedProducts.astro +28 -0
  28. package/scaffold/templates/astro/src/components/ReviewForm.astro +100 -0
  29. package/scaffold/templates/astro/src/components/ReviewList.astro +41 -0
  30. package/scaffold/templates/astro/src/components/SkipNav.astro +3 -0
  31. package/scaffold/templates/astro/src/components/SortDropdown.astro +26 -0
  32. package/scaffold/templates/astro/src/components/StockBadge.astro +32 -0
  33. package/scaffold/templates/astro/src/env.d.ts +2 -0
  34. package/scaffold/templates/astro/src/layouts/AccountLayout.astro +168 -0
  35. package/scaffold/templates/astro/src/layouts/BaseLayout.astro +55 -0
  36. package/scaffold/templates/astro/src/lib/api.ts +89 -0
  37. package/scaffold/templates/astro/src/lib/client.ts +13 -0
  38. package/scaffold/templates/astro/src/lib/format.ts +17 -0
  39. package/scaffold/templates/astro/src/lib/order-status.ts +14 -0
  40. package/scaffold/templates/astro/src/lib/seo.ts +62 -0
  41. package/scaffold/templates/astro/src/pages/404.astro +18 -0
  42. package/scaffold/templates/astro/src/pages/500.astro +17 -0
  43. package/scaffold/templates/astro/src/pages/account/addresses.astro +110 -0
  44. package/scaffold/templates/astro/src/pages/account/index.astro +67 -0
  45. package/scaffold/templates/astro/src/pages/account/login.astro +55 -0
  46. package/scaffold/templates/astro/src/pages/account/orders/[orderNumber].astro +108 -0
  47. package/scaffold/templates/astro/src/pages/account/orders.astro +81 -0
  48. package/scaffold/templates/astro/src/pages/account/profile.astro +131 -0
  49. package/scaffold/templates/astro/src/pages/account/register.astro +94 -0
  50. package/scaffold/templates/astro/src/pages/cart.astro +16 -0
  51. package/scaffold/templates/astro/src/pages/category/[slug].astro +73 -0
  52. package/scaffold/templates/astro/src/pages/checkout.astro +100 -0
  53. package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -0
  54. package/scaffold/templates/astro/src/pages/fragments/header.astro +5 -0
  55. package/scaffold/templates/astro/src/pages/fragments/nav.astro +9 -0
  56. package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +14 -0
  57. package/scaffold/templates/astro/src/pages/index.astro +49 -0
  58. package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +127 -0
  59. package/scaffold/templates/astro/src/pages/product/[slug].astro +107 -0
  60. package/scaffold/templates/astro/src/pages/products/index.astro +62 -0
  61. package/scaffold/templates/astro/src/pages/search.astro +37 -0
  62. package/scaffold/templates/astro/src/scripts/account-guard.ts +22 -0
  63. package/scaffold/templates/astro/src/scripts/auth-forms.ts +89 -0
  64. package/scaffold/templates/astro/src/scripts/cart-page.ts +143 -0
  65. package/scaffold/templates/astro/src/scripts/checkout.ts +277 -0
  66. package/scaffold/templates/astro/src/scripts/image-gallery.ts +22 -0
  67. package/scaffold/templates/astro/src/scripts/init-client.ts +16 -0
  68. package/scaffold/templates/astro/src/scripts/order-status.ts +27 -0
  69. package/scaffold/templates/astro/src/scripts/pdp.ts +67 -0
  70. package/scaffold/templates/astro/src/scripts/plp.ts +109 -0
  71. package/scaffold/templates/astro/src/scripts/search.ts +145 -0
  72. package/scaffold/templates/astro/src/styles/global.css +362 -0
  73. package/scaffold/templates/astro/src/styles/pages/account.css +331 -0
  74. package/scaffold/templates/astro/src/styles/pages/cart.css +243 -0
  75. package/scaffold/templates/astro/src/styles/pages/checkout.css +270 -0
  76. package/scaffold/templates/astro/src/styles/pages/confirmation.css +142 -0
  77. package/scaffold/templates/astro/src/styles/pages/error.css +69 -0
  78. package/scaffold/templates/astro/src/styles/pages/home.css +269 -0
  79. package/scaffold/templates/astro/src/styles/pages/pdp.css +272 -0
  80. package/scaffold/templates/astro/src/styles/pages/plp.css +213 -0
  81. package/scaffold/templates/astro/src/styles/pages/search.css +152 -0
  82. package/scaffold/templates/astro/tsconfig.json +3 -0
  83. package/scaffold/templates/nextjs/README.md +26 -0
  84. package/scaffold/templates/nextjs/_dot.env.example +6 -0
  85. package/scaffold/templates/nextjs/_dot.gitignore +17 -0
  86. package/scaffold/templates/nextjs/_dot.node-version +1 -0
  87. package/scaffold/templates/nextjs/_dot.npmrc +1 -0
  88. package/scaffold/templates/nextjs/app/account/addresses/page.tsx +10 -0
  89. package/scaffold/templates/nextjs/app/account/layout.tsx +57 -0
  90. package/scaffold/templates/nextjs/app/account/login/page.tsx +23 -0
  91. package/scaffold/templates/nextjs/app/account/orders/[orderNumber]/page.tsx +192 -0
  92. package/scaffold/templates/nextjs/app/account/orders/page.tsx +123 -0
  93. package/scaffold/templates/nextjs/app/account/page.tsx +59 -0
  94. package/scaffold/templates/nextjs/app/account/profile/page.tsx +10 -0
  95. package/scaffold/templates/nextjs/app/account/register/page.tsx +10 -0
  96. package/scaffold/templates/nextjs/app/cart/page.tsx +10 -0
  97. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +58 -0
  98. package/scaffold/templates/nextjs/app/checkout/page.tsx +10 -0
  99. package/scaffold/templates/nextjs/app/error.tsx +49 -0
  100. package/scaffold/templates/nextjs/app/globals.css +473 -0
  101. package/scaffold/templates/nextjs/app/layout.tsx +29 -0
  102. package/scaffold/templates/nextjs/app/not-found.tsx +38 -0
  103. package/scaffold/templates/nextjs/app/order/[orderNumber]/page.tsx +127 -0
  104. package/scaffold/templates/nextjs/app/page.tsx +63 -0
  105. package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +111 -0
  106. package/scaffold/templates/nextjs/app/products/page.tsx +40 -0
  107. package/scaffold/templates/nextjs/app/search/page.tsx +24 -0
  108. package/scaffold/templates/nextjs/components/client/AddressManager.tsx +181 -0
  109. package/scaffold/templates/nextjs/components/client/AuthGuard.tsx +55 -0
  110. package/scaffold/templates/nextjs/components/client/CartDrawerToggle.tsx +39 -0
  111. package/scaffold/templates/nextjs/components/client/CartPage.tsx +120 -0
  112. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +523 -0
  113. package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +68 -0
  114. package/scaffold/templates/nextjs/components/client/LoginForm.tsx +89 -0
  115. package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +12 -0
  116. package/scaffold/templates/nextjs/components/client/NewsletterSignup.tsx +74 -0
  117. package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +24 -0
  118. package/scaffold/templates/nextjs/components/client/OrderStatusRefresh.tsx +48 -0
  119. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +148 -0
  120. package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +61 -0
  121. package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +112 -0
  122. package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +131 -0
  123. package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +116 -0
  124. package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +27 -0
  125. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +141 -0
  126. package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +35 -0
  127. package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +36 -0
  128. package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +29 -0
  129. package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +60 -0
  130. package/scaffold/templates/nextjs/components/server/Footer.module.css +64 -0
  131. package/scaffold/templates/nextjs/components/server/Footer.tsx +79 -0
  132. package/scaffold/templates/nextjs/components/server/Header.module.css +147 -0
  133. package/scaffold/templates/nextjs/components/server/Header.tsx +102 -0
  134. package/scaffold/templates/nextjs/components/server/HeroSection.tsx +18 -0
  135. package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +79 -0
  136. package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +31 -0
  137. package/scaffold/templates/nextjs/components/server/ProductCard.tsx +59 -0
  138. package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +31 -0
  139. package/scaffold/templates/nextjs/components/server/ReviewList.tsx +62 -0
  140. package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -0
  141. package/scaffold/templates/nextjs/components/server/StockBadge.tsx +19 -0
  142. package/scaffold/templates/nextjs/lib/api.ts +89 -0
  143. package/scaffold/templates/nextjs/lib/client.ts +23 -0
  144. package/scaffold/templates/nextjs/lib/format.ts +7 -0
  145. package/scaffold/templates/nextjs/lib/order-status.ts +14 -0
  146. package/scaffold/templates/nextjs/lib/seo.ts +73 -0
  147. package/scaffold/templates/nextjs/next.config.mjs +9 -0
  148. package/scaffold/templates/nextjs/package.json +64 -0
  149. package/scaffold/templates/nextjs/styles/pages/account.module.css +377 -0
  150. package/scaffold/templates/nextjs/styles/pages/cart.module.css +240 -0
  151. package/scaffold/templates/nextjs/styles/pages/checkout.module.css +404 -0
  152. package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +237 -0
  153. package/scaffold/templates/nextjs/styles/pages/error.module.css +132 -0
  154. package/scaffold/templates/nextjs/styles/pages/home.module.css +285 -0
  155. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +319 -0
  156. package/scaffold/templates/nextjs/styles/pages/plp.module.css +280 -0
  157. package/scaffold/templates/nextjs/styles/pages/search.module.css +192 -0
  158. package/scaffold/templates/nextjs/tsconfig.json +41 -0
  159. package/README.md +0 -4
@@ -0,0 +1,63 @@
1
+ import Link from 'next/link'
2
+ import { fetchProducts, fetchCategories } from '@/lib/api'
3
+ import { organizationJsonLd } from '@/lib/seo'
4
+ import { HeroSection } from '@/components/server/HeroSection'
5
+ import { CategoryNav } from '@/components/server/CategoryNav'
6
+ import { ProductCard } from '@/components/server/ProductCard'
7
+ import { NewsletterSignup } from '@/components/client/NewsletterSignup'
8
+ import styles from '@/styles/pages/home.module.css'
9
+
10
+ export default async function HomePage() {
11
+ const [{ items: products }, categories] = await Promise.all([
12
+ fetchProducts({ limit: 8 }),
13
+ fetchCategories(),
14
+ ])
15
+
16
+ const jsonLd = organizationJsonLd(
17
+ 'Nymbal Store',
18
+ process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
19
+ )
20
+
21
+ return (
22
+ <>
23
+ <script
24
+ type="application/ld+json"
25
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
26
+ />
27
+
28
+ <HeroSection />
29
+
30
+ <CategoryNav categories={categories} />
31
+
32
+ <section className={`container ${styles.featuredSection}`}>
33
+ <div className={styles.featuredHeader}>
34
+ <h2 className={styles.featuredTitle}>Featured Products</h2>
35
+ <Link href="/products" className={styles.featuredViewAll}>
36
+ View All
37
+ </Link>
38
+ </div>
39
+ {products.length > 0 ? (
40
+ <div className="product-grid">
41
+ {products.map((product) => (
42
+ <ProductCard
43
+ key={product.id}
44
+ product={{
45
+ slug: product.slug,
46
+ name: product.name,
47
+ priceMinor: product.priceMinor ?? null,
48
+ priceRange: product.priceRange ?? null,
49
+ currency: product.currency ?? null,
50
+ media: product.media,
51
+ }}
52
+ />
53
+ ))}
54
+ </div>
55
+ ) : (
56
+ <p className="text-muted text-center">No products available yet. Check back soon.</p>
57
+ )}
58
+ </section>
59
+
60
+ <NewsletterSignup />
61
+ </>
62
+ )
63
+ }
@@ -0,0 +1,111 @@
1
+ import type { Metadata } from 'next'
2
+ import { notFound } from 'next/navigation'
3
+ import { fetchProduct, fetchProducts, fetchReviews } from '@/lib/api'
4
+ import { productJsonLd } from '@/lib/seo'
5
+ import { Breadcrumb } from '@/components/server/Breadcrumb'
6
+ import { ReviewList } from '@/components/server/ReviewList'
7
+ import { RelatedProducts } from '@/components/server/RelatedProducts'
8
+ import { ImageGallery } from '@/components/client/ImageGallery'
9
+ import { ProductInteractive } from '@/components/client/ProductInteractive'
10
+ import { ReviewForm } from '@/components/client/ReviewForm'
11
+ import styles from '@/styles/pages/pdp.module.css'
12
+
13
+ interface ProductPageProps {
14
+ params: Promise<{ slug: string }>
15
+ }
16
+
17
+ export async function generateMetadata({
18
+ params,
19
+ }: ProductPageProps): Promise<Metadata> {
20
+ const { slug } = await params
21
+ const product = await fetchProduct(slug)
22
+
23
+ if (!product) {
24
+ return { title: 'Product Not Found' }
25
+ }
26
+
27
+ return {
28
+ title: product.name,
29
+ description: product.shortDescription ?? product.description ?? `Buy ${product.name}.`,
30
+ }
31
+ }
32
+
33
+ export default async function ProductPage({ params }: ProductPageProps) {
34
+ const { slug } = await params
35
+ const product = await fetchProduct(slug)
36
+
37
+ if (!product) notFound()
38
+
39
+ const [{ items: reviews }, { items: relatedProducts }] = await Promise.all([
40
+ fetchReviews(product.id),
41
+ fetchProducts({ limit: 4 }),
42
+ ])
43
+
44
+ const related = relatedProducts.filter((p) => p.id !== product.id).slice(0, 4)
45
+
46
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
47
+ const jsonLd = productJsonLd(product, baseUrl)
48
+
49
+ const mediaArray = Array.isArray(product.media) ? product.media : []
50
+ const images = mediaArray.length > 0
51
+ ? mediaArray.map((m: Record<string, string>) => ({
52
+ url: m.url,
53
+ alt: m.alt ?? product.name,
54
+ }))
55
+ : [{ url: `https://picsum.photos/seed/${product.slug}/600/600`, alt: product.name }]
56
+
57
+ return (
58
+ <>
59
+ <script
60
+ type="application/ld+json"
61
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
62
+ />
63
+
64
+ <div className={`container ${styles.page}`}>
65
+ <Breadcrumb
66
+ items={[
67
+ { label: 'Home', href: '/' },
68
+ { label: 'Products', href: '/products' },
69
+ { label: product.name },
70
+ ]}
71
+ />
72
+
73
+ <div className={styles.productLayout}>
74
+ <ImageGallery images={images} />
75
+
76
+ <div className={styles.info}>
77
+ <ProductInteractive product={product} />
78
+
79
+ {product.description && (
80
+ <div className={styles.description}>
81
+ <p>{product.description}</p>
82
+ </div>
83
+ )}
84
+
85
+ <div className={styles.detailsSection}>
86
+ <details className={styles.detailsItem}>
87
+ <summary className={styles.detailsSummary}>Details</summary>
88
+ <div className={styles.detailsContent}>
89
+ {product.description ?? 'No additional details available.'}
90
+ </div>
91
+ </details>
92
+ <details className={styles.detailsItem}>
93
+ <summary className={styles.detailsSummary}>
94
+ Shipping & Returns
95
+ </summary>
96
+ <div className={styles.detailsContent}>
97
+ Free standard shipping on orders over $50. Returns accepted within
98
+ 30 days of purchase.
99
+ </div>
100
+ </details>
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ <ReviewList reviews={reviews} />
106
+ <ReviewForm productId={product.id} />
107
+ <RelatedProducts products={related} />
108
+ </div>
109
+ </>
110
+ )
111
+ }
@@ -0,0 +1,40 @@
1
+ import type { Metadata } from 'next'
2
+ import { fetchProducts, fetchCategories, sortFromSearchParams } from '@/lib/api'
3
+ import { Breadcrumb } from '@/components/server/Breadcrumb'
4
+ import { ProductGrid } from '@/components/client/ProductGrid'
5
+ import styles from '@/styles/pages/plp.module.css'
6
+
7
+ export const metadata: Metadata = {
8
+ title: 'All Products',
9
+ description: 'Browse our full collection of products.',
10
+ }
11
+
12
+ interface ProductsPageProps {
13
+ searchParams: Promise<{ sort?: string | string[] }>
14
+ }
15
+
16
+ export default async function ProductsPage({ searchParams }: ProductsPageProps) {
17
+ const sort = sortFromSearchParams(await searchParams)
18
+ const [{ items, nextCursor }, categories] = await Promise.all([
19
+ fetchProducts({ limit: 24, sort }),
20
+ fetchCategories(),
21
+ ])
22
+
23
+ return (
24
+ <div className={`container ${styles.page}`}>
25
+ <Breadcrumb
26
+ items={[
27
+ { label: 'Home', href: '/' },
28
+ { label: 'Products' },
29
+ ]}
30
+ />
31
+ <h1 className={styles.pageTitle}>All Products</h1>
32
+ <ProductGrid
33
+ initialProducts={items}
34
+ initialCursor={nextCursor}
35
+ initialSort={sort}
36
+ categories={categories}
37
+ />
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,24 @@
1
+ import type { Metadata } from 'next'
2
+ import { Suspense } from 'react'
3
+ import { SearchResults } from '@/components/client/SearchResults'
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'Search',
7
+ description: 'Search our product catalog.',
8
+ }
9
+
10
+ export default function SearchPage() {
11
+ return (
12
+ <Suspense
13
+ fallback={
14
+ <div className="loading-center">
15
+ <div className="spinner spinner-lg" role="status">
16
+ <span className="visually-hidden">Loading...</span>
17
+ </div>
18
+ </div>
19
+ }
20
+ >
21
+ <SearchResults />
22
+ </Suspense>
23
+ )
24
+ }
@@ -0,0 +1,181 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState, type FormEvent } from 'react'
4
+ import { useNymbalClient } from '@nymbalhq/react'
5
+ import type { Address, CustomerAddress } from '@nymbalhq/types'
6
+ import styles from '@/styles/pages/account.module.css'
7
+
8
+ // The same fields and test ids as the Astro template's AddressForm (`new-address-<name>`).
9
+ const FIELDS: Array<{
10
+ name: keyof Address
11
+ label: string
12
+ autoComplete: string
13
+ required: boolean
14
+ full?: boolean
15
+ placeholder?: string
16
+ }> = [
17
+ { name: 'firstName', label: 'First Name', autoComplete: 'given-name', required: true },
18
+ { name: 'lastName', label: 'Last Name', autoComplete: 'family-name', required: true },
19
+ {
20
+ name: 'addressLine1',
21
+ label: 'Address Line 1',
22
+ autoComplete: 'address-line1',
23
+ required: true,
24
+ full: true,
25
+ },
26
+ {
27
+ name: 'addressLine2',
28
+ label: 'Address Line 2 (optional)',
29
+ autoComplete: 'address-line2',
30
+ required: false,
31
+ full: true,
32
+ },
33
+ { name: 'city', label: 'City', autoComplete: 'address-level2', required: true },
34
+ { name: 'region', label: 'State / County', autoComplete: 'address-level1', required: true },
35
+ { name: 'postalCode', label: 'Postcode', autoComplete: 'postal-code', required: true },
36
+ {
37
+ name: 'country',
38
+ label: 'Country (2-letter code)',
39
+ autoComplete: 'country',
40
+ required: true,
41
+ placeholder: 'GB',
42
+ },
43
+ ]
44
+
45
+ export function AddressManager() {
46
+ const client = useNymbalClient()
47
+ const [addresses, setAddresses] = useState<CustomerAddress[] | null>(null)
48
+ const [loadError, setLoadError] = useState(false)
49
+ const [saving, setSaving] = useState(false)
50
+ const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
51
+
52
+ const load = useCallback(async () => {
53
+ try {
54
+ setAddresses(await client.customer.addresses.list())
55
+ setLoadError(false)
56
+ } catch {
57
+ setLoadError(true)
58
+ }
59
+ }, [client])
60
+
61
+ useEffect(() => {
62
+ void load()
63
+ }, [load])
64
+
65
+ async function handleSubmit(e: FormEvent<HTMLFormElement>) {
66
+ e.preventDefault()
67
+ const form = e.currentTarget
68
+ const data = new FormData(form)
69
+ const field = (name: string) => String(data.get(name) ?? '').trim()
70
+ const address: Address = {
71
+ firstName: field('firstName'),
72
+ lastName: field('lastName'),
73
+ addressLine1: field('addressLine1'),
74
+ city: field('city'),
75
+ region: field('region'),
76
+ postalCode: field('postalCode'),
77
+ country: field('country'),
78
+ ...(field('addressLine2') && { addressLine2: field('addressLine2') }),
79
+ }
80
+
81
+ setSaving(true)
82
+ setMessage(null)
83
+ try {
84
+ await client.customer.addresses.create(address)
85
+ form.reset()
86
+ setMessage({ ok: true, text: 'Address saved successfully.' })
87
+ await load()
88
+ } catch {
89
+ setMessage({ ok: false, text: 'Failed to save address. Please try again.' })
90
+ } finally {
91
+ setSaving(false)
92
+ }
93
+ }
94
+
95
+ return (
96
+ <div>
97
+ <h2 className={styles.pageTitle}>Addresses</h2>
98
+
99
+ <div data-testid="addresses-list">
100
+ {addresses === null && !loadError && (
101
+ <p className="text-sm text-muted">Loading addresses...</p>
102
+ )}
103
+ {loadError && <p className="text-sm text-muted">Unable to load addresses.</p>}
104
+ {addresses !== null && addresses.length === 0 && (
105
+ <div className={styles.emptyOrders}>
106
+ <h3 className={styles.emptyOrdersTitle}>No saved addresses</h3>
107
+ <p className={styles.emptyOrdersText}>Add an address below to get started.</p>
108
+ </div>
109
+ )}
110
+ {addresses?.map((addr) => (
111
+ <div
112
+ key={addr.id}
113
+ data-testid={`address-${addr.id}`}
114
+ className={styles.formCard}
115
+ style={{ marginBottom: 'var(--nymbal-spacing-md)' }}
116
+ >
117
+ <p style={{ fontSize: 'var(--nymbal-font-size-sm)', lineHeight: 1.6 }}>
118
+ {addr.firstName} {addr.lastName}
119
+ <br />
120
+ {addr.addressLine1}
121
+ {addr.addressLine2 && <>, {addr.addressLine2}</>}
122
+ <br />
123
+ {addr.city}, {addr.region} {addr.postalCode}
124
+ <br />
125
+ {addr.country}
126
+ </p>
127
+ <div
128
+ style={{
129
+ display: 'flex',
130
+ gap: 'var(--nymbal-spacing-sm)',
131
+ marginTop: 'var(--nymbal-spacing-sm)',
132
+ }}
133
+ >
134
+ {addr.isDefaultShipping && <span className="badge">Default Shipping</span>}
135
+ {addr.isDefaultBilling && <span className="badge">Default Billing</span>}
136
+ </div>
137
+ </div>
138
+ ))}
139
+ </div>
140
+
141
+ <div className={styles.formCard} style={{ marginTop: 'var(--nymbal-spacing-xl)' }}>
142
+ <h3 className={styles.formCardTitle}>Add New Address</h3>
143
+ {message && (
144
+ <div
145
+ className={`${styles.alert} ${message.ok ? styles.alertSuccess : styles.alertError}`}
146
+ >
147
+ {message.text}
148
+ </div>
149
+ )}
150
+ <form onSubmit={handleSubmit} data-testid="add-address-form">
151
+ <div className={styles.formGrid}>
152
+ {FIELDS.map((f) => (
153
+ <div key={f.name} className={f.full ? styles.formGridFull : undefined}>
154
+ <label htmlFor={`new-address-${f.name}`}>{f.label}</label>
155
+ <input
156
+ id={`new-address-${f.name}`}
157
+ name={f.name}
158
+ type="text"
159
+ required={f.required}
160
+ autoComplete={f.autoComplete}
161
+ placeholder={f.placeholder}
162
+ data-testid={`new-address-${f.name}`}
163
+ />
164
+ </div>
165
+ ))}
166
+ </div>
167
+ <div className={styles.formActions}>
168
+ <button
169
+ type="submit"
170
+ className={styles.saveButton}
171
+ disabled={saving}
172
+ data-testid="save-address"
173
+ >
174
+ {saving ? 'Saving...' : 'Save Address'}
175
+ </button>
176
+ </div>
177
+ </form>
178
+ </div>
179
+ </div>
180
+ )
181
+ }
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, type ReactNode } from 'react'
4
+ import { useRouter, usePathname } from 'next/navigation'
5
+ import { useAuth } from '@nymbalhq/react'
6
+ import { restoreSession } from '@/lib/client'
7
+
8
+ export const PUBLIC_AUTH_PATHS = ['/account/login', '/account/register']
9
+
10
+ export function AuthGuard({ children }: { children: ReactNode }) {
11
+ const { isAuthenticated, loading } = useAuth()
12
+ const router = useRouter()
13
+ const pathname = usePathname()
14
+ // A stored token is only known to be valid once its profile has loaded; redirecting before that would sign a
15
+ // reloading shopper out.
16
+ const [restored, setRestored] = useState(false)
17
+
18
+ const isPublicAuthPath = PUBLIC_AUTH_PATHS.includes(pathname)
19
+
20
+ useEffect(() => {
21
+ let active = true
22
+ void restoreSession().finally(() => {
23
+ if (active) setRestored(true)
24
+ })
25
+ return () => {
26
+ active = false
27
+ }
28
+ }, [])
29
+
30
+ useEffect(() => {
31
+ if (restored && !loading && !isAuthenticated && !isPublicAuthPath) {
32
+ router.replace(`/account/login?redirect=${encodeURIComponent(pathname)}`)
33
+ }
34
+ }, [restored, loading, isAuthenticated, isPublicAuthPath, pathname, router])
35
+
36
+ if (isPublicAuthPath) {
37
+ return <>{children}</>
38
+ }
39
+
40
+ if (!restored || loading) {
41
+ return (
42
+ <div className="loading-center">
43
+ <div className="spinner spinner-lg" role="status">
44
+ <span className="visually-hidden">Loading...</span>
45
+ </div>
46
+ </div>
47
+ )
48
+ }
49
+
50
+ if (!isAuthenticated) {
51
+ return null
52
+ }
53
+
54
+ return <>{children}</>
55
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { CartDrawer } from '@nymbalhq/react'
6
+
7
+ export function CartDrawerToggle() {
8
+ const [open, setOpen] = useState(false)
9
+ const router = useRouter()
10
+
11
+ const handleCheckoutClick = useCallback(() => {
12
+ setOpen(false)
13
+ router.push('/checkout')
14
+ }, [router])
15
+
16
+ useEffect(() => {
17
+ function handleOpen() {
18
+ setOpen(true)
19
+ }
20
+
21
+ function handleClose() {
22
+ setOpen(false)
23
+ }
24
+
25
+ window.addEventListener('nymbal:cart:open', handleOpen)
26
+ window.addEventListener('nymbal:cart:close', handleClose)
27
+ return () => {
28
+ window.removeEventListener('nymbal:cart:open', handleOpen)
29
+ window.removeEventListener('nymbal:cart:close', handleClose)
30
+ }
31
+ }, [])
32
+
33
+ return (
34
+ <CartDrawer
35
+ open={open}
36
+ onCheckoutClick={handleCheckoutClick}
37
+ />
38
+ )
39
+ }
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import Link from 'next/link'
4
+ import Image from 'next/image'
5
+ import { useCart, QuantitySelector } from '@nymbalhq/react'
6
+ import { formatPrice } from '@/lib/format'
7
+ import styles from '@/styles/pages/cart.module.css'
8
+
9
+ export function CartPage() {
10
+ const { items, subtotalMinor, currency, removeItem, updateQuantity, loading } = useCart()
11
+
12
+ if (loading) {
13
+ return (
14
+ <div className="loading-center">
15
+ <div className="spinner spinner-lg" role="status">
16
+ <span className="visually-hidden">Loading cart...</span>
17
+ </div>
18
+ </div>
19
+ )
20
+ }
21
+
22
+ if (!items || items.length === 0) {
23
+ return (
24
+ <div className={`container ${styles.page}`}>
25
+ <h1 className={styles.pageTitle}>Shopping Cart</h1>
26
+ <div className={styles.empty}>
27
+ <h2 className={styles.emptyTitle}>Your cart is empty</h2>
28
+ <p className={styles.emptyText}>
29
+ Looks like you have not added anything to your cart yet.
30
+ </p>
31
+ <Link href="/products" className="btn btn-primary" data-testid="continue-shopping">
32
+ Continue Shopping
33
+ </Link>
34
+ </div>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ const cartCurrency = currency ?? 'GBP'
40
+
41
+ return (
42
+ <div className={`container ${styles.page}`}>
43
+ <h1 className={styles.pageTitle}>Shopping Cart</h1>
44
+ <div className={styles.layout}>
45
+ <div className={styles.itemsSection}>
46
+ <div className={styles.itemsList}>
47
+ {items.map((item) => (
48
+ <div key={item.variantId} className={styles.item} data-testid={`cart-item-${item.variantId}`}>
49
+ <div className={styles.itemImage}>
50
+ {item.imageUrl ? (
51
+ <Image
52
+ src={item.imageUrl}
53
+ alt={item.productName}
54
+ width={96}
55
+ height={96}
56
+ />
57
+ ) : (
58
+ <span className="visually-hidden">No image</span>
59
+ )}
60
+ </div>
61
+ <div className={styles.itemDetails}>
62
+ <Link href={`/product/${item.productId}`} className={styles.itemName}>
63
+ {item.productName}
64
+ </Link>
65
+ {item.variantName && (
66
+ <span className={styles.itemVariant}>{item.variantName}</span>
67
+ )}
68
+ <div className={styles.itemActions}>
69
+ <QuantitySelector
70
+ value={item.qty}
71
+ min={1}
72
+ max={99}
73
+ onQuantityChange={(e) => updateQuantity(item.variantId, e.detail.value)}
74
+ />
75
+ <button
76
+ type="button"
77
+ className={styles.removeButton}
78
+ onClick={() => removeItem(item.variantId)}
79
+ aria-label={`Remove ${item.productName} from cart`}
80
+ >
81
+ Remove
82
+ </button>
83
+ </div>
84
+ </div>
85
+ <span className={styles.itemPrice}>
86
+ {formatPrice(item.priceMinor * item.qty, cartCurrency)}
87
+ </span>
88
+ </div>
89
+ ))}
90
+ </div>
91
+ </div>
92
+
93
+ <div className={styles.summary}>
94
+ <h2 className={styles.summaryTitle}>Order Summary</h2>
95
+ <div className={styles.summaryRow}>
96
+ <span className={styles.summaryLabel}>Subtotal</span>
97
+ <span className={styles.summaryValue}>
98
+ {formatPrice(subtotalMinor ?? 0, cartCurrency)}
99
+ </span>
100
+ </div>
101
+ <div className={styles.summaryRow}>
102
+ <span className={styles.summaryLabel}>Shipping</span>
103
+ <span className={styles.summaryValue}>Calculated at checkout</span>
104
+ </div>
105
+ <hr className={styles.summaryDivider} />
106
+ <div className={`${styles.summaryRow} ${styles.summaryTotal}`}>
107
+ <span>Estimated Total</span>
108
+ <span>{formatPrice(subtotalMinor ?? 0, cartCurrency)}</span>
109
+ </div>
110
+ <Link href="/checkout" className={styles.checkoutButton}>
111
+ Proceed to Checkout
112
+ </Link>
113
+ <Link href="/products" className={styles.continueShoppingLink}>
114
+ Continue Shopping
115
+ </Link>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ )
120
+ }