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,147 @@
1
+ .header {
2
+ position: sticky;
3
+ top: 0;
4
+ z-index: var(--nymbal-z-dropdown);
5
+ background-color: var(--nymbal-color-surface);
6
+ border-bottom: 1px solid var(--nymbal-color-border);
7
+ }
8
+
9
+ .inner {
10
+ display: flex;
11
+ align-items: center;
12
+ gap: var(--nymbal-spacing-lg);
13
+ height: 4rem;
14
+ position: relative;
15
+ }
16
+
17
+ .logo {
18
+ font-size: var(--nymbal-font-size-xl);
19
+ font-weight: var(--nymbal-font-weight-bold);
20
+ color: var(--nymbal-color-primary);
21
+ text-decoration: none;
22
+ letter-spacing: -0.02em;
23
+ flex-shrink: 0;
24
+ }
25
+
26
+ .logo:hover {
27
+ color: var(--nymbal-color-primary);
28
+ text-decoration: none;
29
+ }
30
+
31
+ .nav {
32
+ display: none;
33
+ }
34
+
35
+ .navList {
36
+ display: flex;
37
+ gap: var(--nymbal-spacing-lg);
38
+ list-style: none;
39
+ }
40
+
41
+ .navLink {
42
+ font-size: var(--nymbal-font-size-sm);
43
+ font-weight: var(--nymbal-font-weight-medium);
44
+ color: var(--nymbal-color-text);
45
+ text-decoration: none;
46
+ transition: color var(--nymbal-transition-fast);
47
+ }
48
+
49
+ .navLink:hover {
50
+ color: var(--nymbal-color-accent);
51
+ text-decoration: none;
52
+ }
53
+
54
+ .actions {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: var(--nymbal-spacing-md);
58
+ margin-left: auto;
59
+ }
60
+
61
+ .search {
62
+ display: none;
63
+ }
64
+
65
+ .iconLink {
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ color: var(--nymbal-color-text);
70
+ transition: color var(--nymbal-transition-fast);
71
+ }
72
+
73
+ .iconLink:hover {
74
+ color: var(--nymbal-color-accent);
75
+ }
76
+
77
+ /* ── Mobile menu (CSS-only) ───────────────────────────────────────── */
78
+
79
+ .menuCheckbox {
80
+ display: none;
81
+ }
82
+
83
+ .menuButton {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ cursor: pointer;
88
+ color: var(--nymbal-color-text);
89
+ order: 10;
90
+ }
91
+
92
+ .mobileNav {
93
+ display: none;
94
+ position: absolute;
95
+ top: 100%;
96
+ left: 0;
97
+ right: 0;
98
+ background: var(--nymbal-color-surface);
99
+ border-bottom: 1px solid var(--nymbal-color-border);
100
+ box-shadow: var(--nymbal-shadow-md);
101
+ z-index: var(--nymbal-z-dropdown);
102
+ }
103
+
104
+ .menuCheckbox:checked ~ .mobileNav {
105
+ display: block;
106
+ }
107
+
108
+ .mobileNavList {
109
+ list-style: none;
110
+ padding: var(--nymbal-spacing-md);
111
+ }
112
+
113
+ .mobileNavLink {
114
+ display: block;
115
+ padding: var(--nymbal-spacing-sm) 0;
116
+ font-size: var(--nymbal-font-size-base);
117
+ font-weight: var(--nymbal-font-weight-medium);
118
+ color: var(--nymbal-color-text);
119
+ text-decoration: none;
120
+ border-bottom: 1px solid var(--nymbal-color-border);
121
+ }
122
+
123
+ .mobileNavLink:hover {
124
+ color: var(--nymbal-color-accent);
125
+ text-decoration: none;
126
+ }
127
+
128
+ /* ── Desktop ──────────────────────────────────────────────────────── */
129
+
130
+ @media (min-width: 1024px) {
131
+ .nav {
132
+ display: block;
133
+ }
134
+
135
+ .search {
136
+ display: block;
137
+ width: 16rem;
138
+ }
139
+
140
+ .menuButton {
141
+ display: none;
142
+ }
143
+
144
+ .mobileNav {
145
+ display: none !important;
146
+ }
147
+ }
@@ -0,0 +1,102 @@
1
+ import Link from 'next/link'
2
+ import { SearchBarWrapper } from '@/components/client/SearchBarWrapper'
3
+ import { MiniCartWrapper } from '@/components/client/MiniCartWrapper'
4
+ import styles from './Header.module.css'
5
+
6
+ const NAV_LINKS = [
7
+ { href: '/products', label: 'Shop' },
8
+ { href: '/products?category=new', label: 'New Arrivals' },
9
+ { href: '/products?category=sale', label: 'Sale' },
10
+ ]
11
+
12
+ export function Header() {
13
+ return (
14
+ <header className={styles.header} data-testid="site-header">
15
+ <div className={`container ${styles.inner}`}>
16
+ <Link href="/" className={styles.logo}>
17
+ Nymbal
18
+ </Link>
19
+
20
+ <nav className={styles.nav} aria-label="Main navigation">
21
+ <ul className={styles.navList}>
22
+ {NAV_LINKS.map((link) => (
23
+ <li key={link.href}>
24
+ <Link href={link.href} className={styles.navLink}>
25
+ {link.label}
26
+ </Link>
27
+ </li>
28
+ ))}
29
+ </ul>
30
+ </nav>
31
+
32
+ <div className={styles.actions}>
33
+ <div className={styles.search}>
34
+ <SearchBarWrapper />
35
+ </div>
36
+ <Link href="/account" className={styles.iconLink} aria-label="Account">
37
+ <svg
38
+ width="20"
39
+ height="20"
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ strokeWidth="2"
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ aria-hidden="true"
47
+ >
48
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
49
+ <circle cx="12" cy="7" r="4" />
50
+ </svg>
51
+ </Link>
52
+ <MiniCartWrapper />
53
+ </div>
54
+
55
+ <input
56
+ type="checkbox"
57
+ id="mobile-menu-toggle"
58
+ className={styles.menuCheckbox}
59
+ aria-hidden="true"
60
+ />
61
+ <label
62
+ htmlFor="mobile-menu-toggle"
63
+ className={styles.menuButton}
64
+ aria-label="Toggle menu"
65
+ >
66
+ <svg
67
+ width="24"
68
+ height="24"
69
+ viewBox="0 0 24 24"
70
+ fill="none"
71
+ stroke="currentColor"
72
+ strokeWidth="2"
73
+ strokeLinecap="round"
74
+ strokeLinejoin="round"
75
+ aria-hidden="true"
76
+ >
77
+ <line x1="3" y1="6" x2="21" y2="6" />
78
+ <line x1="3" y1="12" x2="21" y2="12" />
79
+ <line x1="3" y1="18" x2="21" y2="18" />
80
+ </svg>
81
+ </label>
82
+
83
+ <nav className={styles.mobileNav} aria-label="Mobile navigation">
84
+ <ul className={styles.mobileNavList}>
85
+ {NAV_LINKS.map((link) => (
86
+ <li key={link.href}>
87
+ <Link href={link.href} className={styles.mobileNavLink}>
88
+ {link.label}
89
+ </Link>
90
+ </li>
91
+ ))}
92
+ <li>
93
+ <Link href="/account" className={styles.mobileNavLink}>
94
+ Account
95
+ </Link>
96
+ </li>
97
+ </ul>
98
+ </nav>
99
+ </div>
100
+ </header>
101
+ )
102
+ }
@@ -0,0 +1,18 @@
1
+ import Link from 'next/link'
2
+ import styles from '@/styles/pages/home.module.css'
3
+
4
+ export function HeroSection() {
5
+ return (
6
+ <section className={styles.hero} data-testid="hero-section">
7
+ <div className={styles.heroContent}>
8
+ <h1 className={styles.heroTitle}>Curated for quality. Built to last.</h1>
9
+ <p className={styles.heroSubtitle}>
10
+ Discover thoughtfully designed products that stand the test of time.
11
+ </p>
12
+ <Link href="/products" className={styles.heroCta}>
13
+ Shop Now
14
+ </Link>
15
+ </div>
16
+ </section>
17
+ )
18
+ }
@@ -0,0 +1,79 @@
1
+ import { formatPrice } from '@/lib/format'
2
+ import styles from '@/styles/pages/confirmation.module.css'
3
+
4
+ interface OrderLineItem {
5
+ variantId: string
6
+ productId: string
7
+ productName: string
8
+ variantName: string
9
+ sku: string
10
+ qty: number
11
+ unitPriceMinor: number
12
+ lineTotalMinor: number
13
+ imageUrl?: string
14
+ }
15
+
16
+ interface OrderSummaryProps {
17
+ items: OrderLineItem[]
18
+ subtotalMinor: number
19
+ currency: string
20
+ shippingTotalMinor: number
21
+ taxTotalMinor: number
22
+ totalMinor: number
23
+ }
24
+
25
+ export function OrderSummary({
26
+ items,
27
+ subtotalMinor,
28
+ currency,
29
+ shippingTotalMinor,
30
+ taxTotalMinor,
31
+ totalMinor,
32
+ }: OrderSummaryProps) {
33
+ return (
34
+ <div className={styles.orderCard}>
35
+ <h3 className={styles.orderCardTitle}>Order Items</h3>
36
+ <div className={styles.lineItems}>
37
+ {items.map((item) => (
38
+ <div key={item.variantId} className={styles.lineItem}>
39
+ {item.imageUrl && (
40
+ <div className={styles.lineItemImage}>
41
+ <img src={item.imageUrl} alt={item.productName} />
42
+ </div>
43
+ )}
44
+ <div className={styles.lineItemInfo}>
45
+ <p className={styles.lineItemName}>{item.productName}</p>
46
+ {item.variantName && (
47
+ <p className={styles.lineItemVariant}>{item.variantName}</p>
48
+ )}
49
+ <p className={styles.lineItemQty}>Qty: {item.qty}</p>
50
+ </div>
51
+ <span className={styles.lineItemPrice}>
52
+ {formatPrice(item.lineTotalMinor, currency)}
53
+ </span>
54
+ </div>
55
+ ))}
56
+ </div>
57
+ <div className={styles.totals}>
58
+ <div className={styles.totalRow}>
59
+ <span className={styles.totalLabel}>Subtotal</span>
60
+ <span>{formatPrice(subtotalMinor, currency)}</span>
61
+ </div>
62
+ <div className={styles.totalRow}>
63
+ <span className={styles.totalLabel}>Shipping</span>
64
+ <span>
65
+ {shippingTotalMinor === 0 ? 'Free' : formatPrice(shippingTotalMinor, currency)}
66
+ </span>
67
+ </div>
68
+ <div className={styles.totalRow}>
69
+ <span className={styles.totalLabel}>Tax</span>
70
+ <span>{formatPrice(taxTotalMinor, currency)}</span>
71
+ </div>
72
+ <div className={`${styles.totalRow} ${styles.totalGrand}`}>
73
+ <span>Total</span>
74
+ <span>{formatPrice(totalMinor, currency)}</span>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,31 @@
1
+ import { formatPrice } from '@/lib/format'
2
+ import styles from '@/styles/pages/pdp.module.css'
3
+
4
+ interface PriceDisplayProps {
5
+ priceMinor: number
6
+ currency: string
7
+ compareAtMinor?: number
8
+ }
9
+
10
+ export function PriceDisplay({ priceMinor, currency, compareAtMinor }: PriceDisplayProps) {
11
+ const hasDiscount = compareAtMinor != null && compareAtMinor > priceMinor
12
+
13
+ return (
14
+ <div className={styles.price} data-testid="product-price">
15
+ <span>{formatPrice(priceMinor, currency)}</span>
16
+ {hasDiscount && (
17
+ <span
18
+ style={{
19
+ textDecoration: 'line-through',
20
+ fontSize: '0.875em',
21
+ color: 'var(--nymbal-color-text-muted)',
22
+ marginLeft: 'var(--nymbal-spacing-sm)',
23
+ fontWeight: 'var(--nymbal-font-weight-normal)',
24
+ }}
25
+ >
26
+ {formatPrice(compareAtMinor, currency)}
27
+ </span>
28
+ )}
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,59 @@
1
+ import Link from 'next/link'
2
+ import Image from 'next/image'
3
+ import { formatPrice } from '@/lib/format'
4
+ import styles from '@/styles/pages/home.module.css'
5
+
6
+ interface ProductCardProps {
7
+ product: {
8
+ slug: string
9
+ name: string
10
+ priceMinor: number | null
11
+ priceRange?: { minMinor: number } | null
12
+ currency: string | null
13
+ media?: unknown
14
+ imageUrl?: string
15
+ }
16
+ }
17
+
18
+ function getImageUrl(product: ProductCardProps['product']): string | null {
19
+ if (product.imageUrl) return product.imageUrl
20
+ if (Array.isArray(product.media) && product.media.length > 0) {
21
+ const first = product.media[0] as Record<string, unknown>
22
+ if (typeof first?.url === 'string') return first.url
23
+ }
24
+ return null
25
+ }
26
+
27
+ export function ProductCard({ product }: ProductCardProps) {
28
+ const imageUrl = getImageUrl(product)
29
+ // The "from" price, which the price sorts order by.
30
+ const fromMinor = product.priceRange?.minMinor ?? product.priceMinor
31
+
32
+ return (
33
+ <Link
34
+ href={`/product/${product.slug}`}
35
+ className={styles.productCard}
36
+ data-testid={`product-card-${product.slug}`}
37
+ >
38
+ <div className={styles.productImage}>
39
+ {imageUrl ? (
40
+ <Image
41
+ src={imageUrl}
42
+ alt={product.name}
43
+ width={400}
44
+ height={400}
45
+ sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 25vw"
46
+ />
47
+ ) : (
48
+ <span className="visually-hidden">No image available</span>
49
+ )}
50
+ </div>
51
+ <div className={styles.productInfo}>
52
+ <p className={styles.productName}>{product.name}</p>
53
+ {fromMinor != null && product.currency && (
54
+ <p className={styles.productPrice}>{formatPrice(fromMinor, product.currency)}</p>
55
+ )}
56
+ </div>
57
+ </Link>
58
+ )
59
+ }
@@ -0,0 +1,31 @@
1
+ import { ProductCard } from './ProductCard'
2
+ import type { DenormalisedProduct } from '@nymbalhq/sdk'
3
+
4
+ interface RelatedProductsProps {
5
+ products: DenormalisedProduct[]
6
+ }
7
+
8
+ export function RelatedProducts({ products }: RelatedProductsProps) {
9
+ if (products.length === 0) return null
10
+
11
+ return (
12
+ <section className="section">
13
+ <h2 className="section-title">You may also like</h2>
14
+ <div className="product-grid">
15
+ {products.map((product) => (
16
+ <ProductCard
17
+ key={product.id}
18
+ product={{
19
+ slug: product.slug,
20
+ name: product.name,
21
+ priceMinor: product.priceMinor ?? null,
22
+ priceRange: product.priceRange ?? null,
23
+ currency: product.currency ?? null,
24
+ media: product.media,
25
+ }}
26
+ />
27
+ ))}
28
+ </div>
29
+ </section>
30
+ )
31
+ }
@@ -0,0 +1,62 @@
1
+ import { formatDate } from '@/lib/format'
2
+ import styles from '@/styles/pages/pdp.module.css'
3
+
4
+ interface Review {
5
+ id: string
6
+ productId: string
7
+ rating: number
8
+ title: string
9
+ body: string
10
+ submittedAt: string
11
+ }
12
+
13
+ interface ReviewListProps {
14
+ reviews: Review[]
15
+ }
16
+
17
+ function StarRating({ rating }: { rating: number }) {
18
+ return (
19
+ <div className={styles.reviewStars} aria-label={`${rating} out of 5 stars`}>
20
+ {Array.from({ length: 5 }, (_, i) => (
21
+ <span
22
+ key={i}
23
+ className={i < rating ? styles.starFilled : styles.starEmpty}
24
+ aria-hidden="true"
25
+ >
26
+ ★
27
+ </span>
28
+ ))}
29
+ </div>
30
+ )
31
+ }
32
+
33
+ export function ReviewList({ reviews }: ReviewListProps) {
34
+ if (reviews.length === 0) {
35
+ return (
36
+ <div className={styles.reviewsSection}>
37
+ <h2 className={styles.reviewsTitle}>Reviews</h2>
38
+ <p className={styles.noReviews}>No reviews yet. Be the first to leave a review.</p>
39
+ </div>
40
+ )
41
+ }
42
+
43
+ return (
44
+ <div className={styles.reviewsSection}>
45
+ <h2 className={styles.reviewsTitle}>Reviews ({reviews.length})</h2>
46
+ <div className={styles.reviewsList}>
47
+ {reviews.map((review) => (
48
+ <article key={review.id} className={styles.reviewCard}>
49
+ <div className={styles.reviewHeader}>
50
+ <StarRating rating={review.rating} />
51
+ <time className={styles.reviewDate} dateTime={review.submittedAt}>
52
+ {formatDate(review.submittedAt)}
53
+ </time>
54
+ </div>
55
+ {review.title && <h3 className={styles.reviewTitle}>{review.title}</h3>}
56
+ <p className={styles.reviewBody}>{review.body}</p>
57
+ </article>
58
+ ))}
59
+ </div>
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,7 @@
1
+ export function SkipNav() {
2
+ return (
3
+ <a href="#main" className="skip-nav">
4
+ Skip to content
5
+ </a>
6
+ )
7
+ }
@@ -0,0 +1,19 @@
1
+ import styles from '@/styles/pages/pdp.module.css'
2
+
3
+ interface StockBadgeProps {
4
+ stock: number
5
+ }
6
+
7
+ export function StockBadge({ stock }: StockBadgeProps) {
8
+ const inStock = stock > 0
9
+
10
+ return (
11
+ <span
12
+ className={`${styles.stockBadge} ${inStock ? styles.inStock : styles.outOfStock}`}
13
+ data-testid="stock-badge"
14
+ >
15
+ <span className={styles.stockDot} aria-hidden="true" />
16
+ {inStock ? (stock <= 5 ? `Only ${stock} left` : 'In Stock') : 'Out of Stock'}
17
+ </span>
18
+ )
19
+ }
@@ -0,0 +1,89 @@
1
+ import type { DenormalisedProduct, ProductListResult, ReviewsListResult } from '@nymbalhq/sdk'
2
+ import { DEFAULT_PRODUCT_SORT, PRODUCT_SORT_KEYS } from '@nymbalhq/types'
3
+ import type { Category, Order, ProductSortKey } from '@nymbalhq/types'
4
+
5
+ const API_URL = process.env.NYMBAL_API_URL ?? 'http://localhost:3001'
6
+
7
+ export async function fetchProducts(params?: {
8
+ limit?: number
9
+ cursor?: string
10
+ category?: string
11
+ sort?: ProductSortKey
12
+ }): Promise<ProductListResult> {
13
+ const url = new URL('/api/products', API_URL)
14
+ if (params?.limit) url.searchParams.set('limit', String(params.limit))
15
+ if (params?.cursor) url.searchParams.set('cursor', params.cursor)
16
+ if (params?.category) url.searchParams.set('category', params.category)
17
+ if (params?.sort) url.searchParams.set('sort', params.sort)
18
+
19
+ try {
20
+ const res = await fetch(url.toString(), { cache: 'no-store' })
21
+ if (!res.ok) return { items: [], nextCursor: null }
22
+ const envelope = (await res.json()) as { data: ProductListResult }
23
+ return envelope.data ?? { items: [], nextCursor: null }
24
+ } catch (err) {
25
+ console.error('[api] fetchProducts failed', err)
26
+ return { items: [], nextCursor: null }
27
+ }
28
+ }
29
+
30
+ /** The sort a listing URL asks for; a missing or unknown `?sort=` is the default. */
31
+ export function sortFromSearchParams(params: { sort?: string | string[] }): ProductSortKey {
32
+ const sort = Array.isArray(params.sort) ? params.sort[0] : params.sort
33
+ return PRODUCT_SORT_KEYS.find((key) => key === sort) ?? DEFAULT_PRODUCT_SORT
34
+ }
35
+
36
+ export async function fetchProduct(slug: string): Promise<DenormalisedProduct | null> {
37
+ try {
38
+ const res = await fetch(`${API_URL}/api/products/${encodeURIComponent(slug)}`, { cache: 'no-store' })
39
+ if (!res.ok) return null
40
+ const envelope = (await res.json()) as { data: DenormalisedProduct }
41
+ return envelope.data ?? null
42
+ } catch (err) {
43
+ console.error('[api] fetchProduct failed', err)
44
+ return null
45
+ }
46
+ }
47
+
48
+ const SEED_CATEGORIES: Category[] = [
49
+ { id: 'cat_apparel', parentId: null, name: 'Apparel', slug: 'apparel', description: 'Clothing and accessories', position: 0, createdAt: '2024-01-01T00:00:00Z' },
50
+ { id: 'cat_home', parentId: null, name: 'Home', slug: 'home', description: 'Home goods and decor', position: 1, createdAt: '2024-01-01T00:00:00Z' },
51
+ { id: 'cat_electronics', parentId: null, name: 'Electronics', slug: 'electronics', description: 'Gadgets and devices', position: 2, createdAt: '2024-01-01T00:00:00Z' },
52
+ { id: 'cat_books', parentId: null, name: 'Books', slug: 'books', description: 'Books and reading', position: 3, createdAt: '2024-01-01T00:00:00Z' },
53
+ ]
54
+
55
+ export async function fetchCategories(): Promise<Category[]> {
56
+ try {
57
+ const res = await fetch(`${API_URL}/api/categories`, { cache: 'no-store' })
58
+ if (!res.ok) return SEED_CATEGORIES
59
+ const envelope = (await res.json()) as { data: Category[] }
60
+ return envelope.data ?? SEED_CATEGORIES
61
+ } catch (err) {
62
+ console.error('[api] fetchCategories failed', err)
63
+ return SEED_CATEGORIES
64
+ }
65
+ }
66
+
67
+ export async function fetchOrder(orderNumber: string): Promise<Order | null> {
68
+ try {
69
+ const res = await fetch(`${API_URL}/api/orders/${encodeURIComponent(orderNumber)}`, { cache: 'no-store' })
70
+ if (!res.ok) return null
71
+ const envelope = (await res.json()) as { data: Order }
72
+ return envelope.data ?? null
73
+ } catch (err) {
74
+ console.error('[api] fetchOrder failed', err)
75
+ return null
76
+ }
77
+ }
78
+
79
+ export async function fetchReviews(productId: string): Promise<ReviewsListResult> {
80
+ try {
81
+ const res = await fetch(`${API_URL}/api/products/${encodeURIComponent(productId)}/reviews`, { cache: 'no-store' })
82
+ if (!res.ok) return { items: [], nextCursor: null }
83
+ const envelope = (await res.json()) as { data: ReviewsListResult }
84
+ return envelope.data ?? { items: [], nextCursor: null }
85
+ } catch (err) {
86
+ console.error('[api] fetchReviews failed', err)
87
+ return { items: [], nextCursor: null }
88
+ }
89
+ }
@@ -0,0 +1,23 @@
1
+ import { createNymbalClient, type NymbalClient } from '@nymbalhq/sdk'
2
+
3
+ let instance: NymbalClient | null = null
4
+ let restoring: Promise<void> | null = null
5
+
6
+ export function getNymbalClient(): NymbalClient {
7
+ if (!instance) {
8
+ instance = createNymbalClient({
9
+ baseUrl: process.env.NEXT_PUBLIC_NYMBAL_API_URL ?? 'http://localhost:3001',
10
+ tokenStorage: 'localStorage',
11
+ })
12
+ }
13
+ return instance
14
+ }
15
+
16
+ /**
17
+ * Loads the profile for a stored token once per page load, so a reload keeps a signed-in shopper signed in.
18
+ * The account guard awaits the same promise before deciding to redirect.
19
+ */
20
+ export function restoreSession(): Promise<void> {
21
+ restoring ??= getNymbalClient().auth.loadProfile()
22
+ return restoring
23
+ }
@@ -0,0 +1,7 @@
1
+ export function formatPrice(minor: number, currency: string): string {
2
+ return new Intl.NumberFormat('en-GB', { style: 'currency', currency }).format(minor / 100)
3
+ }
4
+
5
+ export function formatDate(iso: string): string {
6
+ return new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' }).format(new Date(iso))
7
+ }