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.
- package/bin/create-nymbal-app.mjs +9 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +419 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -7
- package/scaffold/project/astro/src/pages/index.astro +57 -0
- package/scaffold/templates/astro/README.md +26 -0
- package/scaffold/templates/astro/_dot.env.example +6 -0
- package/scaffold/templates/astro/_dot.gitignore +16 -0
- package/scaffold/templates/astro/_dot.node-version +1 -0
- package/scaffold/templates/astro/_dot.npmrc +1 -0
- package/scaffold/templates/astro/astro.config.mjs +14 -0
- package/scaffold/templates/astro/package.json +59 -0
- package/scaffold/templates/astro/src/components/AddressForm.astro +107 -0
- package/scaffold/templates/astro/src/components/Breadcrumb.astro +20 -0
- package/scaffold/templates/astro/src/components/CategoryNav.astro +27 -0
- package/scaffold/templates/astro/src/components/CheckoutSteps.astro +29 -0
- package/scaffold/templates/astro/src/components/Footer.astro +108 -0
- package/scaffold/templates/astro/src/components/Header.astro +192 -0
- package/scaffold/templates/astro/src/components/HeroSection.astro +17 -0
- package/scaffold/templates/astro/src/components/ImageGallery.astro +42 -0
- package/scaffold/templates/astro/src/components/NewsletterSignup.astro +39 -0
- package/scaffold/templates/astro/src/components/OrderSummary.astro +53 -0
- package/scaffold/templates/astro/src/components/Pagination.astro +13 -0
- package/scaffold/templates/astro/src/components/PriceDisplay.astro +25 -0
- package/scaffold/templates/astro/src/components/ProductCard.astro +44 -0
- package/scaffold/templates/astro/src/components/RelatedProducts.astro +28 -0
- package/scaffold/templates/astro/src/components/ReviewForm.astro +100 -0
- package/scaffold/templates/astro/src/components/ReviewList.astro +41 -0
- package/scaffold/templates/astro/src/components/SkipNav.astro +3 -0
- package/scaffold/templates/astro/src/components/SortDropdown.astro +26 -0
- package/scaffold/templates/astro/src/components/StockBadge.astro +32 -0
- package/scaffold/templates/astro/src/env.d.ts +2 -0
- package/scaffold/templates/astro/src/layouts/AccountLayout.astro +168 -0
- package/scaffold/templates/astro/src/layouts/BaseLayout.astro +55 -0
- package/scaffold/templates/astro/src/lib/api.ts +89 -0
- package/scaffold/templates/astro/src/lib/client.ts +13 -0
- package/scaffold/templates/astro/src/lib/format.ts +17 -0
- package/scaffold/templates/astro/src/lib/order-status.ts +14 -0
- package/scaffold/templates/astro/src/lib/seo.ts +62 -0
- package/scaffold/templates/astro/src/pages/404.astro +18 -0
- package/scaffold/templates/astro/src/pages/500.astro +17 -0
- package/scaffold/templates/astro/src/pages/account/addresses.astro +110 -0
- package/scaffold/templates/astro/src/pages/account/index.astro +67 -0
- package/scaffold/templates/astro/src/pages/account/login.astro +55 -0
- package/scaffold/templates/astro/src/pages/account/orders/[orderNumber].astro +108 -0
- package/scaffold/templates/astro/src/pages/account/orders.astro +81 -0
- package/scaffold/templates/astro/src/pages/account/profile.astro +131 -0
- package/scaffold/templates/astro/src/pages/account/register.astro +94 -0
- package/scaffold/templates/astro/src/pages/cart.astro +16 -0
- package/scaffold/templates/astro/src/pages/category/[slug].astro +73 -0
- package/scaffold/templates/astro/src/pages/checkout.astro +100 -0
- package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -0
- package/scaffold/templates/astro/src/pages/fragments/header.astro +5 -0
- package/scaffold/templates/astro/src/pages/fragments/nav.astro +9 -0
- package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +14 -0
- package/scaffold/templates/astro/src/pages/index.astro +49 -0
- package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +127 -0
- package/scaffold/templates/astro/src/pages/product/[slug].astro +107 -0
- package/scaffold/templates/astro/src/pages/products/index.astro +62 -0
- package/scaffold/templates/astro/src/pages/search.astro +37 -0
- package/scaffold/templates/astro/src/scripts/account-guard.ts +22 -0
- package/scaffold/templates/astro/src/scripts/auth-forms.ts +89 -0
- package/scaffold/templates/astro/src/scripts/cart-page.ts +143 -0
- package/scaffold/templates/astro/src/scripts/checkout.ts +277 -0
- package/scaffold/templates/astro/src/scripts/image-gallery.ts +22 -0
- package/scaffold/templates/astro/src/scripts/init-client.ts +16 -0
- package/scaffold/templates/astro/src/scripts/order-status.ts +27 -0
- package/scaffold/templates/astro/src/scripts/pdp.ts +67 -0
- package/scaffold/templates/astro/src/scripts/plp.ts +109 -0
- package/scaffold/templates/astro/src/scripts/search.ts +145 -0
- package/scaffold/templates/astro/src/styles/global.css +362 -0
- package/scaffold/templates/astro/src/styles/pages/account.css +331 -0
- package/scaffold/templates/astro/src/styles/pages/cart.css +243 -0
- package/scaffold/templates/astro/src/styles/pages/checkout.css +270 -0
- package/scaffold/templates/astro/src/styles/pages/confirmation.css +142 -0
- package/scaffold/templates/astro/src/styles/pages/error.css +69 -0
- package/scaffold/templates/astro/src/styles/pages/home.css +269 -0
- package/scaffold/templates/astro/src/styles/pages/pdp.css +272 -0
- package/scaffold/templates/astro/src/styles/pages/plp.css +213 -0
- package/scaffold/templates/astro/src/styles/pages/search.css +152 -0
- package/scaffold/templates/astro/tsconfig.json +3 -0
- package/scaffold/templates/nextjs/README.md +26 -0
- package/scaffold/templates/nextjs/_dot.env.example +6 -0
- package/scaffold/templates/nextjs/_dot.gitignore +17 -0
- package/scaffold/templates/nextjs/_dot.node-version +1 -0
- package/scaffold/templates/nextjs/_dot.npmrc +1 -0
- package/scaffold/templates/nextjs/app/account/addresses/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/account/layout.tsx +57 -0
- package/scaffold/templates/nextjs/app/account/login/page.tsx +23 -0
- package/scaffold/templates/nextjs/app/account/orders/[orderNumber]/page.tsx +192 -0
- package/scaffold/templates/nextjs/app/account/orders/page.tsx +123 -0
- package/scaffold/templates/nextjs/app/account/page.tsx +59 -0
- package/scaffold/templates/nextjs/app/account/profile/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/account/register/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/cart/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +58 -0
- package/scaffold/templates/nextjs/app/checkout/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/error.tsx +49 -0
- package/scaffold/templates/nextjs/app/globals.css +473 -0
- package/scaffold/templates/nextjs/app/layout.tsx +29 -0
- package/scaffold/templates/nextjs/app/not-found.tsx +38 -0
- package/scaffold/templates/nextjs/app/order/[orderNumber]/page.tsx +127 -0
- package/scaffold/templates/nextjs/app/page.tsx +63 -0
- package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +111 -0
- package/scaffold/templates/nextjs/app/products/page.tsx +40 -0
- package/scaffold/templates/nextjs/app/search/page.tsx +24 -0
- package/scaffold/templates/nextjs/components/client/AddressManager.tsx +181 -0
- package/scaffold/templates/nextjs/components/client/AuthGuard.tsx +55 -0
- package/scaffold/templates/nextjs/components/client/CartDrawerToggle.tsx +39 -0
- package/scaffold/templates/nextjs/components/client/CartPage.tsx +120 -0
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +523 -0
- package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +68 -0
- package/scaffold/templates/nextjs/components/client/LoginForm.tsx +89 -0
- package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +12 -0
- package/scaffold/templates/nextjs/components/client/NewsletterSignup.tsx +74 -0
- package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +24 -0
- package/scaffold/templates/nextjs/components/client/OrderStatusRefresh.tsx +48 -0
- package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +148 -0
- package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +61 -0
- package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +112 -0
- package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +131 -0
- package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +116 -0
- package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +27 -0
- package/scaffold/templates/nextjs/components/client/SearchResults.tsx +141 -0
- package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +35 -0
- package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +36 -0
- package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +29 -0
- package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +60 -0
- package/scaffold/templates/nextjs/components/server/Footer.module.css +64 -0
- package/scaffold/templates/nextjs/components/server/Footer.tsx +79 -0
- package/scaffold/templates/nextjs/components/server/Header.module.css +147 -0
- package/scaffold/templates/nextjs/components/server/Header.tsx +102 -0
- package/scaffold/templates/nextjs/components/server/HeroSection.tsx +18 -0
- package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +79 -0
- package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +31 -0
- package/scaffold/templates/nextjs/components/server/ProductCard.tsx +59 -0
- package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +31 -0
- package/scaffold/templates/nextjs/components/server/ReviewList.tsx +62 -0
- package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -0
- package/scaffold/templates/nextjs/components/server/StockBadge.tsx +19 -0
- package/scaffold/templates/nextjs/lib/api.ts +89 -0
- package/scaffold/templates/nextjs/lib/client.ts +23 -0
- package/scaffold/templates/nextjs/lib/format.ts +7 -0
- package/scaffold/templates/nextjs/lib/order-status.ts +14 -0
- package/scaffold/templates/nextjs/lib/seo.ts +73 -0
- package/scaffold/templates/nextjs/next.config.mjs +9 -0
- package/scaffold/templates/nextjs/package.json +64 -0
- package/scaffold/templates/nextjs/styles/pages/account.module.css +377 -0
- package/scaffold/templates/nextjs/styles/pages/cart.module.css +240 -0
- package/scaffold/templates/nextjs/styles/pages/checkout.module.css +404 -0
- package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +237 -0
- package/scaffold/templates/nextjs/styles/pages/error.module.css +132 -0
- package/scaffold/templates/nextjs/styles/pages/home.module.css +285 -0
- package/scaffold/templates/nextjs/styles/pages/pdp.module.css +319 -0
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +280 -0
- package/scaffold/templates/nextjs/styles/pages/search.module.css +192 -0
- package/scaffold/templates/nextjs/tsconfig.json +41 -0
- package/README.md +0 -4
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, type FormEvent } from 'react'
|
|
4
|
+
import styles from '@/styles/pages/home.module.css'
|
|
5
|
+
|
|
6
|
+
export function NewsletterSignup() {
|
|
7
|
+
const [email, setEmail] = useState('')
|
|
8
|
+
const [submitted, setSubmitted] = useState(false)
|
|
9
|
+
const [error, setError] = useState<string | null>(null)
|
|
10
|
+
|
|
11
|
+
const handleSubmit = useCallback(
|
|
12
|
+
async (e: FormEvent) => {
|
|
13
|
+
e.preventDefault()
|
|
14
|
+
setError(null)
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const apiUrl = process.env.NEXT_PUBLIC_NYMBAL_API_URL ?? 'http://localhost:3001'
|
|
18
|
+
const res = await fetch(`${apiUrl}/api/newsletter`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ email }),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (!res.ok) throw new Error('Subscription failed.')
|
|
25
|
+
|
|
26
|
+
setSubmitted(true)
|
|
27
|
+
setEmail('')
|
|
28
|
+
} catch {
|
|
29
|
+
// Gracefully handle — still show success for UX
|
|
30
|
+
setSubmitted(true)
|
|
31
|
+
setEmail('')
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
[email],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<section className={`container ${styles.newsletter}`}>
|
|
39
|
+
<div className={styles.newsletterInner}>
|
|
40
|
+
<h2 className={styles.newsletterTitle}>Stay in the loop</h2>
|
|
41
|
+
<p className={styles.newsletterText}>
|
|
42
|
+
Subscribe to our newsletter for new arrivals, exclusive offers, and style inspiration.
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
{submitted ? (
|
|
46
|
+
<p style={{ fontSize: 'var(--nymbal-font-size-sm)', color: 'var(--nymbal-color-success)', fontWeight: 'var(--nymbal-font-weight-medium)' }}>
|
|
47
|
+
Thanks for subscribing! Check your inbox for a confirmation.
|
|
48
|
+
</p>
|
|
49
|
+
) : (
|
|
50
|
+
<form onSubmit={handleSubmit} className={styles.newsletterForm}>
|
|
51
|
+
<input
|
|
52
|
+
type="email"
|
|
53
|
+
className={styles.newsletterInput}
|
|
54
|
+
value={email}
|
|
55
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
56
|
+
required
|
|
57
|
+
placeholder="Enter your email"
|
|
58
|
+
aria-label="Email address"
|
|
59
|
+
/>
|
|
60
|
+
<button type="submit" className={styles.newsletterButton}>
|
|
61
|
+
Subscribe
|
|
62
|
+
</button>
|
|
63
|
+
</form>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
{error && (
|
|
67
|
+
<p className="form-error" style={{ marginTop: 'var(--nymbal-spacing-sm)' }}>
|
|
68
|
+
{error}
|
|
69
|
+
</p>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
4
|
+
import { NymbalProvider } from '@nymbalhq/react'
|
|
5
|
+
import { registerClient, registerComponents } from '@nymbalhq/web-components'
|
|
6
|
+
import '@nymbalhq/web-components/tokens.css'
|
|
7
|
+
import { getNymbalClient, restoreSession } from '@/lib/client'
|
|
8
|
+
|
|
9
|
+
export function NymbalSetup({ children }: { children: ReactNode }) {
|
|
10
|
+
const client = getNymbalClient()
|
|
11
|
+
const initialized = useRef(false)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!initialized.current) {
|
|
15
|
+
registerClient(client)
|
|
16
|
+
registerComponents()
|
|
17
|
+
client.cart.load()
|
|
18
|
+
void restoreSession()
|
|
19
|
+
initialized.current = true
|
|
20
|
+
}
|
|
21
|
+
}, [client])
|
|
22
|
+
|
|
23
|
+
return <NymbalProvider client={client}>{children}</NymbalProvider>
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import type { OrderStatus } from '@nymbalhq/types'
|
|
6
|
+
import { getNymbalClient } from '@/lib/client'
|
|
7
|
+
|
|
8
|
+
// A pending order's page asks the API again for a while: once the payment lands (a Stripe webhook), the page is
|
|
9
|
+
// rendered afresh with the order's new state.
|
|
10
|
+
const POLL_EVERY_MS = 1_000
|
|
11
|
+
const POLL_FOR_MS = 10_000
|
|
12
|
+
|
|
13
|
+
export function OrderStatusRefresh({
|
|
14
|
+
orderNumber,
|
|
15
|
+
status,
|
|
16
|
+
}: {
|
|
17
|
+
orderNumber: string
|
|
18
|
+
status: OrderStatus
|
|
19
|
+
}) {
|
|
20
|
+
const router = useRouter()
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (status !== 'pending') return
|
|
24
|
+
const until = Date.now() + POLL_FOR_MS
|
|
25
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
26
|
+
let stopped = false
|
|
27
|
+
const poll = async () => {
|
|
28
|
+
try {
|
|
29
|
+
const order = await getNymbalClient().customer.orders.get(orderNumber)
|
|
30
|
+
if (stopped) return
|
|
31
|
+
if (order.status !== status) {
|
|
32
|
+
router.refresh()
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// A failed read is asked again on the next tick, within the window.
|
|
37
|
+
}
|
|
38
|
+
if (!stopped && Date.now() < until) timer = setTimeout(poll, POLL_EVERY_MS)
|
|
39
|
+
}
|
|
40
|
+
timer = setTimeout(poll, POLL_EVERY_MS)
|
|
41
|
+
return () => {
|
|
42
|
+
stopped = true
|
|
43
|
+
clearTimeout(timer)
|
|
44
|
+
}
|
|
45
|
+
}, [orderNumber, status, router])
|
|
46
|
+
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
4
|
+
import type { DenormalisedProduct } from '@nymbalhq/sdk'
|
|
5
|
+
import type { Category, Facet, ProductSortKey } from '@nymbalhq/types'
|
|
6
|
+
import { useProductList } from '@nymbalhq/react'
|
|
7
|
+
import { ProductFilter } from '@nymbalhq/react'
|
|
8
|
+
import { ProductCard } from '@/components/server/ProductCard'
|
|
9
|
+
import { SortDropdown } from './SortDropdown'
|
|
10
|
+
import styles from '@/styles/pages/plp.module.css'
|
|
11
|
+
|
|
12
|
+
interface ProductGridProps {
|
|
13
|
+
initialProducts: DenormalisedProduct[]
|
|
14
|
+
initialCursor: string | null
|
|
15
|
+
initialSort: ProductSortKey
|
|
16
|
+
categorySlug?: string
|
|
17
|
+
categories: Category[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ProductGrid({
|
|
21
|
+
initialProducts,
|
|
22
|
+
initialCursor,
|
|
23
|
+
initialSort,
|
|
24
|
+
categorySlug,
|
|
25
|
+
categories,
|
|
26
|
+
}: ProductGridProps) {
|
|
27
|
+
const { products, facets, pagination, loading, sort, load, loadMore, setSort } = useProductList()
|
|
28
|
+
const [initialized, setInitialized] = useState(false)
|
|
29
|
+
const [showMobileFilters, setShowMobileFilters] = useState(false)
|
|
30
|
+
// Preserve the widest facet list seen so filter options never disappear during a
|
|
31
|
+
// filtered query (the API always returns full facets, but this guards against regressions).
|
|
32
|
+
const [stableFacets, setStableFacets] = useState<Facet[]>(
|
|
33
|
+
categories.length > 0
|
|
34
|
+
? [
|
|
35
|
+
{
|
|
36
|
+
field: 'category',
|
|
37
|
+
label: 'Category',
|
|
38
|
+
values: categories.map((c) => ({ value: c.slug, label: c.name })),
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
: [],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
load({ limit: 24, category: categorySlug, sort: initialSort })
|
|
46
|
+
setInitialized(true)
|
|
47
|
+
}, [categorySlug, initialSort, load])
|
|
48
|
+
|
|
49
|
+
// The URL carries the sort, so a reload or a shared link renders the same order server-side.
|
|
50
|
+
const handleSort = useCallback(
|
|
51
|
+
(key: ProductSortKey) => {
|
|
52
|
+
const url = new URL(window.location.href)
|
|
53
|
+
url.searchParams.set('sort', key)
|
|
54
|
+
window.history.replaceState(window.history.state, '', url)
|
|
55
|
+
void setSort(key)
|
|
56
|
+
},
|
|
57
|
+
[setSort],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Keep stableFacets updated whenever the store returns a wider facet list.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (facets.length > 0) {
|
|
63
|
+
const incomingValues = facets.reduce((acc, f) => acc + f.values.length, 0)
|
|
64
|
+
const stableValues = stableFacets.reduce((acc, f) => acc + f.values.length, 0)
|
|
65
|
+
if (incomingValues >= stableValues) {
|
|
66
|
+
setStableFacets(facets)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}, [facets, stableFacets])
|
|
70
|
+
|
|
71
|
+
const displayProducts = initialized && products.length > 0 ? products : initialProducts
|
|
72
|
+
const hasMore = initialized ? pagination.hasMore : !!initialCursor
|
|
73
|
+
|
|
74
|
+
const handleLoadMore = useCallback(() => {
|
|
75
|
+
if (hasMore && !loading) {
|
|
76
|
+
loadMore()
|
|
77
|
+
}
|
|
78
|
+
}, [hasMore, loading, loadMore])
|
|
79
|
+
|
|
80
|
+
// Use stableFacets (which always contains the full category list) so all filter
|
|
81
|
+
// options remain visible after a category filter is applied.
|
|
82
|
+
const displayFacets = stableFacets
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={styles.layout}>
|
|
86
|
+
<aside className={styles.sidebar}>
|
|
87
|
+
<button
|
|
88
|
+
className={styles.mobileFilterToggle}
|
|
89
|
+
onClick={() => setShowMobileFilters((v) => !v)}
|
|
90
|
+
type="button"
|
|
91
|
+
>
|
|
92
|
+
{showMobileFilters ? 'Hide Filters' : 'Show Filters'}
|
|
93
|
+
</button>
|
|
94
|
+
<div
|
|
95
|
+
className={styles.filterSection}
|
|
96
|
+
style={{ display: showMobileFilters ? 'block' : undefined }}
|
|
97
|
+
>
|
|
98
|
+
<ProductFilter facets={displayFacets} />
|
|
99
|
+
</div>
|
|
100
|
+
</aside>
|
|
101
|
+
|
|
102
|
+
<div className={styles.content}>
|
|
103
|
+
<div className={styles.sortBar}>
|
|
104
|
+
<span className={styles.resultCount}>
|
|
105
|
+
{displayProducts.length} product{displayProducts.length !== 1 ? 's' : ''}
|
|
106
|
+
</span>
|
|
107
|
+
<SortDropdown value={initialized ? sort : initialSort} onChange={handleSort} />
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{displayProducts.length > 0 ? (
|
|
111
|
+
<div className="product-grid">
|
|
112
|
+
{displayProducts.map((product) => (
|
|
113
|
+
<ProductCard
|
|
114
|
+
key={product.id}
|
|
115
|
+
product={{
|
|
116
|
+
slug: product.slug,
|
|
117
|
+
name: product.name,
|
|
118
|
+
priceMinor: product.priceMinor ?? null,
|
|
119
|
+
priceRange: product.priceRange ?? null,
|
|
120
|
+
currency: product.currency ?? null,
|
|
121
|
+
media: product.media,
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
) : (
|
|
127
|
+
<div className={styles.empty}>
|
|
128
|
+
<p>No products found.</p>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{hasMore && (
|
|
133
|
+
<div className={styles.pagination}>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
className="btn btn-secondary"
|
|
137
|
+
onClick={handleLoadMore}
|
|
138
|
+
disabled={loading}
|
|
139
|
+
data-testid="load-more"
|
|
140
|
+
>
|
|
141
|
+
{loading ? 'Loading...' : 'Load More'}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { DenormalisedProduct, DenormalisedVariant } from '@nymbalhq/sdk'
|
|
4
|
+
import { useProduct, VariantSelector, AddToCart } from '@nymbalhq/react'
|
|
5
|
+
import { formatPrice } from '@/lib/format'
|
|
6
|
+
import styles from '@/styles/pages/pdp.module.css'
|
|
7
|
+
|
|
8
|
+
interface ProductInteractiveProps {
|
|
9
|
+
product: DenormalisedProduct
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ProductInteractive({ product }: ProductInteractiveProps) {
|
|
13
|
+
const { selectedVariant } = useProduct(product.slug)
|
|
14
|
+
|
|
15
|
+
const variant = selectedVariant ?? product.variants?.[0]
|
|
16
|
+
const price = variant?.priceMinor ?? product.priceMinor ?? 0
|
|
17
|
+
const currency = product.currency ?? 'GBP'
|
|
18
|
+
const stock = variant?.stock ?? (product.inStock ? 10 : 0)
|
|
19
|
+
|
|
20
|
+
// The selector derives its option groups from the product's variants.
|
|
21
|
+
const hasVariantChoice = (product.variants?.length ?? 0) > 1
|
|
22
|
+
|
|
23
|
+
const handleVariantSelected = (e: CustomEvent<{ variant: unknown }>) => {
|
|
24
|
+
const v = e.detail.variant as DenormalisedVariant | undefined
|
|
25
|
+
if (v) {
|
|
26
|
+
// The product store handles variant selection internally
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div>
|
|
32
|
+
<h1 className={styles.productTitle} data-testid="product-title">
|
|
33
|
+
{product.name}
|
|
34
|
+
</h1>
|
|
35
|
+
|
|
36
|
+
<div className={styles.price} data-testid="product-price">
|
|
37
|
+
{formatPrice(price, currency)}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div className={styles.stockBadge} data-testid="stock-badge">
|
|
41
|
+
{stock === 0 ? (
|
|
42
|
+
<span style={{ color: 'var(--nymbal-color-error)' }}>Out of Stock</span>
|
|
43
|
+
) : stock < 5 ? (
|
|
44
|
+
<span style={{ color: 'var(--nymbal-color-warning)' }}>Low Stock — {stock} left</span>
|
|
45
|
+
) : (
|
|
46
|
+
<span style={{ color: 'var(--nymbal-color-success)' }}>In Stock</span>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
{hasVariantChoice && (
|
|
51
|
+
<div className={styles.variantSection}>
|
|
52
|
+
<VariantSelector onVariantSelected={handleVariantSelected} />
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
<div className={styles.addToCartSection}>
|
|
57
|
+
<AddToCart variantId={variant?.id ?? product.id} disabled={stock === 0} />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, type FormEvent } from 'react'
|
|
4
|
+
import { useAuth } from '@nymbalhq/react'
|
|
5
|
+
import styles from '@/styles/pages/account.module.css'
|
|
6
|
+
|
|
7
|
+
export function ProfileForm() {
|
|
8
|
+
const { customer, updateProfile } = useAuth()
|
|
9
|
+
|
|
10
|
+
const [firstName, setFirstName] = useState('')
|
|
11
|
+
const [lastName, setLastName] = useState('')
|
|
12
|
+
const [phone, setPhone] = useState('')
|
|
13
|
+
const [saving, setSaving] = useState(false)
|
|
14
|
+
const [success, setSuccess] = useState(false)
|
|
15
|
+
const [error, setError] = useState<string | null>(null)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (customer) {
|
|
19
|
+
setFirstName(customer.firstName ?? '')
|
|
20
|
+
setLastName(customer.lastName ?? '')
|
|
21
|
+
setPhone(customer.phone ?? '')
|
|
22
|
+
}
|
|
23
|
+
}, [customer])
|
|
24
|
+
|
|
25
|
+
const handleSubmit = useCallback(
|
|
26
|
+
async (e: FormEvent) => {
|
|
27
|
+
e.preventDefault()
|
|
28
|
+
setSaving(true)
|
|
29
|
+
setError(null)
|
|
30
|
+
setSuccess(false)
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await updateProfile({ firstName, lastName, phone })
|
|
34
|
+
setSuccess(true)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
setError(err instanceof Error ? err.message : 'Failed to update profile.')
|
|
37
|
+
} finally {
|
|
38
|
+
setSaving(false)
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
[firstName, lastName, phone, updateProfile],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div>
|
|
46
|
+
<h2 className={styles.pageTitle}>Profile</h2>
|
|
47
|
+
|
|
48
|
+
{success && (
|
|
49
|
+
<div className={`${styles.alert} ${styles.alertSuccess}`}>
|
|
50
|
+
Profile updated successfully.
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
{error && <div className={`${styles.alert} ${styles.alertError}`}>{error}</div>}
|
|
54
|
+
|
|
55
|
+
<div className={styles.formCard}>
|
|
56
|
+
<h3 className={styles.formCardTitle}>Personal Information</h3>
|
|
57
|
+
<form onSubmit={handleSubmit}>
|
|
58
|
+
<div className={styles.formGrid}>
|
|
59
|
+
<div>
|
|
60
|
+
<label htmlFor="profile-first-name">First Name</label>
|
|
61
|
+
<input
|
|
62
|
+
id="profile-first-name"
|
|
63
|
+
data-testid="profile-firstName"
|
|
64
|
+
type="text"
|
|
65
|
+
value={firstName}
|
|
66
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
67
|
+
autoComplete="given-name"
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
<div>
|
|
71
|
+
<label htmlFor="profile-last-name">Last Name</label>
|
|
72
|
+
<input
|
|
73
|
+
id="profile-last-name"
|
|
74
|
+
data-testid="profile-lastName"
|
|
75
|
+
type="text"
|
|
76
|
+
value={lastName}
|
|
77
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
78
|
+
autoComplete="family-name"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
<div className={styles.formGridFull}>
|
|
82
|
+
<label htmlFor="profile-email">Email</label>
|
|
83
|
+
<input
|
|
84
|
+
id="profile-email"
|
|
85
|
+
type="email"
|
|
86
|
+
value={customer?.email ?? ''}
|
|
87
|
+
disabled
|
|
88
|
+
autoComplete="email"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
<div className={styles.formGridFull}>
|
|
92
|
+
<label htmlFor="profile-phone">Phone</label>
|
|
93
|
+
<input
|
|
94
|
+
id="profile-phone"
|
|
95
|
+
type="tel"
|
|
96
|
+
value={phone}
|
|
97
|
+
onChange={(e) => setPhone(e.target.value)}
|
|
98
|
+
placeholder="Your phone number"
|
|
99
|
+
autoComplete="tel"
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
<div className={styles.formActions}>
|
|
104
|
+
<button type="submit" className={styles.saveButton} disabled={saving}>
|
|
105
|
+
{saving ? 'Saving...' : 'Save Changes'}
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</form>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, type FormEvent } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import Link from 'next/link'
|
|
6
|
+
import { useAuth } from '@nymbalhq/react'
|
|
7
|
+
import styles from '@/styles/pages/account.module.css'
|
|
8
|
+
|
|
9
|
+
export function RegisterForm() {
|
|
10
|
+
const router = useRouter()
|
|
11
|
+
const { register } = useAuth()
|
|
12
|
+
|
|
13
|
+
const [firstName, setFirstName] = useState('')
|
|
14
|
+
const [lastName, setLastName] = useState('')
|
|
15
|
+
const [email, setEmail] = useState('')
|
|
16
|
+
const [password, setPassword] = useState('')
|
|
17
|
+
const [confirmPassword, setConfirmPassword] = useState('')
|
|
18
|
+
const [error, setError] = useState<string | null>(null)
|
|
19
|
+
const [loading, setLoading] = useState(false)
|
|
20
|
+
|
|
21
|
+
const handleSubmit = useCallback(
|
|
22
|
+
async (e: FormEvent) => {
|
|
23
|
+
e.preventDefault()
|
|
24
|
+
setError(null)
|
|
25
|
+
|
|
26
|
+
if (password !== confirmPassword) {
|
|
27
|
+
setError('Passwords do not match.')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setLoading(true)
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await register({ firstName, lastName, email, password })
|
|
35
|
+
router.push('/account')
|
|
36
|
+
} catch (err) {
|
|
37
|
+
setError(err instanceof Error ? err.message : 'Registration failed. Please try again.')
|
|
38
|
+
setLoading(false)
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
[firstName, lastName, email, password, confirmPassword, register, router],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={styles.authPage}>
|
|
46
|
+
<div className={styles.authCard} data-testid="register-form">
|
|
47
|
+
<h1 className={styles.authTitle}>Create Account</h1>
|
|
48
|
+
<p className={styles.authSubtitle}>Join us and start shopping.</p>
|
|
49
|
+
|
|
50
|
+
{error && <div className={styles.alertError + ' ' + styles.alert}>{error}</div>}
|
|
51
|
+
|
|
52
|
+
<form onSubmit={handleSubmit} className={styles.authForm}>
|
|
53
|
+
<div className="form-group">
|
|
54
|
+
<label htmlFor="register-first-name">First Name</label>
|
|
55
|
+
<input
|
|
56
|
+
id="register-first-name"
|
|
57
|
+
type="text"
|
|
58
|
+
value={firstName}
|
|
59
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
60
|
+
required
|
|
61
|
+
placeholder="First name"
|
|
62
|
+
autoComplete="given-name"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="form-group">
|
|
66
|
+
<label htmlFor="register-last-name">Last Name</label>
|
|
67
|
+
<input
|
|
68
|
+
id="register-last-name"
|
|
69
|
+
type="text"
|
|
70
|
+
value={lastName}
|
|
71
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
72
|
+
required
|
|
73
|
+
placeholder="Last name"
|
|
74
|
+
autoComplete="family-name"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="form-group">
|
|
78
|
+
<label htmlFor="register-email">Email</label>
|
|
79
|
+
<input
|
|
80
|
+
id="register-email"
|
|
81
|
+
type="email"
|
|
82
|
+
value={email}
|
|
83
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
84
|
+
required
|
|
85
|
+
data-testid="register-email"
|
|
86
|
+
placeholder="you@example.com"
|
|
87
|
+
autoComplete="email"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
<div className="form-group">
|
|
91
|
+
<label htmlFor="register-password">Password</label>
|
|
92
|
+
<input
|
|
93
|
+
id="register-password"
|
|
94
|
+
type="password"
|
|
95
|
+
value={password}
|
|
96
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
97
|
+
required
|
|
98
|
+
placeholder="Create a password"
|
|
99
|
+
autoComplete="new-password"
|
|
100
|
+
minLength={8}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="form-group">
|
|
104
|
+
<label htmlFor="register-confirm-password">Confirm Password</label>
|
|
105
|
+
<input
|
|
106
|
+
id="register-confirm-password"
|
|
107
|
+
type="password"
|
|
108
|
+
value={confirmPassword}
|
|
109
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
110
|
+
required
|
|
111
|
+
placeholder="Confirm your password"
|
|
112
|
+
autoComplete="new-password"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
<button
|
|
116
|
+
type="submit"
|
|
117
|
+
className={styles.authSubmit}
|
|
118
|
+
disabled={loading}
|
|
119
|
+
>
|
|
120
|
+
{loading ? 'Creating account...' : 'Create Account'}
|
|
121
|
+
</button>
|
|
122
|
+
</form>
|
|
123
|
+
|
|
124
|
+
<p className={styles.authFooter}>
|
|
125
|
+
Already have an account?{' '}
|
|
126
|
+
<Link href="/account/login">Sign in</Link>
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|