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
|
@@ -1,102 +1,38 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
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'
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
{ href: '/products?category=new', label: 'New Arrivals' },
|
|
9
|
-
{ href: '/products?category=sale', label: 'Sale' },
|
|
10
|
-
]
|
|
11
|
-
|
|
12
|
-
export function Header() {
|
|
8
|
+
function HeaderElement({ header }: { header: ReturnType<typeof siteHeader> }) {
|
|
9
|
+
const serverHtml = renderNymbal('nymbal-site-header', header.attributes, header.children).__html
|
|
13
10
|
return (
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{NAV_LINKS.map((link) => (
|
|
23
|
-
<li key={link.href}>
|
|
24
|
-
<Link href={link.href} className={styles.navLink}>
|
|
25
|
-
{link.label}
|
|
26
|
-
</Link>
|
|
27
|
-
</li>
|
|
28
|
-
))}
|
|
29
|
-
</ul>
|
|
30
|
-
</nav>
|
|
31
|
-
|
|
32
|
-
<div className={styles.actions}>
|
|
33
|
-
<div className={styles.search}>
|
|
34
|
-
<SearchBarWrapper />
|
|
35
|
-
</div>
|
|
36
|
-
<Link href="/account" className={styles.iconLink} aria-label="Account">
|
|
37
|
-
<svg
|
|
38
|
-
width="20"
|
|
39
|
-
height="20"
|
|
40
|
-
viewBox="0 0 24 24"
|
|
41
|
-
fill="none"
|
|
42
|
-
stroke="currentColor"
|
|
43
|
-
strokeWidth="2"
|
|
44
|
-
strokeLinecap="round"
|
|
45
|
-
strokeLinejoin="round"
|
|
46
|
-
aria-hidden="true"
|
|
47
|
-
>
|
|
48
|
-
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
|
49
|
-
<circle cx="12" cy="7" r="4" />
|
|
50
|
-
</svg>
|
|
51
|
-
</Link>
|
|
52
|
-
<MiniCartWrapper />
|
|
53
|
-
</div>
|
|
11
|
+
<SiteHeader
|
|
12
|
+
links={HEADER.links}
|
|
13
|
+
logo={HEADER.logo}
|
|
14
|
+
accountHref={HEADER.accountHref}
|
|
15
|
+
serverHtml={serverHtml}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
54
19
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
>
|
|
66
|
-
<svg
|
|
67
|
-
width="24"
|
|
68
|
-
height="24"
|
|
69
|
-
viewBox="0 0 24 24"
|
|
70
|
-
fill="none"
|
|
71
|
-
stroke="currentColor"
|
|
72
|
-
strokeWidth="2"
|
|
73
|
-
strokeLinecap="round"
|
|
74
|
-
strokeLinejoin="round"
|
|
75
|
-
aria-hidden="true"
|
|
76
|
-
>
|
|
77
|
-
<line x1="3" y1="6" x2="21" y2="6" />
|
|
78
|
-
<line x1="3" y1="12" x2="21" y2="12" />
|
|
79
|
-
<line x1="3" y1="18" x2="21" y2="18" />
|
|
80
|
-
</svg>
|
|
81
|
-
</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
|
+
}
|
|
82
30
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
</li>
|
|
91
|
-
))}
|
|
92
|
-
<li>
|
|
93
|
-
<Link href="/account" className={styles.mobileNavLink}>
|
|
94
|
-
Account
|
|
95
|
-
</Link>
|
|
96
|
-
</li>
|
|
97
|
-
</ul>
|
|
98
|
-
</nav>
|
|
99
|
-
</div>
|
|
100
|
-
</header>
|
|
101
|
-
)
|
|
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)} />
|
|
102
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,9 +1,56 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { cache } from 'react'
|
|
2
|
+
import { unstable_rethrow } from 'next/navigation'
|
|
3
|
+
import type {
|
|
4
|
+
DenormalisedProduct,
|
|
5
|
+
ProductListResult,
|
|
6
|
+
ProductSearchResult,
|
|
7
|
+
ReviewsListResult,
|
|
8
|
+
} from '@nymbalhq/sdk'
|
|
2
9
|
import { DEFAULT_PRODUCT_SORT, PRODUCT_SORT_KEYS } from '@nymbalhq/types'
|
|
3
|
-
import type { Category, Order, ProductSortKey } from '@nymbalhq/types'
|
|
10
|
+
import type { Cart, Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
|
|
4
11
|
|
|
5
12
|
const API_URL = process.env.NYMBAL_API_URL ?? 'http://localhost:3001'
|
|
6
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
|
+
|
|
7
54
|
export async function fetchProducts(params?: {
|
|
8
55
|
limit?: number
|
|
9
56
|
cursor?: string
|
|
@@ -16,74 +63,143 @@ export async function fetchProducts(params?: {
|
|
|
16
63
|
if (params?.category) url.searchParams.set('category', params.category)
|
|
17
64
|
if (params?.sort) url.searchParams.set('sort', params.sort)
|
|
18
65
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return envelope.data ?? { items: [], nextCursor: null }
|
|
24
|
-
} catch (err) {
|
|
25
|
-
console.error('[api] fetchProducts failed', err)
|
|
26
|
-
return { items: [], nextCursor: null }
|
|
27
|
-
}
|
|
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 }
|
|
28
70
|
}
|
|
29
71
|
|
|
30
|
-
/**
|
|
31
|
-
export function
|
|
72
|
+
/** What the store can do, which decides what the page renders; null when the API has no capabilities to report. */
|
|
73
|
+
export async function fetchCapabilities(): Promise<StoreCapabilities | null> {
|
|
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
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The sort a listing URL asks for, among the store's keys; a missing or unknown `?sort=` is the store's default. */
|
|
81
|
+
export function sortFromSearchParams(
|
|
82
|
+
params: { sort?: string | string[] },
|
|
83
|
+
catalog?: StoreCapabilities['catalog'],
|
|
84
|
+
): ProductSortKey {
|
|
32
85
|
const sort = Array.isArray(params.sort) ? params.sort[0] : params.sort
|
|
33
|
-
|
|
86
|
+
const keys = catalog?.sortKeys ?? PRODUCT_SORT_KEYS
|
|
87
|
+
return keys.find((key) => key === sort) ?? catalog?.defaultSort ?? DEFAULT_PRODUCT_SORT
|
|
34
88
|
}
|
|
35
89
|
|
|
36
|
-
|
|
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> {
|
|
37
116
|
try {
|
|
38
|
-
const res = await fetch(`${API_URL}/api/
|
|
117
|
+
const res = await fetch(`${API_URL}/api/orders/${encodeURIComponent(orderNumber)}`, {
|
|
118
|
+
cache: 'no-store',
|
|
119
|
+
})
|
|
39
120
|
if (!res.ok) return null
|
|
40
|
-
const envelope = (await res.json()) as { data:
|
|
121
|
+
const envelope = (await res.json()) as { data: Order }
|
|
41
122
|
return envelope.data ?? null
|
|
42
123
|
} catch (err) {
|
|
43
|
-
|
|
124
|
+
unstable_rethrow(err)
|
|
125
|
+
console.error('[api] fetchOrder failed', err)
|
|
44
126
|
return null
|
|
45
127
|
}
|
|
46
128
|
}
|
|
47
129
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
]
|
|
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
|
+
}
|
|
54
139
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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('=')
|
|
64
155
|
}
|
|
156
|
+
return null
|
|
65
157
|
}
|
|
66
158
|
|
|
67
|
-
|
|
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
|
|
68
168
|
try {
|
|
69
|
-
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
|
+
})
|
|
70
173
|
if (!res.ok) return null
|
|
71
|
-
const
|
|
72
|
-
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
|
+
}
|
|
73
181
|
} catch (err) {
|
|
74
|
-
|
|
182
|
+
unstable_rethrow(err)
|
|
183
|
+
console.error('[api] fetchCart failed', err)
|
|
75
184
|
return null
|
|
76
185
|
}
|
|
77
|
-
}
|
|
186
|
+
})
|
|
78
187
|
|
|
79
|
-
|
|
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)
|
|
80
196
|
try {
|
|
81
|
-
const res = await fetch(
|
|
82
|
-
if (!res.ok) return
|
|
83
|
-
|
|
84
|
-
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
|
|
85
200
|
} catch (err) {
|
|
86
|
-
|
|
87
|
-
|
|
201
|
+
unstable_rethrow(err)
|
|
202
|
+
console.error('[api] fetchSearch failed', err)
|
|
203
|
+
return null
|
|
88
204
|
}
|
|
89
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
|
+
]
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createNymbalClient, type NymbalClient } from '@nymbalhq/sdk'
|
|
2
2
|
|
|
3
|
+
// A payment driver of the project's own is registered here, at module load, before any checkout renders:
|
|
4
|
+
// registerPaymentDriver(name, driver) from @nymbalhq/sdk, named by the driver of the payments adapter's client half.
|
|
5
|
+
|
|
3
6
|
let instance: NymbalClient | null = null
|
|
4
7
|
let restoring: Promise<void> | null = null
|
|
5
8
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { fetchCapabilities } from '@/lib/api'
|
|
2
|
+
import { HEADER } from '@/lib/chrome'
|
|
3
|
+
import { renderElement } from '@/lib/render'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The store's nymbal-site-header as the server renders it: its attributes, and the search bar (when the store searches)
|
|
7
|
+
* and the mini-cart as its light-DOM children, each with the slot it fills. React cannot give children beside the
|
|
8
|
+
* header's server HTML, so they go in that HTML. The mini-cart has a count only on a shopper's own page; a shared,
|
|
9
|
+
* cached header has none.
|
|
10
|
+
*/
|
|
11
|
+
export function siteHeader(
|
|
12
|
+
search: boolean,
|
|
13
|
+
cartCount?: number,
|
|
14
|
+
): { attributes: Record<string, string>; children: string } {
|
|
15
|
+
const miniCart: Record<string, string> = { slot: 'cart' }
|
|
16
|
+
if (cartCount !== undefined) miniCart['count'] = String(cartCount)
|
|
17
|
+
const children = [
|
|
18
|
+
search
|
|
19
|
+
? renderElement('nymbal-search-bar', {
|
|
20
|
+
attributes: { slot: 'search', placeholder: 'Search products...', 'debounce-ms': '300' },
|
|
21
|
+
})
|
|
22
|
+
: '',
|
|
23
|
+
renderElement('nymbal-mini-cart', { attributes: miniCart }),
|
|
24
|
+
].join('')
|
|
25
|
+
return {
|
|
26
|
+
attributes: {
|
|
27
|
+
links: JSON.stringify(HEADER.links),
|
|
28
|
+
logo: HEADER.logo,
|
|
29
|
+
'account-href': HEADER.accountHref,
|
|
30
|
+
},
|
|
31
|
+
children,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether the store searches, which puts the search bar in the header. */
|
|
36
|
+
export async function canSearch(): Promise<boolean> {
|
|
37
|
+
const capabilities = await fetchCapabilities()
|
|
38
|
+
return capabilities?.search.capabilities.includes('search') ?? false
|
|
39
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { renderNymbal } from '@nymbalhq/react/server'
|
|
2
|
+
import {
|
|
3
|
+
cardImageOf,
|
|
4
|
+
cardProductOf,
|
|
5
|
+
registerServerElements,
|
|
6
|
+
renderElement,
|
|
7
|
+
} from '@nymbalhq/web-components/ssr'
|
|
8
|
+
import { serverElements } from '@/elements'
|
|
9
|
+
|
|
10
|
+
// Server components only: this loads Lit SSR. The store's own elements render on the server under their tags, as the
|
|
11
|
+
// browser defines them there.
|
|
12
|
+
registerServerElements(serverElements)
|
|
13
|
+
|
|
14
|
+
export { cardImageOf, cardProductOf, renderElement, renderNymbal }
|
|
@@ -1,9 +1,41 @@
|
|
|
1
|
+
import { relative } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
// NYMBAL_ELEMENTS_ENTRY (an absolute path) is the store's elements entry in place of elements/index.ts, on the server
|
|
5
|
+
// and in the browser (the E2E override fixture).
|
|
6
|
+
const elementsEntry = process.env.NYMBAL_ELEMENTS_ENTRY
|
|
7
|
+
const elementsAlias =
|
|
8
|
+
elementsEntry === undefined
|
|
9
|
+
? {}
|
|
10
|
+
: { '@/elements': `./${relative(fileURLToPath(new URL('.', import.meta.url)), elementsEntry)}` }
|
|
11
|
+
|
|
12
|
+
// NYMBAL_NEXT_DIST_DIR builds beside the usual `.next`, so a second build cannot overwrite one being served.
|
|
13
|
+
const distDir = process.env.NYMBAL_NEXT_DIST_DIR || '.next'
|
|
14
|
+
|
|
1
15
|
/** @type {import('next').NextConfig} */
|
|
2
16
|
const nextConfig = {
|
|
3
17
|
reactStrictMode: true,
|
|
18
|
+
distDir,
|
|
19
|
+
...(distDir !== '.next' && { typescript: { tsconfigPath: 'tsconfig.dist.json' } }),
|
|
20
|
+
// Cached pages live in memory, bounded by cacheMaxMemorySize: on disk, every distinct product URL a crawler asks for
|
|
21
|
+
// would keep an entry.
|
|
22
|
+
experimental: { isrFlushToDisk: false },
|
|
23
|
+
// `/` and the header and nav fragments are dynamic segments with no params, so the build prerenders none of them
|
|
24
|
+
// (the API may be down then) and Next caches each on its first request.
|
|
25
|
+
async rewrites() {
|
|
26
|
+
return {
|
|
27
|
+
beforeFiles: [
|
|
28
|
+
{ source: '/', destination: '/home/index' },
|
|
29
|
+
{ source: '/fragments/header', destination: '/fragments/header/index' },
|
|
30
|
+
{ source: '/fragments/nav', destination: '/fragments/nav/index' },
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
},
|
|
4
34
|
transpilePackages: ['@nymbalhq/sdk', '@nymbalhq/react', '@nymbalhq/web-components'],
|
|
5
35
|
images: {
|
|
6
36
|
remotePatterns: [{ protocol: 'https', hostname: '**' }],
|
|
7
37
|
},
|
|
38
|
+
turbopack: { resolveAlias: elementsAlias },
|
|
8
39
|
}
|
|
40
|
+
|
|
9
41
|
export default nextConfig
|