create-nymbal-app 0.3.0 → 0.5.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/dist/index.d.ts +2 -1
- package/dist/index.js +14 -12
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/scaffold/project/astro/src/pages/index.astro +13 -15
- package/scaffold/templates/astro/_dot.node-version +1 -1
- package/scaffold/templates/astro/astro.config.mjs +23 -0
- package/scaffold/templates/astro/package.json +26 -27
- package/scaffold/templates/astro/src/components/SkipNav.astro +3 -1
- package/scaffold/templates/astro/src/elements/index.ts +11 -0
- package/scaffold/templates/astro/src/layouts/BaseLayout.astro +15 -4
- package/scaffold/templates/astro/src/lib/api.ts +126 -20
- package/scaffold/templates/astro/src/lib/chrome.ts +59 -0
- package/scaffold/templates/astro/src/lib/client.ts +4 -2
- package/scaffold/templates/astro/src/lib/fragments.ts +48 -0
- package/scaffold/templates/astro/src/lib/server-elements.ts +5 -0
- package/scaffold/templates/astro/src/middleware.ts +37 -0
- package/scaffold/templates/astro/src/pages/account/addresses.astro +5 -97
- package/scaffold/templates/astro/src/pages/account/login.astro +7 -34
- package/scaffold/templates/astro/src/pages/account/profile.astro +6 -56
- package/scaffold/templates/astro/src/pages/account/register.astro +7 -73
- package/scaffold/templates/astro/src/pages/cart.astro +14 -7
- package/scaffold/templates/astro/src/pages/category/[slug].astro +41 -26
- package/scaffold/templates/astro/src/pages/checkout.astro +34 -90
- package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -2
- package/scaffold/templates/astro/src/pages/fragments/header.astro +12 -2
- package/scaffold/templates/astro/src/pages/fragments/nav.astro +8 -5
- package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +9 -10
- package/scaffold/templates/astro/src/pages/index.astro +18 -15
- package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +22 -44
- package/scaffold/templates/astro/src/pages/product/[slug].astro +46 -29
- package/scaffold/templates/astro/src/pages/products/index.astro +40 -26
- package/scaffold/templates/astro/src/pages/search.astro +13 -8
- package/scaffold/templates/astro/src/scripts/init-client.ts +14 -0
- package/scaffold/templates/astro/src/scripts/pdp.ts +47 -53
- package/scaffold/templates/astro/src/scripts/plp.ts +21 -98
- package/scaffold/templates/astro/src/scripts/search.ts +19 -120
- package/scaffold/templates/astro/src/scripts/signed-in-redirect.ts +4 -0
- package/scaffold/templates/astro/src/styles/global.css +49 -45
- package/scaffold/templates/astro/src/styles/pages/account.css +0 -90
- package/scaffold/templates/astro/src/styles/pages/confirmation.css +6 -0
- package/scaffold/templates/astro/src/styles/pages/home.css +9 -100
- package/scaffold/templates/astro/src/styles/pages/pdp.css +5 -167
- package/scaffold/templates/astro/src/styles/pages/plp.css +3 -42
- package/scaffold/templates/astro/src/styles/pages/search.css +13 -100
- package/scaffold/templates/astro/src/styles/tokens.css +56 -0
- package/scaffold/templates/nextjs/_dot.node-version +1 -1
- package/scaffold/templates/nextjs/app/(catalog)/category/[slug]/page.tsx +74 -0
- package/scaffold/templates/nextjs/app/(catalog)/home/[key]/page.tsx +80 -0
- package/scaffold/templates/nextjs/app/(catalog)/layout.tsx +13 -0
- package/scaffold/templates/nextjs/app/(catalog)/not-found.tsx +7 -0
- package/scaffold/templates/nextjs/app/(catalog)/product/[slug]/page.tsx +182 -0
- package/scaffold/templates/nextjs/app/(catalog)/products/page.tsx +62 -0
- package/scaffold/templates/nextjs/app/(catalog)/search/SearchView.tsx +90 -0
- package/scaffold/templates/nextjs/app/(catalog)/search/page.tsx +46 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/addresses/AddressesView.tsx +5 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/addresses/page.tsx +17 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/login/LoginView.tsx +28 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/login/page.tsx +23 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/profile/ProfileView.tsx +5 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/profile/page.tsx +20 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/register/RegisterView.tsx +26 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/register/page.tsx +11 -0
- package/scaffold/templates/nextjs/app/(shopper)/cart/CartView.tsx +5 -0
- package/scaffold/templates/nextjs/app/(shopper)/cart/page.tsx +22 -0
- package/scaffold/templates/nextjs/app/(shopper)/checkout/CheckoutView.tsx +27 -0
- package/scaffold/templates/nextjs/app/(shopper)/checkout/page.tsx +39 -0
- package/scaffold/templates/nextjs/app/(shopper)/layout.tsx +12 -0
- package/scaffold/templates/nextjs/app/{order → (shopper)/order}/[orderNumber]/page.tsx +24 -5
- package/scaffold/templates/nextjs/app/error.tsx +3 -7
- package/scaffold/templates/nextjs/app/fragments/footer/route.ts +13 -0
- package/scaffold/templates/nextjs/app/fragments/header/[key]/route.ts +18 -0
- package/scaffold/templates/nextjs/app/fragments/nav/[key]/route.ts +23 -0
- package/scaffold/templates/nextjs/app/fragments/product/[slug]/route.ts +22 -0
- package/scaffold/templates/nextjs/app/globals.css +5 -23
- package/scaffold/templates/nextjs/app/layout.tsx +8 -5
- package/scaffold/templates/nextjs/app/not-found.tsx +9 -33
- package/scaffold/templates/nextjs/app/tokens.css +56 -0
- package/scaffold/templates/nextjs/components/client/ListingControls.tsx +87 -0
- package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +48 -6
- package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +64 -28
- package/scaffold/templates/nextjs/components/client/elements.tsx +15 -0
- package/scaffold/templates/nextjs/components/server/Header.tsx +33 -97
- package/scaffold/templates/nextjs/components/server/NotFoundView.tsx +39 -0
- package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -3
- package/scaffold/templates/nextjs/elements/index.ts +12 -0
- package/scaffold/templates/nextjs/lib/api.ts +162 -46
- package/scaffold/templates/nextjs/lib/chrome.ts +40 -0
- package/scaffold/templates/nextjs/lib/client.ts +3 -0
- package/scaffold/templates/nextjs/lib/fragments.ts +16 -0
- package/scaffold/templates/nextjs/lib/header.ts +39 -0
- package/scaffold/templates/nextjs/lib/render.ts +14 -0
- package/scaffold/templates/nextjs/next.config.mjs +32 -0
- package/scaffold/templates/nextjs/package.json +28 -29
- package/scaffold/templates/nextjs/styles/pages/account.module.css +0 -70
- package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +6 -85
- package/scaffold/templates/nextjs/styles/pages/home.module.css +2 -103
- package/scaffold/templates/nextjs/styles/pages/pdp.module.css +3 -179
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +8 -141
- package/scaffold/templates/nextjs/styles/pages/search.module.css +8 -128
- package/scaffold/templates/nextjs/tsconfig.dist.json +5 -0
- package/scaffold/templates/astro/src/components/AddressForm.astro +0 -107
- package/scaffold/templates/astro/src/components/Breadcrumb.astro +0 -20
- package/scaffold/templates/astro/src/components/CategoryNav.astro +0 -27
- package/scaffold/templates/astro/src/components/CheckoutSteps.astro +0 -29
- package/scaffold/templates/astro/src/components/Footer.astro +0 -108
- package/scaffold/templates/astro/src/components/Header.astro +0 -192
- package/scaffold/templates/astro/src/components/ImageGallery.astro +0 -42
- package/scaffold/templates/astro/src/components/OrderSummary.astro +0 -53
- package/scaffold/templates/astro/src/components/Pagination.astro +0 -13
- package/scaffold/templates/astro/src/components/PriceDisplay.astro +0 -25
- package/scaffold/templates/astro/src/components/ProductCard.astro +0 -44
- package/scaffold/templates/astro/src/components/RelatedProducts.astro +0 -28
- package/scaffold/templates/astro/src/components/ReviewForm.astro +0 -74
- package/scaffold/templates/astro/src/components/ReviewList.astro +0 -41
- package/scaffold/templates/astro/src/components/SortDropdown.astro +0 -26
- package/scaffold/templates/astro/src/components/StockBadge.astro +0 -32
- package/scaffold/templates/astro/src/scripts/auth-forms.ts +0 -82
- package/scaffold/templates/astro/src/scripts/cart-page.ts +0 -143
- package/scaffold/templates/astro/src/scripts/checkout.ts +0 -272
- package/scaffold/templates/astro/src/scripts/image-gallery.ts +0 -22
- package/scaffold/templates/astro/src/scripts/profile-form.ts +0 -57
- package/scaffold/templates/astro/src/scripts/review-form.ts +0 -27
- package/scaffold/templates/astro/src/styles/pages/cart.css +0 -243
- package/scaffold/templates/astro/src/styles/pages/checkout.css +0 -270
- package/scaffold/templates/nextjs/app/account/addresses/page.tsx +0 -10
- package/scaffold/templates/nextjs/app/account/login/page.tsx +0 -23
- package/scaffold/templates/nextjs/app/account/profile/page.tsx +0 -10
- package/scaffold/templates/nextjs/app/account/register/page.tsx +0 -10
- package/scaffold/templates/nextjs/app/cart/page.tsx +0 -10
- package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +0 -58
- package/scaffold/templates/nextjs/app/checkout/page.tsx +0 -10
- package/scaffold/templates/nextjs/app/page.tsx +0 -63
- package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +0 -111
- package/scaffold/templates/nextjs/app/products/page.tsx +0 -40
- package/scaffold/templates/nextjs/app/search/page.tsx +0 -24
- package/scaffold/templates/nextjs/components/client/AddressManager.tsx +0 -181
- package/scaffold/templates/nextjs/components/client/CartPage.tsx +0 -120
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +0 -523
- package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +0 -68
- package/scaffold/templates/nextjs/components/client/LoginForm.tsx +0 -89
- package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +0 -12
- package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +0 -148
- package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +0 -112
- package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +0 -131
- package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +0 -116
- package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +0 -27
- package/scaffold/templates/nextjs/components/client/SearchResults.tsx +0 -141
- package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +0 -35
- package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +0 -36
- package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +0 -29
- package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +0 -60
- package/scaffold/templates/nextjs/components/server/Footer.module.css +0 -64
- package/scaffold/templates/nextjs/components/server/Footer.tsx +0 -79
- package/scaffold/templates/nextjs/components/server/Header.module.css +0 -147
- package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +0 -79
- package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +0 -31
- package/scaffold/templates/nextjs/components/server/ProductCard.tsx +0 -59
- package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +0 -31
- package/scaffold/templates/nextjs/components/server/ReviewList.tsx +0 -62
- package/scaffold/templates/nextjs/components/server/StockBadge.tsx +0 -19
- package/scaffold/templates/nextjs/styles/pages/cart.module.css +0 -240
- package/scaffold/templates/nextjs/styles/pages/checkout.module.css +0 -404
- /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/layout.tsx +0 -0
- /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/[orderNumber]/page.tsx +0 -0
- /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/page.tsx +0 -0
- /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/page.tsx +0 -0
|
@@ -2,7 +2,8 @@ import type { Metadata } from 'next'
|
|
|
2
2
|
import { notFound } from 'next/navigation'
|
|
3
3
|
import Link from 'next/link'
|
|
4
4
|
import { fetchOrder } from '@/lib/api'
|
|
5
|
-
import { OrderSummary } from '@/components/
|
|
5
|
+
import { OrderSummary } from '@/components/client/elements'
|
|
6
|
+
import { renderNymbal } from '@/lib/render'
|
|
6
7
|
import { OrderStatusRefresh } from '@/components/client/OrderStatusRefresh'
|
|
7
8
|
import { isPaid, orderHeading } from '@/lib/order-status'
|
|
8
9
|
import styles from '@/styles/pages/confirmation.module.css'
|
|
@@ -59,6 +60,21 @@ export default async function OrderPage({ params }: OrderPageProps) {
|
|
|
59
60
|
|
|
60
61
|
if (!order) notFound()
|
|
61
62
|
const paid = isPaid(order.status)
|
|
63
|
+
const items = order.lineItems.map((item) => ({
|
|
64
|
+
name: `${item.productName}${item.variantName ? ` - ${item.variantName}` : ''}`,
|
|
65
|
+
qty: item.qty,
|
|
66
|
+
unitPriceMinor: item.unitPriceMinor,
|
|
67
|
+
lineTotalMinor: item.lineTotalMinor,
|
|
68
|
+
}))
|
|
69
|
+
const summary = {
|
|
70
|
+
items: JSON.stringify(items),
|
|
71
|
+
'subtotal-minor': String(order.subtotalMinor),
|
|
72
|
+
'discount-minor': String(order.discountTotalMinor ?? 0),
|
|
73
|
+
'shipping-minor': String(order.shippingTotalMinor ?? 0),
|
|
74
|
+
'tax-minor': String(order.taxTotalMinor ?? 0),
|
|
75
|
+
'total-minor': String(order.totalMinor),
|
|
76
|
+
currency: order.currency,
|
|
77
|
+
}
|
|
62
78
|
|
|
63
79
|
return (
|
|
64
80
|
<div className={`container ${styles.page}`}>
|
|
@@ -97,12 +113,15 @@ export default async function OrderPage({ params }: OrderPageProps) {
|
|
|
97
113
|
</div>
|
|
98
114
|
|
|
99
115
|
<OrderSummary
|
|
100
|
-
|
|
116
|
+
className={styles.summary}
|
|
117
|
+
items={items}
|
|
101
118
|
subtotalMinor={order.subtotalMinor}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
119
|
+
discountMinor={order.discountTotalMinor ?? 0}
|
|
120
|
+
shippingMinor={order.shippingTotalMinor ?? 0}
|
|
121
|
+
taxMinor={order.taxTotalMinor ?? 0}
|
|
105
122
|
totalMinor={order.totalMinor}
|
|
123
|
+
currency={order.currency}
|
|
124
|
+
serverHtml={renderNymbal('nymbal-order-summary', summary).__html}
|
|
106
125
|
/>
|
|
107
126
|
|
|
108
127
|
<div className={styles.orderCard}>
|
|
@@ -10,7 +10,7 @@ interface ErrorProps {
|
|
|
10
10
|
|
|
11
11
|
export default function ErrorPage({ error: _error, reset }: ErrorProps) {
|
|
12
12
|
return (
|
|
13
|
-
<
|
|
13
|
+
<main id="main" className={styles.page}>
|
|
14
14
|
<div className={styles.wrapper}>
|
|
15
15
|
<div className={styles.icon}>
|
|
16
16
|
<svg
|
|
@@ -35,15 +35,11 @@ export default function ErrorPage({ error: _error, reset }: ErrorProps) {
|
|
|
35
35
|
<Link href="/" className={styles.homeLink}>
|
|
36
36
|
Go Home
|
|
37
37
|
</Link>
|
|
38
|
-
<button
|
|
39
|
-
type="button"
|
|
40
|
-
className={styles.retryButton}
|
|
41
|
-
onClick={reset}
|
|
42
|
-
>
|
|
38
|
+
<button type="button" className={styles.retryButton} onClick={reset}>
|
|
43
39
|
Try Again
|
|
44
40
|
</button>
|
|
45
41
|
</div>
|
|
46
42
|
</div>
|
|
47
|
-
</
|
|
43
|
+
</main>
|
|
48
44
|
)
|
|
49
45
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FOOTER_COLUMNS } from '@/lib/chrome'
|
|
2
|
+
import { fragment } from '@/lib/fragments'
|
|
3
|
+
import { renderElement } from '@/lib/render'
|
|
4
|
+
|
|
5
|
+
export const revalidate = 86400
|
|
6
|
+
|
|
7
|
+
export function GET(): Response {
|
|
8
|
+
return fragment(
|
|
9
|
+
renderElement('nymbal-site-footer', {
|
|
10
|
+
attributes: { columns: JSON.stringify(FOOTER_COLUMNS) },
|
|
11
|
+
}),
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fragment, noFragment } from '@/lib/fragments'
|
|
2
|
+
import { canSearch, siteHeader } from '@/lib/header'
|
|
3
|
+
import { renderElement } from '@/lib/render'
|
|
4
|
+
|
|
5
|
+
export const revalidate = 3600
|
|
6
|
+
// A dynamic segment, so the build prerenders nothing when the API may be down; next.config.mjs rewrites
|
|
7
|
+
// `/fragments/<name>` to `/fragments/<name>/index`.
|
|
8
|
+
export function generateStaticParams(): Array<{ key: string }> {
|
|
9
|
+
return []
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function GET(
|
|
13
|
+
_request: Request,
|
|
14
|
+
{ params }: { params: Promise<{ key: string }> },
|
|
15
|
+
): Promise<Response> {
|
|
16
|
+
if ((await params).key !== 'index') return noFragment()
|
|
17
|
+
return fragment(renderElement('nymbal-site-header', siteHeader(await canSearch())))
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fetchCategories } from '@/lib/api'
|
|
2
|
+
import { fragment, noFragment } from '@/lib/fragments'
|
|
3
|
+
import { renderElement } from '@/lib/render'
|
|
4
|
+
|
|
5
|
+
export const revalidate = 21600
|
|
6
|
+
// A dynamic segment, so the build prerenders nothing when the API may be down; next.config.mjs rewrites
|
|
7
|
+
// `/fragments/<name>` to `/fragments/<name>/index`.
|
|
8
|
+
export function generateStaticParams(): Array<{ key: string }> {
|
|
9
|
+
return []
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function GET(
|
|
13
|
+
_request: Request,
|
|
14
|
+
{ params }: { params: Promise<{ key: string }> },
|
|
15
|
+
): Promise<Response> {
|
|
16
|
+
if ((await params).key !== 'index') return noFragment()
|
|
17
|
+
const categories = await fetchCategories()
|
|
18
|
+
return fragment(
|
|
19
|
+
renderElement('nymbal-category-nav', {
|
|
20
|
+
attributes: { categories: JSON.stringify(categories) },
|
|
21
|
+
}),
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { fetchProduct } from '@/lib/api'
|
|
2
|
+
import { fragment, noFragment } from '@/lib/fragments'
|
|
3
|
+
import { cardProductOf, renderElement } from '@/lib/render'
|
|
4
|
+
|
|
5
|
+
export const revalidate = 300
|
|
6
|
+
// The build prerenders no product, since the API may be down then.
|
|
7
|
+
export function generateStaticParams(): Array<{ slug: string }> {
|
|
8
|
+
return []
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function GET(
|
|
12
|
+
_request: Request,
|
|
13
|
+
{ params }: { params: Promise<{ slug: string }> },
|
|
14
|
+
): Promise<Response> {
|
|
15
|
+
const product = await fetchProduct((await params).slug)
|
|
16
|
+
if (product === null) return noFragment()
|
|
17
|
+
return fragment(
|
|
18
|
+
renderElement('nymbal-product-card', {
|
|
19
|
+
attributes: { product: JSON.stringify(cardProductOf(product)) },
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
@import '
|
|
1
|
+
@import './tokens.css';
|
|
2
2
|
|
|
3
3
|
/* ── CSS Reset ─────────────────────────────────────────────────────── */
|
|
4
4
|
|
|
@@ -169,7 +169,8 @@ a:focus-visible {
|
|
|
169
169
|
border: 1px solid transparent;
|
|
170
170
|
border-radius: var(--nymbal-radius-md);
|
|
171
171
|
cursor: pointer;
|
|
172
|
-
transition:
|
|
172
|
+
transition:
|
|
173
|
+
background-color var(--nymbal-transition-fast),
|
|
173
174
|
color var(--nymbal-transition-fast),
|
|
174
175
|
border-color var(--nymbal-transition-fast),
|
|
175
176
|
box-shadow var(--nymbal-transition-fast);
|
|
@@ -243,7 +244,8 @@ select {
|
|
|
243
244
|
background-color: var(--nymbal-color-surface);
|
|
244
245
|
border: 1px solid var(--nymbal-color-border);
|
|
245
246
|
border-radius: var(--nymbal-radius-md);
|
|
246
|
-
transition:
|
|
247
|
+
transition:
|
|
248
|
+
border-color var(--nymbal-transition-fast),
|
|
247
249
|
box-shadow var(--nymbal-transition-fast);
|
|
248
250
|
appearance: none;
|
|
249
251
|
}
|
|
@@ -281,26 +283,6 @@ label {
|
|
|
281
283
|
margin-top: var(--nymbal-spacing-xs);
|
|
282
284
|
}
|
|
283
285
|
|
|
284
|
-
/* ── Product Grid ──────────────────────────────────────────────────── */
|
|
285
|
-
|
|
286
|
-
.product-grid {
|
|
287
|
-
display: grid;
|
|
288
|
-
grid-template-columns: 1fr;
|
|
289
|
-
gap: var(--nymbal-spacing-lg);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
@media (min-width: 640px) {
|
|
293
|
-
.product-grid {
|
|
294
|
-
grid-template-columns: repeat(2, 1fr);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
@media (min-width: 1024px) {
|
|
299
|
-
.product-grid {
|
|
300
|
-
grid-template-columns: repeat(4, 1fr);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
286
|
/* ── Link Styles ───────────────────────────────────────────────────── */
|
|
305
287
|
|
|
306
288
|
.link-muted {
|
|
@@ -2,25 +2,28 @@ import type { ReactNode } from 'react'
|
|
|
2
2
|
import type { Metadata } from 'next'
|
|
3
3
|
import './globals.css'
|
|
4
4
|
import { NymbalSetup } from '@/components/client/NymbalSetup'
|
|
5
|
-
import {
|
|
6
|
-
import { Footer } from '@/components/server/Footer'
|
|
5
|
+
import { SiteFooter } from '@/components/client/elements'
|
|
7
6
|
import { SkipNav } from '@/components/server/SkipNav'
|
|
8
7
|
import { CartDrawerToggle } from '@/components/client/CartDrawerToggle'
|
|
8
|
+
import { FOOTER_COLUMNS } from '@/lib/chrome'
|
|
9
|
+
import { renderNymbal } from '@/lib/render'
|
|
9
10
|
|
|
10
11
|
export const metadata: Metadata = {
|
|
11
12
|
title: { default: 'Nymbal Store', template: '%s | Nymbal' },
|
|
12
13
|
description: 'Shop the Nymbal store',
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// The root layout makes no API call: Next renders the 404 under it at build, when the API may be down. Each route
|
|
17
|
+
// group's layout brings the header the group's pages share, (catalog)'s cached for every shopper, (shopper)'s per request.
|
|
15
18
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
19
|
+
const footer = renderNymbal('nymbal-site-footer', { columns: JSON.stringify(FOOTER_COLUMNS) })
|
|
16
20
|
return (
|
|
17
21
|
<html lang="en">
|
|
18
22
|
<body>
|
|
19
23
|
<NymbalSetup>
|
|
20
24
|
<SkipNav />
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
<Footer />
|
|
25
|
+
{children}
|
|
26
|
+
<SiteFooter columns={FOOTER_COLUMNS} serverHtml={footer.__html} />
|
|
24
27
|
<CartDrawerToggle />
|
|
25
28
|
</NymbalSetup>
|
|
26
29
|
</body>
|
|
@@ -1,38 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { PlainHeader } from '@/components/server/Header'
|
|
2
|
+
import { NotFoundView } from '@/components/server/NotFoundView'
|
|
3
3
|
|
|
4
|
+
// A URL no route matches, and a shopper page with nothing to show.
|
|
4
5
|
export default function NotFound() {
|
|
5
6
|
return (
|
|
6
|
-
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
stroke="currentColor"
|
|
13
|
-
strokeWidth="2"
|
|
14
|
-
strokeLinecap="round"
|
|
15
|
-
strokeLinejoin="round"
|
|
16
|
-
aria-hidden="true"
|
|
17
|
-
>
|
|
18
|
-
<circle cx="11" cy="11" r="8" />
|
|
19
|
-
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
20
|
-
</svg>
|
|
21
|
-
</div>
|
|
22
|
-
<p className={styles.statusCode}>404</p>
|
|
23
|
-
<h1 className={styles.title}>Page Not Found</h1>
|
|
24
|
-
<p className={styles.message}>
|
|
25
|
-
The page you are looking for does not exist or may have been moved.
|
|
26
|
-
</p>
|
|
27
|
-
<div className={styles.actions}>
|
|
28
|
-
<Link href="/" className={styles.homeLink}>
|
|
29
|
-
Go Home
|
|
30
|
-
</Link>
|
|
31
|
-
<Link href="/products" className={styles.retryButton}>
|
|
32
|
-
Browse Products
|
|
33
|
-
</Link>
|
|
34
|
-
</div>
|
|
35
|
-
</div>
|
|
36
|
-
</div>
|
|
7
|
+
<>
|
|
8
|
+
<PlainHeader />
|
|
9
|
+
<main id="main">
|
|
10
|
+
<NotFoundView />
|
|
11
|
+
</main>
|
|
12
|
+
</>
|
|
37
13
|
)
|
|
38
14
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* ----------------------------------------------------------------
|
|
2
|
+
Design tokens: this store's file, edit it to restyle.
|
|
3
|
+
Every Nymbal element reads its look from these custom properties,
|
|
4
|
+
which cross into its shadow root. An element's `part`s can be
|
|
5
|
+
styled further with ::part(), e.g. nymbal-add-to-cart::part(button).
|
|
6
|
+
Defaults: @nymbalhq/web-components/tokens.css.
|
|
7
|
+
---------------------------------------------------------------- */
|
|
8
|
+
|
|
9
|
+
:root {
|
|
10
|
+
--nymbal-color-primary: #18181b;
|
|
11
|
+
--nymbal-color-primary-hover: #27272a;
|
|
12
|
+
--nymbal-color-accent: #2563eb;
|
|
13
|
+
--nymbal-color-accent-hover: #1d4ed8;
|
|
14
|
+
--nymbal-color-success: #16a34a;
|
|
15
|
+
--nymbal-color-error: #dc2626;
|
|
16
|
+
--nymbal-color-warning: #d97706;
|
|
17
|
+
--nymbal-color-text: #18181b;
|
|
18
|
+
--nymbal-color-text-muted: #71717a;
|
|
19
|
+
--nymbal-color-text-inverse: #ffffff;
|
|
20
|
+
--nymbal-color-border: #e4e4e7;
|
|
21
|
+
--nymbal-color-surface: #ffffff;
|
|
22
|
+
--nymbal-color-overlay: rgba(0, 0, 0, 0.4);
|
|
23
|
+
|
|
24
|
+
--nymbal-font-family: system-ui, -apple-system, sans-serif;
|
|
25
|
+
--nymbal-font-size-xs: 0.75rem;
|
|
26
|
+
--nymbal-font-size-sm: 0.875rem;
|
|
27
|
+
--nymbal-font-size-base: 1rem;
|
|
28
|
+
--nymbal-font-size-lg: 1.125rem;
|
|
29
|
+
--nymbal-font-size-xl: 1.25rem;
|
|
30
|
+
--nymbal-font-weight-normal: 400;
|
|
31
|
+
--nymbal-font-weight-medium: 500;
|
|
32
|
+
--nymbal-font-weight-bold: 700;
|
|
33
|
+
|
|
34
|
+
--nymbal-spacing-xs: 0.25rem;
|
|
35
|
+
--nymbal-spacing-sm: 0.5rem;
|
|
36
|
+
--nymbal-spacing-md: 1rem;
|
|
37
|
+
--nymbal-spacing-lg: 1.5rem;
|
|
38
|
+
--nymbal-spacing-xl: 2rem;
|
|
39
|
+
|
|
40
|
+
--nymbal-radius-sm: 0.25rem;
|
|
41
|
+
--nymbal-radius-md: 0.375rem;
|
|
42
|
+
--nymbal-radius-lg: 0.5rem;
|
|
43
|
+
--nymbal-radius-full: 9999px;
|
|
44
|
+
|
|
45
|
+
--nymbal-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
46
|
+
--nymbal-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
47
|
+
--nymbal-shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
|
48
|
+
|
|
49
|
+
--nymbal-transition-fast: 150ms ease;
|
|
50
|
+
--nymbal-transition-normal: 200ms ease;
|
|
51
|
+
--nymbal-transition-slow: 300ms ease;
|
|
52
|
+
|
|
53
|
+
--nymbal-z-dropdown: 100;
|
|
54
|
+
--nymbal-z-drawer: 200;
|
|
55
|
+
--nymbal-z-toast: 300;
|
|
56
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
|
4
|
+
import type { Facet, ProductSortKey } from '@nymbalhq/types'
|
|
5
|
+
import { ProductFilter, SortDropdown, useNymbalClient } from '@nymbalhq/react'
|
|
6
|
+
import styles from '@/styles/pages/plp.module.css'
|
|
7
|
+
|
|
8
|
+
interface ListingControlsProps {
|
|
9
|
+
/** The server-rendered listing's sort and category: the product-list store loads the same page on mount. */
|
|
10
|
+
sort: ProductSortKey
|
|
11
|
+
category?: string
|
|
12
|
+
/** The listing's facets: none when the store's search cannot facet, and then no filter. */
|
|
13
|
+
facets: Facet[]
|
|
14
|
+
/** The sort keys the store reports, in order, and the one it lists by when none is asked. */
|
|
15
|
+
sortKeys: ProductSortKey[]
|
|
16
|
+
defaultSort?: ProductSortKey | undefined
|
|
17
|
+
/** The listing's `nymbal-product-grid`, which follows the store once this load settles. */
|
|
18
|
+
children: ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A listing's filter and sort around its grid: they drive the product-list store, which the grid follows. */
|
|
22
|
+
export function ListingControls({
|
|
23
|
+
sort,
|
|
24
|
+
category,
|
|
25
|
+
facets,
|
|
26
|
+
sortKeys,
|
|
27
|
+
defaultSort,
|
|
28
|
+
children,
|
|
29
|
+
}: ListingControlsProps) {
|
|
30
|
+
const client = useNymbalClient()
|
|
31
|
+
const [showMobileFilters, setShowMobileFilters] = useState(false)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
void client.productList.load({ limit: 24, category, sort })
|
|
35
|
+
}, [client, category, sort])
|
|
36
|
+
|
|
37
|
+
// The URL carries the sort, so a reload or a shared link renders the same order server-side.
|
|
38
|
+
const handleSort = useCallback(
|
|
39
|
+
(event: CustomEvent<{ sort: string }>) => {
|
|
40
|
+
const key = event.detail.sort as ProductSortKey
|
|
41
|
+
const url = new URL(window.location.href)
|
|
42
|
+
url.searchParams.set('sort', key)
|
|
43
|
+
window.history.replaceState(window.history.state, '', url)
|
|
44
|
+
void client.productList.setSort(key)
|
|
45
|
+
},
|
|
46
|
+
[client],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className={styles.layout}>
|
|
51
|
+
<aside className={styles.sidebar}>
|
|
52
|
+
{facets.length > 0 && (
|
|
53
|
+
<>
|
|
54
|
+
<button
|
|
55
|
+
className={styles.mobileFilterToggle}
|
|
56
|
+
onClick={() => setShowMobileFilters((v) => !v)}
|
|
57
|
+
type="button"
|
|
58
|
+
>
|
|
59
|
+
{showMobileFilters ? 'Hide Filters' : 'Show Filters'}
|
|
60
|
+
</button>
|
|
61
|
+
<div
|
|
62
|
+
className={styles.filterSection}
|
|
63
|
+
style={{ display: showMobileFilters ? 'block' : undefined }}
|
|
64
|
+
>
|
|
65
|
+
<ProductFilter facets={facets} />
|
|
66
|
+
</div>
|
|
67
|
+
</>
|
|
68
|
+
)}
|
|
69
|
+
</aside>
|
|
70
|
+
|
|
71
|
+
<div className={styles.content}>
|
|
72
|
+
{sortKeys.length > 0 && (
|
|
73
|
+
<div className={styles.sortBar}>
|
|
74
|
+
<SortDropdown
|
|
75
|
+
className={styles.sortSelect}
|
|
76
|
+
sortKeys={sortKeys}
|
|
77
|
+
value={sort}
|
|
78
|
+
defaultSort={defaultSort}
|
|
79
|
+
onSortChange={handleSort}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
{children}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -1,24 +1,66 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, type ReactNode } from 'react'
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
4
5
|
import { NymbalProvider } from '@nymbalhq/react'
|
|
5
|
-
import
|
|
6
|
-
import '@nymbalhq/web-components
|
|
6
|
+
import '@/elements'
|
|
7
|
+
import { registerClient } from '@nymbalhq/web-components'
|
|
8
|
+
import { NymbalCartDrawer } from '@nymbalhq/web-components/elements/nymbal-cart-drawer'
|
|
9
|
+
import { NymbalMiniCart } from '@nymbalhq/web-components/elements/nymbal-mini-cart'
|
|
10
|
+
import { NymbalSearchBar } from '@nymbalhq/web-components/elements/nymbal-search-bar'
|
|
11
|
+
import { NymbalSiteFooter } from '@nymbalhq/web-components/elements/nymbal-site-footer'
|
|
12
|
+
import { NymbalSiteHeader } from '@nymbalhq/web-components/elements/nymbal-site-header'
|
|
7
13
|
import { getNymbalClient, restoreSession } from '@/lib/client'
|
|
8
14
|
|
|
9
15
|
export function NymbalSetup({ children }: { children: ReactNode }) {
|
|
10
16
|
const client = getNymbalClient()
|
|
17
|
+
const router = useRouter()
|
|
11
18
|
const initialized = useRef(false)
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
// A layout effect runs before every passive effect of the commit, so the client is registered before any wrapper
|
|
21
|
+
// defines its element (each wrapper defines its own, so a route loads only the elements it renders). The cart starts
|
|
22
|
+
// loading first, so an element the server rendered from the cart keeps showing it until the load settles.
|
|
23
|
+
useLayoutEffect(() => {
|
|
14
24
|
if (!initialized.current) {
|
|
15
25
|
registerClient(client)
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
void client.cart.load()
|
|
27
|
+
NymbalSiteHeader.define()
|
|
28
|
+
NymbalSiteFooter.define()
|
|
29
|
+
NymbalSearchBar.define()
|
|
30
|
+
NymbalMiniCart.define()
|
|
31
|
+
NymbalCartDrawer.define()
|
|
18
32
|
void restoreSession()
|
|
19
33
|
initialized.current = true
|
|
20
34
|
}
|
|
21
35
|
}, [client])
|
|
22
36
|
|
|
37
|
+
// The elements' links are plain anchors in shadow roots, which the router does not see: each asks with
|
|
38
|
+
// `nymbal:navigate`, and the router takes it. The header's search bar and mini-cart are in its server HTML, not
|
|
39
|
+
// React children, so their events are taken here too.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const navigate = (event: Event): void => {
|
|
42
|
+
const href = (event as CustomEvent<{ href?: string }>).detail?.href
|
|
43
|
+
if (!href || new URL(href, window.location.href).origin !== window.location.origin) return
|
|
44
|
+
event.preventDefault()
|
|
45
|
+
router.push(href)
|
|
46
|
+
}
|
|
47
|
+
const searchSelected = (event: Event): void => {
|
|
48
|
+
const slug = (event as CustomEvent<{ product?: { slug?: string } }>).detail?.product?.slug
|
|
49
|
+
if (slug) router.push(`/product/${encodeURIComponent(slug)}`)
|
|
50
|
+
}
|
|
51
|
+
// Before the drawer's own listener toggles it, as the wrapper's listener on the mini-cart ran.
|
|
52
|
+
const miniCartClicked = (): void => {
|
|
53
|
+
window.dispatchEvent(new CustomEvent('nymbal:cart:open'))
|
|
54
|
+
}
|
|
55
|
+
document.addEventListener('nymbal:navigate', navigate)
|
|
56
|
+
document.addEventListener('nymbal:search:selected', searchSelected)
|
|
57
|
+
document.addEventListener('nymbal:mini-cart:clicked', miniCartClicked, true)
|
|
58
|
+
return () => {
|
|
59
|
+
document.removeEventListener('nymbal:navigate', navigate)
|
|
60
|
+
document.removeEventListener('nymbal:search:selected', searchSelected)
|
|
61
|
+
document.removeEventListener('nymbal:mini-cart:clicked', miniCartClicked, true)
|
|
62
|
+
}
|
|
63
|
+
}, [router])
|
|
64
|
+
|
|
23
65
|
return <NymbalProvider client={client}>{children}</NymbalProvider>
|
|
24
66
|
}
|
|
@@ -1,60 +1,96 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { useRef, useState } from 'react'
|
|
4
|
+
import type { DenormalisedProduct } from '@nymbalhq/sdk'
|
|
5
|
+
import {
|
|
6
|
+
useProduct,
|
|
7
|
+
VariantSelector,
|
|
8
|
+
AddToCart,
|
|
9
|
+
ProductPrice,
|
|
10
|
+
QuantitySelector,
|
|
11
|
+
StockBadge,
|
|
12
|
+
} from '@nymbalhq/react'
|
|
6
13
|
import styles from '@/styles/pages/pdp.module.css'
|
|
7
14
|
|
|
8
15
|
interface ProductInteractiveProps {
|
|
9
16
|
product: DenormalisedProduct
|
|
17
|
+
/** The server's render of each element, for the product's first variant (app/product/[slug]/page.tsx). */
|
|
18
|
+
serverHtml?: {
|
|
19
|
+
price: string
|
|
20
|
+
stock: string
|
|
21
|
+
variantSelector: string
|
|
22
|
+
quantity: string
|
|
23
|
+
addToCart: string
|
|
24
|
+
}
|
|
10
25
|
}
|
|
11
26
|
|
|
12
|
-
export function ProductInteractive({ product }: ProductInteractiveProps) {
|
|
27
|
+
export function ProductInteractive({ product, serverHtml }: ProductInteractiveProps) {
|
|
13
28
|
const { selectedVariant } = useProduct(product.slug)
|
|
14
29
|
|
|
15
30
|
const variant = selectedVariant ?? product.variants?.[0]
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
31
|
+
// A product with no variants has nothing to sell, so it reads as out of stock.
|
|
32
|
+
const stock = variant?.stock ?? 0
|
|
33
|
+
// The price and the badge are the server's first variant until the store settles on this product; they follow it
|
|
34
|
+
// themselves, so a soft navigation from another product never shows that product's price here.
|
|
35
|
+
const first = product.variants?.[0]
|
|
36
|
+
|
|
37
|
+
// The quantity belongs to the variant it was chosen for, so a variant change starts again at 1.
|
|
38
|
+
const [chosen, setChosen] = useState<{ variantId: string | undefined; qty: number }>({
|
|
39
|
+
variantId: variant?.id,
|
|
40
|
+
qty: 1,
|
|
41
|
+
})
|
|
42
|
+
// Reset on the change itself, so a quantity left on a variant is not there again when the shopper comes back to it.
|
|
43
|
+
if (chosen.variantId !== variant?.id) setChosen({ variantId: variant?.id, qty: 1 })
|
|
44
|
+
const qty = chosen.variantId === variant?.id ? chosen.qty : 1
|
|
45
|
+
const addToCartRef = useRef<HTMLElement>(null)
|
|
19
46
|
|
|
20
47
|
// The selector derives its option groups from the product's variants.
|
|
21
48
|
const hasVariantChoice = (product.variants?.length ?? 0) > 1
|
|
22
49
|
|
|
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
50
|
return (
|
|
31
51
|
<div>
|
|
32
52
|
<h1 className={styles.productTitle} data-testid="product-title">
|
|
33
53
|
{product.name}
|
|
34
54
|
</h1>
|
|
35
55
|
|
|
36
|
-
<
|
|
37
|
-
{
|
|
38
|
-
|
|
56
|
+
<ProductPrice
|
|
57
|
+
productId={product.id}
|
|
58
|
+
priceMinor={first?.priceMinor ?? product.priceMinor ?? 0}
|
|
59
|
+
currency={product.currency ?? 'GBP'}
|
|
60
|
+
serverHtml={serverHtml?.price}
|
|
61
|
+
/>
|
|
39
62
|
|
|
40
|
-
<
|
|
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>
|
|
63
|
+
<StockBadge productId={product.id} stock={first?.stock ?? 0} serverHtml={serverHtml?.stock} />
|
|
49
64
|
|
|
50
65
|
{hasVariantChoice && (
|
|
51
66
|
<div className={styles.variantSection}>
|
|
52
|
-
<VariantSelector
|
|
67
|
+
<VariantSelector product={product} serverHtml={serverHtml?.variantSelector} />
|
|
53
68
|
</div>
|
|
54
69
|
)}
|
|
55
70
|
|
|
56
71
|
<div className={styles.addToCartSection}>
|
|
57
|
-
<
|
|
72
|
+
<div className={styles.quantityWrapper}>
|
|
73
|
+
<QuantitySelector
|
|
74
|
+
key={variant?.id}
|
|
75
|
+
value={1}
|
|
76
|
+
min={1}
|
|
77
|
+
max={99}
|
|
78
|
+
serverHtml={serverHtml?.quantity}
|
|
79
|
+
onQuantityChange={(e) => {
|
|
80
|
+
// A typed quantity changes on blur, and the press on Add is what blurs it: the wrapper's effect lands
|
|
81
|
+
// after a fast click has already read `qty`, so the element gets it here, in the same event.
|
|
82
|
+
addToCartRef.current?.setAttribute('qty', String(e.detail.value))
|
|
83
|
+
setChosen({ variantId: variant?.id, qty: e.detail.value })
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<AddToCart
|
|
88
|
+
ref={addToCartRef}
|
|
89
|
+
variantId={variant?.id ?? product.id}
|
|
90
|
+
qty={qty}
|
|
91
|
+
disabled={stock <= 0}
|
|
92
|
+
serverHtml={serverHtml?.addToCart}
|
|
93
|
+
/>
|
|
58
94
|
</div>
|
|
59
95
|
</div>
|
|
60
96
|
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// The generated wrappers the server components render with their server HTML (`renderNymbal`): each defines its
|
|
4
|
+
// element and keeps its attributes in the browser, so it is a client module.
|
|
5
|
+
export {
|
|
6
|
+
Breadcrumb,
|
|
7
|
+
CategoryNav,
|
|
8
|
+
ImageGallery,
|
|
9
|
+
OrderSummary,
|
|
10
|
+
ProductGrid,
|
|
11
|
+
ReviewForm,
|
|
12
|
+
ReviewList,
|
|
13
|
+
SiteFooter,
|
|
14
|
+
SiteHeader,
|
|
15
|
+
} from '@nymbalhq/react'
|