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,53 @@
1
+ ---
2
+ import { formatPrice } from '../lib/format'
3
+
4
+ interface Props {
5
+ items: Array<{ name: string; qty: number; priceMinor: number }>
6
+ subtotalMinor: number
7
+ currency: string
8
+ shippingMinor?: number
9
+ taxMinor?: number
10
+ totalMinor?: number
11
+ }
12
+
13
+ const { items, subtotalMinor, currency, shippingMinor, taxMinor, totalMinor } = Astro.props
14
+ const computedTotal = totalMinor ?? subtotalMinor + (shippingMinor ?? 0) + (taxMinor ?? 0)
15
+ ---
16
+
17
+ <div class="order-summary" data-testid="order-summary">
18
+ <div class="checkout-summary-items">
19
+ {items.map((item) => (
20
+ <div class="checkout-summary-item">
21
+ <span class="checkout-summary-item-name">{item.name}</span>
22
+ <span class="checkout-summary-item-qty">x{item.qty}</span>
23
+ <span class="checkout-summary-item-price">
24
+ {formatPrice(item.priceMinor * item.qty, currency)}
25
+ </span>
26
+ </div>
27
+ ))}
28
+ </div>
29
+
30
+ <dl>
31
+ <div class="checkout-summary-row">
32
+ <dt>Subtotal</dt>
33
+ <dd>{formatPrice(subtotalMinor, currency)}</dd>
34
+ </div>
35
+ {shippingMinor != null && (
36
+ <div class="checkout-summary-row">
37
+ <dt>Shipping</dt>
38
+ <dd>{shippingMinor === 0 ? 'Free' : formatPrice(shippingMinor, currency)}</dd>
39
+ </div>
40
+ )}
41
+ {taxMinor != null && (
42
+ <div class="checkout-summary-row">
43
+ <dt>Tax</dt>
44
+ <dd>{formatPrice(taxMinor, currency)}</dd>
45
+ </div>
46
+ )}
47
+ </dl>
48
+
49
+ <div class="checkout-summary-total">
50
+ <span>Total</span>
51
+ <span>{formatPrice(computedTotal, currency)}</span>
52
+ </div>
53
+ </div>
@@ -0,0 +1,13 @@
1
+ ---
2
+ ---
3
+
4
+ <div class="plp-pagination">
5
+ <button
6
+ type="button"
7
+ class="btn btn-secondary"
8
+ id="load-more-btn"
9
+ data-testid="load-more"
10
+ >
11
+ Load More
12
+ </button>
13
+ </div>
@@ -0,0 +1,25 @@
1
+ ---
2
+ import { formatPrice } from '../lib/format'
3
+
4
+ interface Props {
5
+ priceMinor: number
6
+ currency: string
7
+ compareAtMinor?: number
8
+ }
9
+
10
+ const { priceMinor, currency, compareAtMinor } = Astro.props
11
+ const price = formatPrice(priceMinor, currency)
12
+ const comparePrice = compareAtMinor ? formatPrice(compareAtMinor, currency) : null
13
+ const isOnSale = compareAtMinor != null && compareAtMinor > priceMinor
14
+ ---
15
+
16
+ <div class="pdp-price" data-testid="product-price">
17
+ {isOnSale && comparePrice && (
18
+ <span
19
+ style="text-decoration: line-through; color: var(--nymbal-color-text-muted, #71717a); font-weight: var(--nymbal-font-weight-normal, 400); margin-right: var(--nymbal-spacing-sm, 0.5rem);"
20
+ >
21
+ {comparePrice}
22
+ </span>
23
+ )}
24
+ <span>{price}</span>
25
+ </div>
@@ -0,0 +1,44 @@
1
+ ---
2
+ import { formatPrice } from '../lib/format'
3
+
4
+ interface Props {
5
+ product: {
6
+ slug: string
7
+ name: string
8
+ priceMinor: number | null
9
+ priceRange?: { minMinor: number } | null
10
+ currency: string | null
11
+ media: any
12
+ imageUrl?: string
13
+ }
14
+ }
15
+
16
+ const { product } = Astro.props
17
+ const imageUrl =
18
+ product.imageUrl ??
19
+ (Array.isArray(product.media) && product.media[0]?.url) ??
20
+ `https://picsum.photos/seed/${product.slug}/600/600`
21
+ // The "from" price, which the price sorts order by.
22
+ const fromMinor = product.priceRange?.minMinor ?? product.priceMinor
23
+ const price =
24
+ fromMinor != null ? formatPrice(fromMinor, product.currency ?? 'GBP') : ''
25
+ ---
26
+
27
+ <a
28
+ href={`/product/${product.slug}`}
29
+ class="product-card"
30
+ data-testid={`product-card-${product.slug}`}
31
+ >
32
+ <img
33
+ src={imageUrl}
34
+ alt={product.name}
35
+ class="product-card-image"
36
+ loading="lazy"
37
+ width="600"
38
+ height="600"
39
+ />
40
+ <div class="product-card-body">
41
+ <span class="product-card-name">{product.name}</span>
42
+ {price && <span class="product-card-price">{price}</span>}
43
+ </div>
44
+ </a>
@@ -0,0 +1,28 @@
1
+ ---
2
+ import ProductCard from './ProductCard.astro'
3
+
4
+ interface Props {
5
+ products: Array<{
6
+ slug: string
7
+ name: string
8
+ priceMinor: number | null
9
+ currency: string | null
10
+ media: any
11
+ imageUrl?: string
12
+ }>
13
+ }
14
+
15
+ const { products } = Astro.props
16
+ const displayProducts = products.slice(0, 4)
17
+ ---
18
+
19
+ {displayProducts.length > 0 && (
20
+ <section class="section" data-testid="related-products">
21
+ <h2 class="section-title">You may also like</h2>
22
+ <div class="product-grid">
23
+ {displayProducts.map((product) => (
24
+ <ProductCard product={product} />
25
+ ))}
26
+ </div>
27
+ </section>
28
+ )}
@@ -0,0 +1,100 @@
1
+ ---
2
+ interface Props {
3
+ productId: string
4
+ }
5
+
6
+ const { productId } = Astro.props
7
+ ---
8
+
9
+ <form
10
+ class="review-form"
11
+ id="review-form"
12
+ data-product-id={productId}
13
+ data-testid="review-form"
14
+ >
15
+ <h3 style="font-size: 1.125rem; font-weight: var(--nymbal-font-weight-bold, 700); margin-bottom: var(--nymbal-spacing-md, 1rem);">
16
+ Write a Review
17
+ </h3>
18
+
19
+ <div class="form-group">
20
+ <label for="review-rating">Rating</label>
21
+ <select id="review-rating" name="rating" required data-testid="review-rating">
22
+ <option value="">Select a rating</option>
23
+ <option value="5">5 - Excellent</option>
24
+ <option value="4">4 - Very Good</option>
25
+ <option value="3">3 - Good</option>
26
+ <option value="2">2 - Fair</option>
27
+ <option value="1">1 - Poor</option>
28
+ </select>
29
+ </div>
30
+
31
+ <div class="form-group">
32
+ <label for="review-title">Title</label>
33
+ <input
34
+ type="text"
35
+ id="review-title"
36
+ name="title"
37
+ placeholder="Summarise your experience"
38
+ required
39
+ data-testid="review-title"
40
+ />
41
+ </div>
42
+
43
+ <div class="form-group">
44
+ <label for="review-body">Review</label>
45
+ <textarea
46
+ id="review-body"
47
+ name="body"
48
+ rows="4"
49
+ placeholder="Tell us what you think..."
50
+ required
51
+ data-testid="review-body"
52
+ style="resize: vertical;"
53
+ ></textarea>
54
+ </div>
55
+
56
+ <button type="submit" class="btn btn-primary" data-testid="review-submit">
57
+ Submit Review
58
+ </button>
59
+
60
+ <p class="review-form-message" id="review-form-message" style="display:none; margin-top: var(--nymbal-spacing-md, 1rem); font-size: var(--nymbal-font-size-sm, 0.875rem);"></p>
61
+ </form>
62
+
63
+ <script>
64
+ import { client } from '../lib/client.js'
65
+
66
+ const form = document.getElementById('review-form') as HTMLFormElement | null
67
+ const messageEl = document.getElementById('review-form-message')
68
+
69
+ form?.addEventListener('submit', async (e) => {
70
+ e.preventDefault()
71
+ if (!form || !messageEl) return
72
+
73
+ const productId = form.dataset.productId ?? ''
74
+ const formData = new FormData(form)
75
+ const rating = Number(formData.get('rating'))
76
+ const title = String(formData.get('title'))
77
+ const body = String(formData.get('body'))
78
+
79
+ try {
80
+ await client.adapter.reviews.submit({ productId, rating, title, body })
81
+ messageEl.textContent = 'Thank you for your review!'
82
+ messageEl.style.display = 'block'
83
+ messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
84
+ form.reset()
85
+ } catch {
86
+ messageEl.textContent = 'Something went wrong. Please try again.'
87
+ messageEl.style.display = 'block'
88
+ messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
89
+ }
90
+ })
91
+ </script>
92
+
93
+ <style>
94
+ .review-form {
95
+ margin-top: var(--nymbal-spacing-xl, 2rem);
96
+ padding-top: var(--nymbal-spacing-xl, 2rem);
97
+ border-top: 1px solid var(--nymbal-color-border, #e4e4e7);
98
+ max-width: 32rem;
99
+ }
100
+ </style>
@@ -0,0 +1,41 @@
1
+ ---
2
+ import '../styles/pages/pdp.css'
3
+ import { formatDate } from '../lib/format'
4
+
5
+ interface Props {
6
+ reviews: Array<{
7
+ id: string
8
+ rating: number
9
+ title: string
10
+ body: string
11
+ submittedAt: string
12
+ }>
13
+ }
14
+
15
+ const { reviews } = Astro.props
16
+
17
+ function renderStars(rating: number): string {
18
+ const filled = '★'
19
+ const empty = '☆'
20
+ return filled.repeat(rating) + empty.repeat(5 - rating)
21
+ }
22
+ ---
23
+
24
+ {reviews.length > 0 ? (
25
+ <div class="review-list" data-testid="review-list">
26
+ {reviews.map((review) => (
27
+ <div class="review-item" data-testid={`review-${review.id}`}>
28
+ <div class="review-header">
29
+ <span class="review-rating" aria-label={`${review.rating} out of 5 stars`}>
30
+ {renderStars(review.rating)}
31
+ </span>
32
+ <span class="review-date">{formatDate(review.submittedAt)}</span>
33
+ </div>
34
+ <h4 class="review-title">{review.title}</h4>
35
+ <p class="review-body">{review.body}</p>
36
+ </div>
37
+ ))}
38
+ </div>
39
+ ) : (
40
+ <p class="reviews-empty">No reviews yet. Be the first to review this product.</p>
41
+ )}
@@ -0,0 +1,3 @@
1
+ ---
2
+ ---
3
+ <a href="#main" class="skip-nav">Skip to main content</a>
@@ -0,0 +1,26 @@
1
+ ---
2
+ import type { ProductSortKey } from '@nymbalhq/types'
3
+
4
+ interface Props {
5
+ sort: ProductSortKey
6
+ }
7
+
8
+ const { sort } = Astro.props
9
+
10
+ const OPTIONS: Array<{ value: ProductSortKey; label: string }> = [
11
+ { value: 'newest', label: 'Newest' },
12
+ { value: 'price-asc', label: 'Price: Low to High' },
13
+ { value: 'price-desc', label: 'Price: High to Low' },
14
+ { value: 'name-asc', label: 'Name: A to Z' },
15
+ { value: 'name-desc', label: 'Name: Z to A' },
16
+ ]
17
+ ---
18
+
19
+ <div class="plp-sort-bar">
20
+ <label for="sort-select">Sort by</label>
21
+ <select id="sort-select" data-testid="sort-dropdown">
22
+ {OPTIONS.map((option) => (
23
+ <option value={option.value} selected={option.value === sort}>{option.label}</option>
24
+ ))}
25
+ </select>
26
+ </div>
@@ -0,0 +1,32 @@
1
+ ---
2
+ interface Props {
3
+ stock: number
4
+ }
5
+
6
+ const { stock } = Astro.props
7
+
8
+ let statusClass: string
9
+ let label: string
10
+
11
+ if (stock <= 0) {
12
+ statusClass = 'out-of-stock'
13
+ label = 'Out of Stock'
14
+ } else if (stock < 5) {
15
+ statusClass = 'low-stock'
16
+ label = 'Low Stock'
17
+ } else {
18
+ statusClass = 'in-stock'
19
+ label = 'In Stock'
20
+ }
21
+ ---
22
+
23
+ <span class={`pdp-stock ${statusClass}`} data-testid="stock-badge">
24
+ <span class="pdp-stock-dot" aria-hidden="true"></span>
25
+ {label}
26
+ </span>
27
+
28
+ <style>
29
+ .low-stock {
30
+ color: var(--nymbal-color-warning, #d97706);
31
+ }
32
+ </style>
@@ -0,0 +1,2 @@
1
+ /// <reference path="../.astro/types.d.ts" />
2
+ /// <reference types="astro/client" />
@@ -0,0 +1,168 @@
1
+ ---
2
+ import BaseLayout from './BaseLayout.astro'
3
+
4
+ interface Props {
5
+ title: string
6
+ }
7
+
8
+ const { title } = Astro.props
9
+ ---
10
+ <BaseLayout title={`${title} | Nymbal`} description="Manage your Nymbal account">
11
+ <div class="account-layout container section">
12
+ <!-- Loading state shown while auth check runs -->
13
+ <div class="account-loading" id="account-loading">
14
+ <p>Checking authentication...</p>
15
+ </div>
16
+
17
+ <!-- Actual content, hidden until auth check passes -->
18
+ <div class="account-content" id="account-content" style="display:none;">
19
+ <aside class="account-sidebar">
20
+ <nav class="account-nav" aria-label="Account navigation">
21
+ <a href="/account" class="account-nav-link">Dashboard</a>
22
+ <a href="/account/orders" class="account-nav-link">Orders</a>
23
+ <a href="/account/addresses" class="account-nav-link">Addresses</a>
24
+ <a href="/account/profile" class="account-nav-link">Profile</a>
25
+ <button type="button" class="account-nav-link account-logout-btn" id="logout-btn">
26
+ Logout
27
+ </button>
28
+ </nav>
29
+ </aside>
30
+
31
+ <div class="account-main">
32
+ <slot />
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </BaseLayout>
37
+
38
+ <script>
39
+ import { client } from '../lib/client.js'
40
+
41
+ async function guardAuth() {
42
+ const loadingEl = document.getElementById('account-loading')
43
+ const contentEl = document.getElementById('account-content')
44
+
45
+ let state = client.auth.getState()
46
+
47
+ if (!state.isAuthenticated) {
48
+ try {
49
+ await client.auth.loadProfile()
50
+ state = client.auth.getState()
51
+ } catch {
52
+ // ignore — will redirect below
53
+ }
54
+ }
55
+
56
+ if (!state.isAuthenticated) {
57
+ const redirect = encodeURIComponent(window.location.pathname + window.location.search)
58
+ window.location.href = `/account/login?redirect=${redirect}`
59
+ return
60
+ }
61
+
62
+ if (loadingEl) loadingEl.style.display = 'none'
63
+ if (contentEl) contentEl.style.display = ''
64
+ }
65
+
66
+ guardAuth()
67
+
68
+ // Logout handler
69
+ const logoutBtn = document.getElementById('logout-btn')
70
+ if (logoutBtn) {
71
+ logoutBtn.addEventListener('click', async () => {
72
+ await client.auth.logout()
73
+ window.location.href = '/account/login'
74
+ })
75
+ }
76
+ </script>
77
+
78
+ <style>
79
+ .account-layout {
80
+ min-height: 60vh;
81
+ }
82
+
83
+ .account-loading {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ min-height: 40vh;
88
+ color: var(--nymbal-color-text-muted, #71717a);
89
+ font-size: var(--nymbal-font-size-sm, 0.875rem);
90
+ }
91
+
92
+ .account-content {
93
+ display: grid;
94
+ grid-template-columns: 1fr;
95
+ gap: var(--nymbal-spacing-xl, 2rem);
96
+ }
97
+
98
+ @media (min-width: 769px) {
99
+ .account-content {
100
+ grid-template-columns: 220px 1fr;
101
+ }
102
+ }
103
+
104
+ .account-sidebar {
105
+ border-bottom: 1px solid var(--nymbal-color-border, #e4e4e7);
106
+ padding-bottom: var(--nymbal-spacing-md, 1rem);
107
+ }
108
+
109
+ @media (min-width: 769px) {
110
+ .account-sidebar {
111
+ border-bottom: none;
112
+ border-right: 1px solid var(--nymbal-color-border, #e4e4e7);
113
+ padding-bottom: 0;
114
+ padding-right: var(--nymbal-spacing-xl, 2rem);
115
+ }
116
+ }
117
+
118
+ .account-nav {
119
+ display: flex;
120
+ gap: var(--nymbal-spacing-xs, 0.25rem);
121
+ overflow-x: auto;
122
+ }
123
+
124
+ @media (min-width: 769px) {
125
+ .account-nav {
126
+ flex-direction: column;
127
+ overflow-x: visible;
128
+ }
129
+ }
130
+
131
+ .account-nav-link {
132
+ display: block;
133
+ padding: var(--nymbal-spacing-sm, 0.5rem) var(--nymbal-spacing-md, 1rem);
134
+ font-size: var(--nymbal-font-size-sm, 0.875rem);
135
+ font-weight: var(--nymbal-font-weight-medium, 500);
136
+ color: var(--nymbal-color-text, #18181b);
137
+ text-decoration: none;
138
+ border-radius: var(--nymbal-radius-md, 0.5rem);
139
+ white-space: nowrap;
140
+ transition: background-color var(--nymbal-transition-fast, 150ms) ease,
141
+ color var(--nymbal-transition-fast, 150ms) ease;
142
+ background: none;
143
+ border: none;
144
+ cursor: pointer;
145
+ text-align: left;
146
+ font-family: inherit;
147
+ width: 100%;
148
+ }
149
+
150
+ .account-nav-link:hover {
151
+ background-color: var(--nymbal-color-border, #e4e4e7);
152
+ color: var(--nymbal-color-accent, #2563eb);
153
+ }
154
+
155
+ .account-logout-btn {
156
+ color: var(--nymbal-color-error, #dc2626);
157
+ margin-top: var(--nymbal-spacing-sm, 0.5rem);
158
+ }
159
+
160
+ .account-logout-btn:hover {
161
+ background-color: rgba(220, 38, 38, 0.08);
162
+ color: var(--nymbal-color-error, #dc2626);
163
+ }
164
+
165
+ .account-main {
166
+ min-width: 0;
167
+ }
168
+ </style>
@@ -0,0 +1,55 @@
1
+ ---
2
+ import '../styles/global.css'
3
+ import SkipNav from '../components/SkipNav.astro'
4
+ import Header from '../components/Header.astro'
5
+ import Footer from '../components/Footer.astro'
6
+
7
+ interface Props {
8
+ title: string
9
+ description?: string
10
+ ogImage?: string
11
+ jsonLd?: Record<string, unknown>
12
+ }
13
+
14
+ const { title, description = 'Shop the Nymbal store', ogImage, jsonLd } = Astro.props
15
+ const canonicalUrl = Astro.url.href
16
+ ---
17
+ <!doctype html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="utf-8" />
21
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
22
+ <title>{title}</title>
23
+ <meta name="description" content={description} />
24
+ <link rel="canonical" href={canonicalUrl} />
25
+
26
+ <!-- Open Graph -->
27
+ <meta property="og:title" content={title} />
28
+ <meta property="og:description" content={description} />
29
+ <meta property="og:type" content="website" />
30
+ <meta property="og:url" content={canonicalUrl} />
31
+ {ogImage && <meta property="og:image" content={ogImage} />}
32
+
33
+ <!-- JSON-LD structured data -->
34
+ {jsonLd && (
35
+ <script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
36
+ )}
37
+ </head>
38
+ <body>
39
+ <SkipNav />
40
+ <Header />
41
+ <main id="main">
42
+ <slot />
43
+ </main>
44
+ <Footer />
45
+
46
+ <!-- Web component overlays (rendered outside main flow) -->
47
+ <nymbal-cart-drawer></nymbal-cart-drawer>
48
+ <nymbal-toast></nymbal-toast>
49
+
50
+ <!-- Initialise SDK + web components on the client -->
51
+ <script>
52
+ import '../scripts/init-client.js'
53
+ </script>
54
+ </body>
55
+ </html>
@@ -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 = import.meta.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())
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 sortFromUrl(url: URL): ProductSortKey {
32
+ const sort = url.searchParams.get('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)}`)
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`)
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)}`)
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`)
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
+ }