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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { notFound } from 'next/navigation'
|
|
3
|
+
import { preload } from 'react-dom'
|
|
4
|
+
import { fetchProduct, fetchProducts, fetchReviews } from '@/lib/api'
|
|
5
|
+
import { cardProductOf, renderNymbal } from '@/lib/render'
|
|
6
|
+
import { productJsonLd } from '@/lib/seo'
|
|
7
|
+
import {
|
|
8
|
+
Breadcrumb,
|
|
9
|
+
ImageGallery,
|
|
10
|
+
ProductGrid,
|
|
11
|
+
ReviewForm,
|
|
12
|
+
ReviewList,
|
|
13
|
+
} from '@/components/client/elements'
|
|
14
|
+
import { ProductInteractive } from '@/components/client/ProductInteractive'
|
|
15
|
+
import styles from '@/styles/pages/pdp.module.css'
|
|
16
|
+
|
|
17
|
+
// Next caches each product page on its first request and regenerates it in the background every 60 s; the build
|
|
18
|
+
// prerenders none, since the API may be down then.
|
|
19
|
+
export const revalidate = 60
|
|
20
|
+
|
|
21
|
+
export function generateStaticParams(): Array<{ slug: string }> {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ProductPageProps {
|
|
26
|
+
params: Promise<{ slug: string }>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
|
30
|
+
const { slug } = await params
|
|
31
|
+
const product = await fetchProduct(slug, revalidate)
|
|
32
|
+
|
|
33
|
+
if (!product) {
|
|
34
|
+
return { title: 'Product Not Found' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
title: product.name,
|
|
39
|
+
description: product.shortDescription ?? product.description ?? `Buy ${product.name}.`,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default async function ProductPage({ params }: ProductPageProps) {
|
|
44
|
+
const { slug } = await params
|
|
45
|
+
const product = await fetchProduct(slug, revalidate)
|
|
46
|
+
|
|
47
|
+
if (!product) notFound()
|
|
48
|
+
|
|
49
|
+
const [{ items: reviews }, { items: relatedProducts }] = await Promise.all([
|
|
50
|
+
fetchReviews(product.id),
|
|
51
|
+
fetchProducts({ limit: 4 }),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const related = relatedProducts
|
|
55
|
+
.filter((p) => p.id !== product.id)
|
|
56
|
+
.slice(0, 4)
|
|
57
|
+
.map(cardProductOf)
|
|
58
|
+
|
|
59
|
+
// The product's elements as the server renders them for its first variant, which ProductInteractive shows first.
|
|
60
|
+
const variant = product.variants?.[0]
|
|
61
|
+
const serverHtml = {
|
|
62
|
+
price: renderNymbal('nymbal-product-price', {
|
|
63
|
+
'product-id': product.id,
|
|
64
|
+
'price-minor': String(variant?.priceMinor ?? product.priceMinor ?? 0),
|
|
65
|
+
currency: product.currency ?? 'GBP',
|
|
66
|
+
}).__html,
|
|
67
|
+
stock: renderNymbal('nymbal-stock-badge', {
|
|
68
|
+
'product-id': product.id,
|
|
69
|
+
stock: String(variant?.stock ?? 0),
|
|
70
|
+
}).__html,
|
|
71
|
+
variantSelector: renderNymbal('nymbal-variant-selector', { product: JSON.stringify(product) })
|
|
72
|
+
.__html,
|
|
73
|
+
quantity: renderNymbal('nymbal-quantity-selector', { value: '1', min: '1', max: '99' }).__html,
|
|
74
|
+
addToCart: renderNymbal('nymbal-add-to-cart', {
|
|
75
|
+
'variant-id': variant?.id ?? product.id,
|
|
76
|
+
qty: '1',
|
|
77
|
+
...((variant?.stock ?? 0) <= 0 && { disabled: '' }),
|
|
78
|
+
}).__html,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
|
82
|
+
const jsonLd = productJsonLd(product, baseUrl)
|
|
83
|
+
|
|
84
|
+
const mediaArray = Array.isArray(product.media) ? product.media : []
|
|
85
|
+
const images =
|
|
86
|
+
mediaArray.length > 0
|
|
87
|
+
? mediaArray.map((m: Record<string, string>) => ({
|
|
88
|
+
url: m.url,
|
|
89
|
+
alt: m.alt ?? product.name,
|
|
90
|
+
}))
|
|
91
|
+
: [{ url: `https://picsum.photos/seed/${product.slug}/600/600`, alt: product.name }]
|
|
92
|
+
const crumbs = [
|
|
93
|
+
{ label: 'Home', href: '/' },
|
|
94
|
+
{ label: 'Products', href: '/products' },
|
|
95
|
+
{ label: product.name },
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
// The gallery's main image is the page's LCP: a preload in the head starts it before the browser parses the body.
|
|
99
|
+
// React's preload(), not a <link> element: a cached render writes an element and its preload both.
|
|
100
|
+
preload(images[0]!.url, { as: 'image', fetchPriority: 'high' })
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
<script
|
|
105
|
+
type="application/ld+json"
|
|
106
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
<div className={`container ${styles.page}`}>
|
|
110
|
+
<Breadcrumb
|
|
111
|
+
items={crumbs}
|
|
112
|
+
serverHtml={renderNymbal('nymbal-breadcrumb', { items: JSON.stringify(crumbs) }).__html}
|
|
113
|
+
/>
|
|
114
|
+
|
|
115
|
+
<div className={styles.productLayout}>
|
|
116
|
+
<ImageGallery
|
|
117
|
+
className={styles.gallery}
|
|
118
|
+
images={images}
|
|
119
|
+
serverHtml={
|
|
120
|
+
renderNymbal('nymbal-image-gallery', { images: JSON.stringify(images) }).__html
|
|
121
|
+
}
|
|
122
|
+
/>
|
|
123
|
+
|
|
124
|
+
<div className={styles.info}>
|
|
125
|
+
<ProductInteractive product={product} serverHtml={serverHtml} />
|
|
126
|
+
|
|
127
|
+
{product.description && (
|
|
128
|
+
<div className={styles.description}>
|
|
129
|
+
<p>{product.description}</p>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
<div className={styles.detailsSection}>
|
|
134
|
+
<details className={styles.detailsItem}>
|
|
135
|
+
<summary className={styles.detailsSummary}>Details</summary>
|
|
136
|
+
<div className={styles.detailsContent}>
|
|
137
|
+
{product.description ?? 'No additional details available.'}
|
|
138
|
+
</div>
|
|
139
|
+
</details>
|
|
140
|
+
<details className={styles.detailsItem}>
|
|
141
|
+
<summary className={styles.detailsSummary}>Shipping & Returns</summary>
|
|
142
|
+
<div className={styles.detailsContent}>
|
|
143
|
+
Free standard shipping on orders over $50. Returns accepted within 30 days of
|
|
144
|
+
purchase.
|
|
145
|
+
</div>
|
|
146
|
+
</details>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<section className={styles.reviewsSection}>
|
|
152
|
+
<h2 className={styles.reviewsTitle}>Customer Reviews</h2>
|
|
153
|
+
<ReviewList
|
|
154
|
+
reviews={reviews}
|
|
155
|
+
serverHtml={
|
|
156
|
+
renderNymbal('nymbal-review-list', { reviews: JSON.stringify(reviews) }).__html
|
|
157
|
+
}
|
|
158
|
+
/>
|
|
159
|
+
<ReviewForm
|
|
160
|
+
className={styles.reviewForm}
|
|
161
|
+
productId={product.id}
|
|
162
|
+
serverHtml={renderNymbal('nymbal-review-form', { 'product-id': product.id }).__html}
|
|
163
|
+
/>
|
|
164
|
+
</section>
|
|
165
|
+
|
|
166
|
+
{related.length > 0 && (
|
|
167
|
+
<section className="section">
|
|
168
|
+
<h2 className="section-title">You may also like</h2>
|
|
169
|
+
<ProductGrid
|
|
170
|
+
products={related}
|
|
171
|
+
lazy
|
|
172
|
+
serverHtml={
|
|
173
|
+
renderNymbal('nymbal-product-grid', { products: JSON.stringify(related), lazy: '' })
|
|
174
|
+
.__html
|
|
175
|
+
}
|
|
176
|
+
/>
|
|
177
|
+
</section>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { fetchProducts, fetchCapabilities, sortFromSearchParams } from '@/lib/api'
|
|
3
|
+
import { cardImageOf, cardProductOf, renderNymbal } from '@/lib/render'
|
|
4
|
+
import { Breadcrumb, ProductGrid } from '@/components/client/elements'
|
|
5
|
+
import { ListingControls } from '@/components/client/ListingControls'
|
|
6
|
+
import styles from '@/styles/pages/plp.module.css'
|
|
7
|
+
|
|
8
|
+
export const metadata: Metadata = {
|
|
9
|
+
title: 'All Products',
|
|
10
|
+
description: 'Browse our full collection of products.',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The page reads `?sort=`, so it renders per request; said here, the build does not render it with the API down. Its
|
|
14
|
+
// catalog reads still go through Next's data cache.
|
|
15
|
+
export const dynamic = 'force-dynamic'
|
|
16
|
+
|
|
17
|
+
interface ProductsPageProps {
|
|
18
|
+
searchParams: Promise<{ sort?: string | string[] }>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CRUMBS = [{ label: 'Home', href: '/' }, { label: 'Products' }]
|
|
22
|
+
|
|
23
|
+
export default async function ProductsPage({ searchParams }: ProductsPageProps) {
|
|
24
|
+
const capabilities = await fetchCapabilities()
|
|
25
|
+
const sort = sortFromSearchParams(await searchParams, capabilities?.catalog)
|
|
26
|
+
const { items, nextCursor, facets } = await fetchProducts({ limit: 24, sort })
|
|
27
|
+
const cards = items.map(cardProductOf)
|
|
28
|
+
// The first card's image is the page's LCP, and the preload scanner never reaches it inside the grid's shadow root:
|
|
29
|
+
// a preload link, which React hoists into the head, starts it.
|
|
30
|
+
const firstImage = cards[0] === undefined ? null : cardImageOf(cards[0]).url
|
|
31
|
+
const grid = {
|
|
32
|
+
products: JSON.stringify(cards),
|
|
33
|
+
source: 'product-list',
|
|
34
|
+
...(nextCursor !== null && { 'has-more': '' }),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={`container ${styles.page}`}>
|
|
39
|
+
{firstImage !== null && (
|
|
40
|
+
<link rel="preload" as="image" href={firstImage} fetchPriority="high" />
|
|
41
|
+
)}
|
|
42
|
+
<Breadcrumb
|
|
43
|
+
items={CRUMBS}
|
|
44
|
+
serverHtml={renderNymbal('nymbal-breadcrumb', { items: JSON.stringify(CRUMBS) }).__html}
|
|
45
|
+
/>
|
|
46
|
+
<h1 className={styles.pageTitle}>All Products</h1>
|
|
47
|
+
<ListingControls
|
|
48
|
+
sort={sort}
|
|
49
|
+
facets={facets ?? []}
|
|
50
|
+
sortKeys={capabilities?.catalog.sortKeys ?? []}
|
|
51
|
+
defaultSort={capabilities?.catalog.defaultSort}
|
|
52
|
+
>
|
|
53
|
+
<ProductGrid
|
|
54
|
+
products={cards}
|
|
55
|
+
source="product-list"
|
|
56
|
+
hasMore={nextCursor !== null}
|
|
57
|
+
serverHtml={renderNymbal('nymbal-product-grid', grid).__html}
|
|
58
|
+
/>
|
|
59
|
+
</ListingControls>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect } from 'react'
|
|
4
|
+
import { useSearchParams } from 'next/navigation'
|
|
5
|
+
import Link from 'next/link'
|
|
6
|
+
import type { DenormalisedProduct } from '@nymbalhq/sdk'
|
|
7
|
+
import { ProductFilter, SearchResults, useSearch } from '@nymbalhq/react'
|
|
8
|
+
import styles from '@/styles/pages/search.module.css'
|
|
9
|
+
|
|
10
|
+
const POPULAR = ['Shirts', 'Shoes', 'Jackets', 'Accessories', 'New Arrivals']
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The URL owns the term and the category; `SearchResults` renders the store's answer, and until the store has one, the
|
|
14
|
+
* results the server fetched for the URL (`given`, rendered in `serverHtml`).
|
|
15
|
+
*/
|
|
16
|
+
export function SearchView({
|
|
17
|
+
given,
|
|
18
|
+
serverHtml,
|
|
19
|
+
}: {
|
|
20
|
+
given: { results: DenormalisedProduct[]; query: string } | null
|
|
21
|
+
serverHtml: string
|
|
22
|
+
}) {
|
|
23
|
+
const searchParams = useSearchParams()
|
|
24
|
+
const query = searchParams.get('q') ?? ''
|
|
25
|
+
const category = searchParams.get('category')
|
|
26
|
+
const {
|
|
27
|
+
results,
|
|
28
|
+
facets,
|
|
29
|
+
query: searched,
|
|
30
|
+
category: searchedCategory,
|
|
31
|
+
search,
|
|
32
|
+
clearSearch,
|
|
33
|
+
} = useSearch()
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
// Runs when the URL changes; the filter has already searched the category it writes there. The store outlives
|
|
37
|
+
// the page on a client-side navigation, so a URL with no term clears what an earlier visit searched.
|
|
38
|
+
if (!query) {
|
|
39
|
+
if (searched) clearSearch()
|
|
40
|
+
} else if (query !== searched || category !== searchedCategory) {
|
|
41
|
+
void search(query, { category })
|
|
42
|
+
}
|
|
43
|
+
}, [query, category, search, clearSearch])
|
|
44
|
+
|
|
45
|
+
const onFilterChange = useCallback((event: CustomEvent<{ name: string; value: string }>) => {
|
|
46
|
+
const url = new URL(window.location.href)
|
|
47
|
+
if (event.detail.value) url.searchParams.set('category', event.detail.value)
|
|
48
|
+
else url.searchParams.delete('category')
|
|
49
|
+
window.history.replaceState(null, '', url.toString())
|
|
50
|
+
}, [])
|
|
51
|
+
|
|
52
|
+
const filtered = facets.length > 0 && results.length > 0
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={`container ${styles.page}`}>
|
|
56
|
+
<div className={styles.header}>
|
|
57
|
+
<h1 className={styles.pageTitle}>Search Results</h1>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className={filtered ? styles.layout : undefined}>
|
|
61
|
+
{filtered && (
|
|
62
|
+
<aside className={styles.sidebar}>
|
|
63
|
+
<ProductFilter source="search" onFilterChange={onFilterChange} />
|
|
64
|
+
</aside>
|
|
65
|
+
)}
|
|
66
|
+
<SearchResults
|
|
67
|
+
data-testid="search-results"
|
|
68
|
+
results={given?.results}
|
|
69
|
+
query={given?.query}
|
|
70
|
+
serverHtml={serverHtml}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div className={styles.suggestions}>
|
|
75
|
+
<h3 className={styles.suggestionsTitle}>Popular searches</h3>
|
|
76
|
+
<div className={styles.suggestionTags}>
|
|
77
|
+
{POPULAR.map((term) => (
|
|
78
|
+
<Link
|
|
79
|
+
key={term}
|
|
80
|
+
href={`/search?q=${encodeURIComponent(term)}`}
|
|
81
|
+
className={styles.suggestionTag}
|
|
82
|
+
>
|
|
83
|
+
{term}
|
|
84
|
+
</Link>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { Suspense } from 'react'
|
|
3
|
+
import { fetchSearch } from '@/lib/api'
|
|
4
|
+
import { renderNymbal } from '@/lib/render'
|
|
5
|
+
import { SearchView } from './SearchView'
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: 'Search',
|
|
9
|
+
description: 'Search our product catalog.',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The page reads `?q=`, so it renders per request; said here, the build does not render it with the API down. The
|
|
13
|
+
// header's catalog reads still go through Next's data cache.
|
|
14
|
+
export const dynamic = 'force-dynamic'
|
|
15
|
+
|
|
16
|
+
const first = (value: string | string[] | undefined): string | null =>
|
|
17
|
+
(Array.isArray(value) ? value[0] : value) ?? null
|
|
18
|
+
|
|
19
|
+
export default async function SearchPage({
|
|
20
|
+
searchParams,
|
|
21
|
+
}: {
|
|
22
|
+
searchParams: Promise<Record<string, string | string[] | undefined>>
|
|
23
|
+
}) {
|
|
24
|
+
const params = await searchParams
|
|
25
|
+
const q = first(params['q'])?.trim() ?? ''
|
|
26
|
+
const search = q === '' ? null : await fetchSearch(q, first(params['category']))
|
|
27
|
+
// The results the page's search would show, so they are in the HTML; the search store takes over once it answers.
|
|
28
|
+
const given = search === null ? null : { results: search.items, query: q }
|
|
29
|
+
const serverHtml = renderNymbal(
|
|
30
|
+
'nymbal-search-results',
|
|
31
|
+
given === null ? {} : { results: JSON.stringify(given.results), query: given.query },
|
|
32
|
+
).__html
|
|
33
|
+
return (
|
|
34
|
+
<Suspense
|
|
35
|
+
fallback={
|
|
36
|
+
<div className="loading-center">
|
|
37
|
+
<div className="spinner spinner-lg" role="status">
|
|
38
|
+
<span className="visually-hidden">Loading...</span>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
}
|
|
42
|
+
>
|
|
43
|
+
<SearchView given={given} serverHtml={serverHtml} />
|
|
44
|
+
</Suspense>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { renderNymbal } from '@/lib/render'
|
|
3
|
+
import AddressBook from './AddressesView'
|
|
4
|
+
import styles from '@/styles/pages/account.module.css'
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: 'Addresses',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function AddressesPage() {
|
|
11
|
+
return (
|
|
12
|
+
<div>
|
|
13
|
+
<h2 className={styles.pageTitle}>Addresses</h2>
|
|
14
|
+
<AddressBook serverHtml={renderNymbal('nymbal-address-book').__html} />
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import Link from 'next/link'
|
|
6
|
+
import { LoginForm } from '@nymbalhq/react'
|
|
7
|
+
import styles from '@/styles/pages/account.module.css'
|
|
8
|
+
|
|
9
|
+
/** `serverHtml` is the form as the server rendered it, `redirect` where to go once signed in (page.tsx). */
|
|
10
|
+
export function LoginView({ serverHtml, redirect }: { serverHtml: string; redirect?: string }) {
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
const onSignedIn = useCallback(() => {
|
|
13
|
+
router.push(redirect ?? '/account')
|
|
14
|
+
}, [router, redirect])
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className={styles.authPage}>
|
|
18
|
+
<div className={styles.authCard}>
|
|
19
|
+
<h1 className={styles.authTitle}>Sign In</h1>
|
|
20
|
+
<p className={styles.authSubtitle}>Welcome back. Enter your credentials to continue.</p>
|
|
21
|
+
<LoginForm onSignedIn={onSignedIn} serverHtml={serverHtml} />
|
|
22
|
+
<p className={styles.authFooter}>
|
|
23
|
+
Don't have an account? <Link href="/account/register">Create one</Link>
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { renderNymbal } from '@/lib/render'
|
|
3
|
+
import { LoginView } from './LoginView'
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'Sign In',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface LoginPageProps {
|
|
10
|
+
searchParams: Promise<{ redirect?: string | string[] }>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The page reads `redirect` itself rather than the view reading the URL: a view that reads it needs a Suspense
|
|
14
|
+
// boundary, which React may stream after the shell, and without JavaScript a streamed boundary is never shown.
|
|
15
|
+
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|
16
|
+
const { redirect } = await searchParams
|
|
17
|
+
return (
|
|
18
|
+
<LoginView
|
|
19
|
+
redirect={typeof redirect === 'string' ? redirect : undefined}
|
|
20
|
+
serverHtml={renderNymbal('nymbal-login-form').__html}
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { renderNymbal } from '@/lib/render'
|
|
3
|
+
import ProfileForm from './ProfileView'
|
|
4
|
+
import styles from '@/styles/pages/account.module.css'
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: 'Profile',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function ProfilePage() {
|
|
11
|
+
return (
|
|
12
|
+
<div>
|
|
13
|
+
<h2 className={styles.pageTitle}>Profile</h2>
|
|
14
|
+
<div className={styles.formCard}>
|
|
15
|
+
<h3 className={styles.formCardTitle}>Personal Information</h3>
|
|
16
|
+
<ProfileForm serverHtml={renderNymbal('nymbal-profile-form').__html} />
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import Link from 'next/link'
|
|
6
|
+
import { RegisterForm } from '@nymbalhq/react'
|
|
7
|
+
import styles from '@/styles/pages/account.module.css'
|
|
8
|
+
|
|
9
|
+
/** `serverHtml` is the form as the server rendered it (page.tsx). */
|
|
10
|
+
export function RegisterView({ serverHtml }: { serverHtml: string }) {
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
const onSignedIn = useCallback(() => router.push('/account'), [router])
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className={styles.authPage}>
|
|
16
|
+
<div className={styles.authCard}>
|
|
17
|
+
<h1 className={styles.authTitle}>Create Account</h1>
|
|
18
|
+
<p className={styles.authSubtitle}>Join us and start shopping.</p>
|
|
19
|
+
<RegisterForm onSignedIn={onSignedIn} serverHtml={serverHtml} />
|
|
20
|
+
<p className={styles.authFooter}>
|
|
21
|
+
Already have an account? <Link href="/account/login">Sign in</Link>
|
|
22
|
+
</p>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { renderNymbal } from '@/lib/render'
|
|
3
|
+
import { RegisterView } from './RegisterView'
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'Create Account',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function RegisterPage() {
|
|
10
|
+
return <RegisterView serverHtml={renderNymbal('nymbal-register-form').__html} />
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { headers } from 'next/headers'
|
|
3
|
+
import { fetchCart } from '@/lib/api'
|
|
4
|
+
import { renderNymbal } from '@/lib/render'
|
|
5
|
+
import CartPage from './CartView'
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: 'Shopping Cart',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default async function CartRoute() {
|
|
12
|
+
const cart = await fetchCart((await headers()).get('cookie'))
|
|
13
|
+
const serverHtml = renderNymbal(
|
|
14
|
+
'nymbal-cart-page',
|
|
15
|
+
cart === null ? {} : { cart: JSON.stringify(cart) },
|
|
16
|
+
).__html
|
|
17
|
+
return (
|
|
18
|
+
<div className="container section" data-testid="cart-page">
|
|
19
|
+
<CartPage cart={cart ?? undefined} serverHtml={serverHtml} />
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { Checkout } from '@nymbalhq/react'
|
|
6
|
+
import { NymbalPaymentElement } from '@nymbalhq/web-components/elements/nymbal-payment-element'
|
|
7
|
+
import type { CartProjection } from '@/lib/api'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The element places the order; the page only goes to it. The server rendered the checkout with its payment element
|
|
11
|
+
* as a light-DOM child (`serverHtml`), which is defined here: it stays mounted by its driver in the browser.
|
|
12
|
+
*/
|
|
13
|
+
export function CheckoutView({ cart, serverHtml }: { cart: CartProjection | null; serverHtml: string }) {
|
|
14
|
+
const router = useRouter()
|
|
15
|
+
const onPlaced = useCallback(
|
|
16
|
+
(event: CustomEvent<{ orderNumber: string }>) => {
|
|
17
|
+
router.push(`/order/${encodeURIComponent(event.detail.orderNumber)}`)
|
|
18
|
+
},
|
|
19
|
+
[router],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
NymbalPaymentElement.define()
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
return <Checkout cart={cart ?? undefined} serverHtml={serverHtml} onPlaced={onPlaced} />
|
|
27
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { headers } from 'next/headers'
|
|
3
|
+
import { fetchCapabilities, fetchCart } from '@/lib/api'
|
|
4
|
+
import { renderNymbal } from '@/lib/render'
|
|
5
|
+
import { CheckoutView } from './CheckoutView'
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: 'Checkout',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const escapeAttribute = (value: string): string =>
|
|
12
|
+
value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
|
13
|
+
|
|
14
|
+
export default async function CheckoutRoute() {
|
|
15
|
+
const [capabilities, cart] = await Promise.all([
|
|
16
|
+
fetchCapabilities(),
|
|
17
|
+
headers().then((h) => fetchCart(h.get('cookie'))),
|
|
18
|
+
])
|
|
19
|
+
if (capabilities === null) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
'The store did not answer GET /api/capabilities, so checkout cannot tell how to take a payment.',
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
// The payments adapter's client half, as the checkout's light-DOM child in its payment slot; a driver the project
|
|
25
|
+
// registers in lib/client.ts renders it.
|
|
26
|
+
const payment = `<nymbal-payment-element slot="payment" client="${escapeAttribute(
|
|
27
|
+
JSON.stringify(capabilities.payments.client),
|
|
28
|
+
)}" data-testid="payment-element"></nymbal-payment-element>`
|
|
29
|
+
const serverHtml = renderNymbal(
|
|
30
|
+
'nymbal-checkout',
|
|
31
|
+
cart === null ? {} : { cart: JSON.stringify(cart) },
|
|
32
|
+
payment,
|
|
33
|
+
).__html
|
|
34
|
+
return (
|
|
35
|
+
<div className="container section">
|
|
36
|
+
<CheckoutView cart={cart} serverHtml={serverHtml} />
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Header } from '@/components/server/Header'
|
|
3
|
+
|
|
4
|
+
// The shopper's own pages render per request: the header reads the cart cookie, so its count is in the HTML.
|
|
5
|
+
export default function ShopperLayout({ children }: { children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<>
|
|
8
|
+
<Header shopper />
|
|
9
|
+
<main id="main">{children}</main>
|
|
10
|
+
</>
|
|
11
|
+
)
|
|
12
|
+
}
|