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
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|