create-nymbal-app 0.4.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 +7 -4
- 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 -26
- 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 +113 -21
- package/scaffold/templates/astro/src/lib/chrome.ts +59 -0
- package/scaffold/templates/astro/src/lib/client.ts +4 -4
- 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 +17 -21
- package/scaffold/templates/astro/src/pages/checkout.astro +30 -94
- 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 +44 -30
- package/scaffold/templates/astro/src/pages/products/index.astro +16 -21
- package/scaffold/templates/astro/src/pages/search.astro +10 -11
- package/scaffold/templates/astro/src/scripts/init-client.ts +14 -0
- package/scaffold/templates/astro/src/scripts/pdp.ts +24 -21
- package/scaffold/templates/astro/src/scripts/plp.ts +15 -93
- package/scaffold/templates/astro/src/scripts/search.ts +7 -132
- 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 -171
- package/scaffold/templates/astro/src/styles/pages/plp.css +3 -42
- package/scaffold/templates/astro/src/styles/pages/search.css +4 -110
- 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 +30 -15
- package/scaffold/templates/nextjs/components/client/elements.tsx +15 -0
- package/scaffold/templates/nextjs/components/server/Header.tsx +33 -102
- 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 +151 -55
- package/scaffold/templates/nextjs/lib/chrome.ts +40 -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 -27
- 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 -183
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +9 -143
- package/scaffold/templates/nextjs/styles/pages/search.module.css +1 -145
- 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 -198
- 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/StockBadge.astro +0 -15
- package/scaffold/templates/astro/src/scripts/auth-forms.ts +0 -82
- package/scaffold/templates/astro/src/scripts/cart-page.ts +0 -118
- package/scaffold/templates/astro/src/scripts/checkout.ts +0 -271
- 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 -215
- 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 -61
- package/scaffold/templates/nextjs/app/checkout/page.tsx +0 -17
- 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 -121
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +0 -482
- 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 -137
- 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 -181
- 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/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,24 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
import { useRef, useState } from 'react'
|
|
4
4
|
import type { DenormalisedProduct } from '@nymbalhq/sdk'
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import {
|
|
6
|
+
useProduct,
|
|
7
|
+
VariantSelector,
|
|
8
|
+
AddToCart,
|
|
9
|
+
ProductPrice,
|
|
10
|
+
QuantitySelector,
|
|
11
|
+
StockBadge,
|
|
12
|
+
} from '@nymbalhq/react'
|
|
8
13
|
import styles from '@/styles/pages/pdp.module.css'
|
|
9
14
|
|
|
10
15
|
interface ProductInteractiveProps {
|
|
11
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
|
+
}
|
|
12
25
|
}
|
|
13
26
|
|
|
14
|
-
export function ProductInteractive({ product }: ProductInteractiveProps) {
|
|
27
|
+
export function ProductInteractive({ product, serverHtml }: ProductInteractiveProps) {
|
|
15
28
|
const { selectedVariant } = useProduct(product.slug)
|
|
16
29
|
|
|
17
30
|
const variant = selectedVariant ?? product.variants?.[0]
|
|
18
|
-
const price = variant?.priceMinor ?? product.priceMinor ?? 0
|
|
19
|
-
const currency = product.currency ?? 'GBP'
|
|
20
31
|
// A product with no variants has nothing to sell, so it reads as out of stock.
|
|
21
32
|
const stock = variant?.stock ?? 0
|
|
22
|
-
|
|
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]
|
|
23
36
|
|
|
24
37
|
// The quantity belongs to the variant it was chosen for, so a variant change starts again at 1.
|
|
25
38
|
const [chosen, setChosen] = useState<{ variantId: string | undefined; qty: number }>({
|
|
@@ -40,18 +53,18 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
|
|
|
40
53
|
{product.name}
|
|
41
54
|
</h1>
|
|
42
55
|
|
|
43
|
-
<
|
|
44
|
-
{
|
|
45
|
-
|
|
56
|
+
<ProductPrice
|
|
57
|
+
productId={product.id}
|
|
58
|
+
priceMinor={first?.priceMinor ?? product.priceMinor ?? 0}
|
|
59
|
+
currency={product.currency ?? 'GBP'}
|
|
60
|
+
serverHtml={serverHtml?.price}
|
|
61
|
+
/>
|
|
46
62
|
|
|
47
|
-
<
|
|
48
|
-
<span className={styles.stockDot} aria-hidden="true" />
|
|
49
|
-
{STOCK_LABELS[level]}
|
|
50
|
-
</div>
|
|
63
|
+
<StockBadge productId={product.id} stock={first?.stock ?? 0} serverHtml={serverHtml?.stock} />
|
|
51
64
|
|
|
52
65
|
{hasVariantChoice && (
|
|
53
66
|
<div className={styles.variantSection}>
|
|
54
|
-
<VariantSelector />
|
|
67
|
+
<VariantSelector product={product} serverHtml={serverHtml?.variantSelector} />
|
|
55
68
|
</div>
|
|
56
69
|
)}
|
|
57
70
|
|
|
@@ -62,6 +75,7 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
|
|
|
62
75
|
value={1}
|
|
63
76
|
min={1}
|
|
64
77
|
max={99}
|
|
78
|
+
serverHtml={serverHtml?.quantity}
|
|
65
79
|
onQuantityChange={(e) => {
|
|
66
80
|
// A typed quantity changes on blur, and the press on Add is what blurs it: the wrapper's effect lands
|
|
67
81
|
// after a fast click has already read `qty`, so the element gets it here, in the same event.
|
|
@@ -75,6 +89,7 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
|
|
|
75
89
|
variantId={variant?.id ?? product.id}
|
|
76
90
|
qty={qty}
|
|
77
91
|
disabled={stock <= 0}
|
|
92
|
+
serverHtml={serverHtml?.addToCart}
|
|
78
93
|
/>
|
|
79
94
|
</div>
|
|
80
95
|
</div>
|
|
@@ -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'
|
|
@@ -1,107 +1,38 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
1
|
+
import { headers } from 'next/headers'
|
|
2
|
+
import { SiteHeader } from '@/components/client/elements'
|
|
3
|
+
import { fetchCart } from '@/lib/api'
|
|
4
|
+
import { HEADER } from '@/lib/chrome'
|
|
5
|
+
import { canSearch, siteHeader } from '@/lib/header'
|
|
6
|
+
import { renderNymbal } from '@/lib/render'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
{ href: '/products?category=new', label: 'New Arrivals' },
|
|
10
|
-
{ href: '/products?category=sale', label: 'Sale' },
|
|
11
|
-
]
|
|
12
|
-
|
|
13
|
-
export async function Header() {
|
|
14
|
-
const capabilities = await fetchCapabilities()
|
|
15
|
-
const canSearch = capabilities?.search.capabilities.includes('search') ?? false
|
|
8
|
+
function HeaderElement({ header }: { header: ReturnType<typeof siteHeader> }) {
|
|
9
|
+
const serverHtml = renderNymbal('nymbal-site-header', header.attributes, header.children).__html
|
|
16
10
|
return (
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
{NAV_LINKS.map((link) => (
|
|
26
|
-
<li key={link.href}>
|
|
27
|
-
<Link href={link.href} className={styles.navLink}>
|
|
28
|
-
{link.label}
|
|
29
|
-
</Link>
|
|
30
|
-
</li>
|
|
31
|
-
))}
|
|
32
|
-
</ul>
|
|
33
|
-
</nav>
|
|
34
|
-
|
|
35
|
-
<div className={styles.actions}>
|
|
36
|
-
{canSearch && (
|
|
37
|
-
<div className={styles.search}>
|
|
38
|
-
<SearchBarWrapper />
|
|
39
|
-
</div>
|
|
40
|
-
)}
|
|
41
|
-
<Link href="/account" className={styles.iconLink} aria-label="Account">
|
|
42
|
-
<svg
|
|
43
|
-
width="20"
|
|
44
|
-
height="20"
|
|
45
|
-
viewBox="0 0 24 24"
|
|
46
|
-
fill="none"
|
|
47
|
-
stroke="currentColor"
|
|
48
|
-
strokeWidth="2"
|
|
49
|
-
strokeLinecap="round"
|
|
50
|
-
strokeLinejoin="round"
|
|
51
|
-
aria-hidden="true"
|
|
52
|
-
>
|
|
53
|
-
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
|
54
|
-
<circle cx="12" cy="7" r="4" />
|
|
55
|
-
</svg>
|
|
56
|
-
</Link>
|
|
57
|
-
<MiniCartWrapper />
|
|
58
|
-
</div>
|
|
11
|
+
<SiteHeader
|
|
12
|
+
links={HEADER.links}
|
|
13
|
+
logo={HEADER.logo}
|
|
14
|
+
accountHref={HEADER.accountHref}
|
|
15
|
+
serverHtml={serverHtml}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
59
19
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
>
|
|
71
|
-
<svg
|
|
72
|
-
width="24"
|
|
73
|
-
height="24"
|
|
74
|
-
viewBox="0 0 24 24"
|
|
75
|
-
fill="none"
|
|
76
|
-
stroke="currentColor"
|
|
77
|
-
strokeWidth="2"
|
|
78
|
-
strokeLinecap="round"
|
|
79
|
-
strokeLinejoin="round"
|
|
80
|
-
aria-hidden="true"
|
|
81
|
-
>
|
|
82
|
-
<line x1="3" y1="6" x2="21" y2="6" />
|
|
83
|
-
<line x1="3" y1="12" x2="21" y2="12" />
|
|
84
|
-
<line x1="3" y1="18" x2="21" y2="18" />
|
|
85
|
-
</svg>
|
|
86
|
-
</label>
|
|
20
|
+
/**
|
|
21
|
+
* The header of a page. `shopper` reads the request's cart cookie, so the count is in the HTML with JavaScript off and
|
|
22
|
+
* the page renders per request; without it the page may be cached for every shopper, and the mini-cart fills its count
|
|
23
|
+
* in the browser.
|
|
24
|
+
*/
|
|
25
|
+
export async function Header({ shopper = false }: { shopper?: boolean }) {
|
|
26
|
+
const cookie = shopper ? (await headers()).get('cookie') : null
|
|
27
|
+
const [search, cart] = await Promise.all([canSearch(), shopper ? fetchCart(cookie) : null])
|
|
28
|
+
return <HeaderElement header={siteHeader(search, cart?.itemCount)} />
|
|
29
|
+
}
|
|
87
30
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
</li>
|
|
96
|
-
))}
|
|
97
|
-
<li>
|
|
98
|
-
<Link href="/account" className={styles.mobileNavLink}>
|
|
99
|
-
Account
|
|
100
|
-
</Link>
|
|
101
|
-
</li>
|
|
102
|
-
</ul>
|
|
103
|
-
</nav>
|
|
104
|
-
</div>
|
|
105
|
-
</header>
|
|
106
|
-
)
|
|
31
|
+
/**
|
|
32
|
+
* The header with no search bar and no count, for the root 404: Next renders that 404 into every page under the root
|
|
33
|
+
* layout and at build, so it can neither read the request (a cached page would fail) nor call the API (a build with
|
|
34
|
+
* the API down would fail).
|
|
35
|
+
*/
|
|
36
|
+
export function PlainHeader() {
|
|
37
|
+
return <HeaderElement header={siteHeader(false)} />
|
|
107
38
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
import styles from '@/styles/pages/error.module.css'
|
|
3
|
+
|
|
4
|
+
/** The 404's message and its ways back, under whichever header the 404 has. */
|
|
5
|
+
export function NotFoundView() {
|
|
6
|
+
return (
|
|
7
|
+
<div className={styles.page}>
|
|
8
|
+
<div className={styles.wrapper}>
|
|
9
|
+
<div className={`${styles.icon} ${styles.notFoundIcon}`}>
|
|
10
|
+
<svg
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
fill="none"
|
|
13
|
+
stroke="currentColor"
|
|
14
|
+
strokeWidth="2"
|
|
15
|
+
strokeLinecap="round"
|
|
16
|
+
strokeLinejoin="round"
|
|
17
|
+
aria-hidden="true"
|
|
18
|
+
>
|
|
19
|
+
<circle cx="11" cy="11" r="8" />
|
|
20
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
21
|
+
</svg>
|
|
22
|
+
</div>
|
|
23
|
+
<p className={styles.statusCode}>404</p>
|
|
24
|
+
<h1 className={styles.title}>Page Not Found</h1>
|
|
25
|
+
<p className={styles.message}>
|
|
26
|
+
The page you are looking for does not exist or may have been moved.
|
|
27
|
+
</p>
|
|
28
|
+
<div className={styles.actions}>
|
|
29
|
+
<Link href="/" className={styles.homeLink}>
|
|
30
|
+
Go Home
|
|
31
|
+
</Link>
|
|
32
|
+
<Link href="/products" className={styles.retryButton}>
|
|
33
|
+
Browse Products
|
|
34
|
+
</Link>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
// In a landmark of its own: the header's links are in its shadow root, where a checker cannot tell the skip link
|
|
2
|
+
// comes before them, so it would count it as content outside every landmark.
|
|
1
3
|
export function SkipNav() {
|
|
2
4
|
return (
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
<nav aria-label="Skip link">
|
|
6
|
+
<a href="#main" className="skip-nav">
|
|
7
|
+
Skip to content
|
|
8
|
+
</a>
|
|
9
|
+
</nav>
|
|
6
10
|
)
|
|
7
11
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// This store's own elements. Everything here runs before any Nymbal element is defined (components/client/
|
|
2
|
+
// NymbalSetup.tsx imports it first), and Nymbal leaves alone any tag that is already defined, so an element defined
|
|
3
|
+
// here under a Nymbal tag replaces Nymbal's. The server renders Nymbal's elements too, so a replacement is also named
|
|
4
|
+
// in `serverElements`, and the server renders it under the tag (lib/render.ts). The module is evaluated on the server
|
|
5
|
+
// as well, so define elements only in the browser, and import @nymbalhq/web-components/hydrate-support before lit,
|
|
6
|
+
// so the element takes over what the server rendered:
|
|
7
|
+
//
|
|
8
|
+
// import '@nymbalhq/web-components/hydrate-support'
|
|
9
|
+
// import { MyMiniCart } from './my-mini-cart'
|
|
10
|
+
// if (typeof window !== 'undefined') customElements.define('nymbal-mini-cart', MyMiniCart)
|
|
11
|
+
// export const serverElements = { 'nymbal-mini-cart': MyMiniCart }
|
|
12
|
+
export const serverElements = {}
|
|
@@ -1,10 +1,56 @@
|
|
|
1
|
+
import { cache } from 'react'
|
|
1
2
|
import { unstable_rethrow } from 'next/navigation'
|
|
2
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
DenormalisedProduct,
|
|
5
|
+
ProductListResult,
|
|
6
|
+
ProductSearchResult,
|
|
7
|
+
ReviewsListResult,
|
|
8
|
+
} from '@nymbalhq/sdk'
|
|
3
9
|
import { DEFAULT_PRODUCT_SORT, PRODUCT_SORT_KEYS } from '@nymbalhq/types'
|
|
4
|
-
import type { Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
|
|
10
|
+
import type { Cart, Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
|
|
5
11
|
|
|
6
12
|
const API_URL = process.env.NYMBAL_API_URL ?? 'http://localhost:3001'
|
|
7
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Seconds a catalog read stays in Next's data cache: each is at least the TTL of the longest route that reads it, since
|
|
16
|
+
* a route's TTL is the smallest of its segment's and its fetches'. NYMBAL_CATALOG_REVALIDATE_S replaces every one.
|
|
17
|
+
*/
|
|
18
|
+
const CATALOG_REVALIDATE_S = {
|
|
19
|
+
capabilities: 3600,
|
|
20
|
+
categories: 21600,
|
|
21
|
+
product: 300,
|
|
22
|
+
products: 300,
|
|
23
|
+
reviews: 60,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
function revalidateOf(read: keyof typeof CATALOG_REVALIDATE_S, seconds?: number): number {
|
|
27
|
+
const override = Number(process.env.NYMBAL_CATALOG_REVALIDATE_S)
|
|
28
|
+
if (Number.isInteger(override) && override > 0) return override
|
|
29
|
+
return seconds ?? CATALOG_REVALIDATE_S[read]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A catalog read through Next's data cache. It resolves a 200 or a 404 (an answer about the data, which the caller
|
|
34
|
+
* reads); a network error, a 5xx or any other refusal (a rate limit's 429) rejects, so a failed first render is an
|
|
35
|
+
* error Next does not cache and a failed regeneration keeps the last good page, where an empty value would be cached
|
|
36
|
+
* for every shopper. `next dev` caches no page but still keeps fetches, so there the read is fresh and a catalog edit
|
|
37
|
+
* shows at once.
|
|
38
|
+
*/
|
|
39
|
+
async function catalogFetch(
|
|
40
|
+
url: string,
|
|
41
|
+
read: keyof typeof CATALOG_REVALIDATE_S,
|
|
42
|
+
seconds?: number,
|
|
43
|
+
): Promise<Response> {
|
|
44
|
+
const res = await fetch(
|
|
45
|
+
url,
|
|
46
|
+
process.env.NODE_ENV === 'development'
|
|
47
|
+
? { cache: 'no-store' }
|
|
48
|
+
: { next: { revalidate: revalidateOf(read, seconds) } },
|
|
49
|
+
)
|
|
50
|
+
if (!res.ok && res.status !== 404) throw new Error(`[api] GET ${url} answered ${res.status}`)
|
|
51
|
+
return res
|
|
52
|
+
}
|
|
53
|
+
|
|
8
54
|
export async function fetchProducts(params?: {
|
|
9
55
|
limit?: number
|
|
10
56
|
cursor?: string
|
|
@@ -17,30 +63,18 @@ export async function fetchProducts(params?: {
|
|
|
17
63
|
if (params?.category) url.searchParams.set('category', params.category)
|
|
18
64
|
if (params?.sort) url.searchParams.set('sort', params.sort)
|
|
19
65
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return envelope.data ?? { items: [], nextCursor: null }
|
|
25
|
-
} catch (err) {
|
|
26
|
-
console.error('[api] fetchProducts failed', err)
|
|
27
|
-
return { items: [], nextCursor: null }
|
|
28
|
-
}
|
|
66
|
+
const res = await catalogFetch(url.toString(), 'products')
|
|
67
|
+
if (!res.ok) return { items: [], nextCursor: null }
|
|
68
|
+
const envelope = (await res.json()) as { data: ProductListResult }
|
|
69
|
+
return envelope.data ?? { items: [], nextCursor: null }
|
|
29
70
|
}
|
|
30
71
|
|
|
31
|
-
/** What the store can do, which decides what the page renders; null when the API
|
|
72
|
+
/** What the store can do, which decides what the page renders; null when the API has no capabilities to report. */
|
|
32
73
|
export async function fetchCapabilities(): Promise<StoreCapabilities | null> {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return envelope.data ?? null
|
|
38
|
-
} catch (err) {
|
|
39
|
-
// Next signals a request-time render by throwing from fetch at build; that must reach Next, not become null.
|
|
40
|
-
unstable_rethrow(err)
|
|
41
|
-
console.error('[api] fetchCapabilities failed', err)
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
74
|
+
const res = await catalogFetch(`${API_URL}/api/capabilities`, 'capabilities')
|
|
75
|
+
if (!res.ok) return null
|
|
76
|
+
const envelope = (await res.json()) as { data: StoreCapabilities }
|
|
77
|
+
return envelope.data ?? null
|
|
44
78
|
}
|
|
45
79
|
|
|
46
80
|
/** The sort a listing URL asks for, among the store's keys; a missing or unknown `?sort=` is the store's default. */
|
|
@@ -53,57 +87,119 @@ export function sortFromSearchParams(
|
|
|
53
87
|
return keys.find((key) => key === sort) ?? catalog?.defaultSort ?? DEFAULT_PRODUCT_SORT
|
|
54
88
|
}
|
|
55
89
|
|
|
56
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The product, or null when the API has none by that slug (a 404, the only answer that is null). `revalidateS` is the
|
|
92
|
+
* caller's route TTL when it is shorter than the default: callers share one data-cache entry, and Next judges its
|
|
93
|
+
* staleness by each caller's own seconds, so the PDP's 60 s regeneration reads stock no older than 60 s.
|
|
94
|
+
*/
|
|
95
|
+
export async function fetchProduct(
|
|
96
|
+
slug: string,
|
|
97
|
+
revalidateS?: number,
|
|
98
|
+
): Promise<DenormalisedProduct | null> {
|
|
99
|
+
const url = `${API_URL}/api/products/${encodeURIComponent(slug)}`
|
|
100
|
+
const res = await catalogFetch(url, 'product', revalidateS)
|
|
101
|
+
if (res.status === 404) return null
|
|
102
|
+
const envelope = (await res.json()) as { data: DenormalisedProduct }
|
|
103
|
+
return envelope.data ?? null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The store's categories as the API lists them; never a list of its own, which a cached page would then show. */
|
|
107
|
+
export async function fetchCategories(): Promise<Category[]> {
|
|
108
|
+
const url = `${API_URL}/api/categories`
|
|
109
|
+
const res = await catalogFetch(url, 'categories')
|
|
110
|
+
if (!res.ok) throw new Error(`[api] GET ${url} answered ${res.status}`)
|
|
111
|
+
const envelope = (await res.json()) as { data: Category[] }
|
|
112
|
+
return envelope.data
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function fetchOrder(orderNumber: string): Promise<Order | null> {
|
|
57
116
|
try {
|
|
58
|
-
const res = await fetch(`${API_URL}/api/
|
|
117
|
+
const res = await fetch(`${API_URL}/api/orders/${encodeURIComponent(orderNumber)}`, {
|
|
118
|
+
cache: 'no-store',
|
|
119
|
+
})
|
|
59
120
|
if (!res.ok) return null
|
|
60
|
-
const envelope = (await res.json()) as { data:
|
|
121
|
+
const envelope = (await res.json()) as { data: Order }
|
|
61
122
|
return envelope.data ?? null
|
|
62
123
|
} catch (err) {
|
|
63
|
-
|
|
124
|
+
unstable_rethrow(err)
|
|
125
|
+
console.error('[api] fetchOrder failed', err)
|
|
64
126
|
return null
|
|
65
127
|
}
|
|
66
128
|
}
|
|
67
129
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
]
|
|
130
|
+
export async function fetchReviews(productId: string): Promise<ReviewsListResult> {
|
|
131
|
+
const res = await catalogFetch(
|
|
132
|
+
`${API_URL}/api/products/${encodeURIComponent(productId)}/reviews`,
|
|
133
|
+
'reviews',
|
|
134
|
+
)
|
|
135
|
+
if (!res.ok) return { items: [], nextCursor: null }
|
|
136
|
+
const envelope = (await res.json()) as { data: ReviewsListResult }
|
|
137
|
+
return envelope.data ?? { items: [], nextCursor: null }
|
|
138
|
+
}
|
|
74
139
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
140
|
+
/** The cart the cart elements take as their `cart` attribute: its lines and sums, never the cart token. */
|
|
141
|
+
export interface CartProjection {
|
|
142
|
+
items: Cart['items']
|
|
143
|
+
subtotalMinor: number
|
|
144
|
+
currency: string
|
|
145
|
+
itemCount: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const CART_COOKIE = 'nymbal.cart'
|
|
149
|
+
|
|
150
|
+
/** The `nymbal.cart` value in a request's Cookie header, or null. */
|
|
151
|
+
function cartToken(cookie: string | null): string | null {
|
|
152
|
+
for (const pair of (cookie ?? '').split(';')) {
|
|
153
|
+
const [name, ...value] = pair.trim().split('=')
|
|
154
|
+
if (name === CART_COOKIE && value.length > 0) return value.join('=')
|
|
84
155
|
}
|
|
156
|
+
return null
|
|
85
157
|
}
|
|
86
158
|
|
|
87
|
-
|
|
159
|
+
/**
|
|
160
|
+
* The shopper's cart, read with the request's `nymbal.cart` cookie, as the projection the elements take; once per
|
|
161
|
+
* request, however many server components ask. Null without the cookie: `GET /api/cart` with none would make a cart
|
|
162
|
+
* the page could not keep, so the element renders its shell and the browser fills it. The cookie reaches this server
|
|
163
|
+
* when the store and the API share a host or a proxy.
|
|
164
|
+
*/
|
|
165
|
+
export const fetchCart = cache(async (cookie: string | null): Promise<CartProjection | null> => {
|
|
166
|
+
const token = cartToken(cookie)
|
|
167
|
+
if (token === null) return null
|
|
88
168
|
try {
|
|
89
|
-
const res = await fetch(`${API_URL}/api/
|
|
169
|
+
const res = await fetch(`${API_URL}/api/cart`, {
|
|
170
|
+
cache: 'no-store',
|
|
171
|
+
headers: { Cookie: `${CART_COOKIE}=${token}` },
|
|
172
|
+
})
|
|
90
173
|
if (!res.ok) return null
|
|
91
|
-
const
|
|
92
|
-
return
|
|
174
|
+
const { items, subtotalMinor, currency } = ((await res.json()) as { data: Cart }).data
|
|
175
|
+
return {
|
|
176
|
+
items,
|
|
177
|
+
subtotalMinor,
|
|
178
|
+
currency,
|
|
179
|
+
itemCount: items.reduce((n, item) => n + item.qty, 0),
|
|
180
|
+
}
|
|
93
181
|
} catch (err) {
|
|
94
|
-
|
|
182
|
+
unstable_rethrow(err)
|
|
183
|
+
console.error('[api] fetchCart failed', err)
|
|
95
184
|
return null
|
|
96
185
|
}
|
|
97
|
-
}
|
|
186
|
+
})
|
|
98
187
|
|
|
99
|
-
|
|
188
|
+
/** The search the search page shows for `q` (and its category), as the search store would hold it. */
|
|
189
|
+
export async function fetchSearch(
|
|
190
|
+
q: string,
|
|
191
|
+
category: string | null,
|
|
192
|
+
): Promise<ProductSearchResult | null> {
|
|
193
|
+
const url = new URL('/api/products/search', API_URL)
|
|
194
|
+
url.searchParams.set('q', q)
|
|
195
|
+
if (category) url.searchParams.set('category', category)
|
|
100
196
|
try {
|
|
101
|
-
const res = await fetch(
|
|
102
|
-
if (!res.ok) return
|
|
103
|
-
|
|
104
|
-
return envelope.data ?? { items: [], nextCursor: null }
|
|
197
|
+
const res = await fetch(url.toString(), { cache: 'no-store' })
|
|
198
|
+
if (!res.ok) return null
|
|
199
|
+
return ((await res.json()) as { data: ProductSearchResult }).data ?? null
|
|
105
200
|
} catch (err) {
|
|
106
|
-
|
|
107
|
-
|
|
201
|
+
unstable_rethrow(err)
|
|
202
|
+
console.error('[api] fetchSearch failed', err)
|
|
203
|
+
return null
|
|
108
204
|
}
|
|
109
205
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The store's header and footer, rendered by nymbal-site-header and nymbal-site-footer: edit the links here.
|
|
2
|
+
|
|
3
|
+
export const HEADER = {
|
|
4
|
+
logo: 'Nymbal',
|
|
5
|
+
accountHref: '/account',
|
|
6
|
+
links: [
|
|
7
|
+
{ href: '/products', label: 'Shop' },
|
|
8
|
+
{ href: '/products?category=new', label: 'New Arrivals' },
|
|
9
|
+
{ href: '/products?category=sale', label: 'Sale' },
|
|
10
|
+
],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const FOOTER_COLUMNS = [
|
|
14
|
+
{
|
|
15
|
+
heading: 'Shop',
|
|
16
|
+
links: [
|
|
17
|
+
{ href: '/products', label: 'All Products' },
|
|
18
|
+
{ href: '/products?category=new', label: 'New Arrivals' },
|
|
19
|
+
{ href: '/products?category=sale', label: 'Sale' },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
heading: 'Account',
|
|
24
|
+
links: [
|
|
25
|
+
{ href: '/account', label: 'My Account' },
|
|
26
|
+
{ href: '/account/orders', label: 'Order History' },
|
|
27
|
+
{ href: '/account/login', label: 'Sign In' },
|
|
28
|
+
{ href: '/account/register', label: 'Create Account' },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
heading: 'About',
|
|
33
|
+
links: [
|
|
34
|
+
{ href: '/about', label: 'About Us' },
|
|
35
|
+
{ href: '/contact', label: 'Contact' },
|
|
36
|
+
{ href: '/shipping', label: 'Shipping & Returns' },
|
|
37
|
+
{ href: '/privacy', label: 'Privacy Policy' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The fragment routes answer one element as the server renders it, the same as the pages carry, for a CDN to cache
|
|
2
|
+
// and an edge to assemble. Next sets each 200's Cache-Control from the route's TTL; a fragment reads no request, so it
|
|
3
|
+
// is the same for every shopper.
|
|
4
|
+
|
|
5
|
+
/** A fragment's HTML. It has no <meta charset>, so the charset is in the header. */
|
|
6
|
+
export function fragment(html: string): Response {
|
|
7
|
+
return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** No fragment by that name: Next may hold it for the route's TTL, and `no-store` keeps a CDN from storing it. */
|
|
11
|
+
export function noFragment(): Response {
|
|
12
|
+
return new Response('Not found', {
|
|
13
|
+
status: 404,
|
|
14
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' },
|
|
15
|
+
})
|
|
16
|
+
}
|