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,6 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
DenormalisedProduct,
|
|
3
|
+
ProductListResult,
|
|
4
|
+
ProductSearchResult,
|
|
5
|
+
ReviewsListResult,
|
|
6
|
+
} from '@nymbalhq/sdk'
|
|
2
7
|
import { DEFAULT_PRODUCT_SORT, PRODUCT_SORT_KEYS } from '@nymbalhq/types'
|
|
3
|
-
import type { Category, Order, ProductSortKey } from '@nymbalhq/types'
|
|
8
|
+
import type { Cart, Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
|
|
4
9
|
|
|
5
10
|
const API_URL = import.meta.env.NYMBAL_API_URL ?? 'http://localhost:3001'
|
|
6
11
|
|
|
@@ -27,40 +32,78 @@ export async function fetchProducts(params?: {
|
|
|
27
32
|
}
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
/** The
|
|
31
|
-
export
|
|
35
|
+
/** The API failed: it did not answer, answered an error other than a 404, or answered with no data. */
|
|
36
|
+
export class ApiUnavailableError extends Error {
|
|
37
|
+
override name = 'ApiUnavailableError'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `GET <path>`'s data; null on a 404; rejects with ApiUnavailableError (or fetch's own error) on any other failure. */
|
|
41
|
+
async function apiData<T>(path: string): Promise<T | null> {
|
|
42
|
+
const res = await fetch(`${API_URL}${path}`)
|
|
43
|
+
if (res.status === 404) return null
|
|
44
|
+
if (!res.ok) throw new ApiUnavailableError(`GET ${path} answered ${res.status}`)
|
|
45
|
+
const envelope = (await res.json()) as { data?: T | null }
|
|
46
|
+
if (envelope.data === undefined || envelope.data === null) {
|
|
47
|
+
throw new ApiUnavailableError(`GET ${path} answered no data`)
|
|
48
|
+
}
|
|
49
|
+
return envelope.data
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What the store can do, as the API sent it; rejects when the API fails. */
|
|
53
|
+
export async function loadCapabilities(): Promise<StoreCapabilities> {
|
|
54
|
+
const capabilities = await apiData<StoreCapabilities>('/api/capabilities')
|
|
55
|
+
if (capabilities === null) throw new ApiUnavailableError('GET /api/capabilities answered 404')
|
|
56
|
+
return capabilities
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What the store can do, which decides what the page renders; null when the API did not answer. */
|
|
60
|
+
export async function fetchCapabilities(): Promise<StoreCapabilities | null> {
|
|
61
|
+
try {
|
|
62
|
+
return await loadCapabilities()
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('[api] fetchCapabilities failed', err)
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The sort a listing URL asks for, among the store's keys; a missing or unknown `?sort=` is the store's default. */
|
|
70
|
+
export function sortFromUrl(url: URL, catalog?: StoreCapabilities['catalog']): ProductSortKey {
|
|
32
71
|
const sort = url.searchParams.get('sort')
|
|
33
|
-
|
|
72
|
+
const keys = catalog?.sortKeys ?? PRODUCT_SORT_KEYS
|
|
73
|
+
return keys.find((key) => key === sort) ?? catalog?.defaultSort ?? DEFAULT_PRODUCT_SORT
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A product as the API sent it; null when the API has none (a 404); rejects when the API fails. */
|
|
77
|
+
export async function loadProduct(slug: string): Promise<DenormalisedProduct | null> {
|
|
78
|
+
return apiData<DenormalisedProduct>(`/api/products/${encodeURIComponent(slug)}`)
|
|
34
79
|
}
|
|
35
80
|
|
|
36
81
|
export async function fetchProduct(slug: string): Promise<DenormalisedProduct | null> {
|
|
37
82
|
try {
|
|
38
|
-
|
|
39
|
-
if (!res.ok) return null
|
|
40
|
-
const envelope = (await res.json()) as { data: DenormalisedProduct }
|
|
41
|
-
return envelope.data ?? null
|
|
83
|
+
return await loadProduct(slug)
|
|
42
84
|
} catch (err) {
|
|
43
85
|
console.error('[api] fetchProduct failed', err)
|
|
44
86
|
return null
|
|
45
87
|
}
|
|
46
88
|
}
|
|
47
89
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
90
|
+
/** The store's categories as the API sent them; rejects when the API fails. */
|
|
91
|
+
export async function loadCategories(): Promise<Category[]> {
|
|
92
|
+
const categories = await apiData<Category[]>('/api/categories')
|
|
93
|
+
if (categories === null) throw new ApiUnavailableError('GET /api/categories answered 404')
|
|
94
|
+
return categories
|
|
95
|
+
}
|
|
54
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The store's categories, or none when the API failed: a page renders per request and no shared cache keeps it, so
|
|
99
|
+
* the next request shows the store's own; a list the API did not send would link to categories the store lacks.
|
|
100
|
+
*/
|
|
55
101
|
export async function fetchCategories(): Promise<Category[]> {
|
|
56
102
|
try {
|
|
57
|
-
|
|
58
|
-
if (!res.ok) return SEED_CATEGORIES
|
|
59
|
-
const envelope = (await res.json()) as { data: Category[] }
|
|
60
|
-
return envelope.data ?? SEED_CATEGORIES
|
|
103
|
+
return await loadCategories()
|
|
61
104
|
} catch (err) {
|
|
62
105
|
console.error('[api] fetchCategories failed', err)
|
|
63
|
-
return
|
|
106
|
+
return []
|
|
64
107
|
}
|
|
65
108
|
}
|
|
66
109
|
|
|
@@ -87,3 +130,66 @@ export async function fetchReviews(productId: string): Promise<ReviewsListResult
|
|
|
87
130
|
return { items: [], nextCursor: null }
|
|
88
131
|
}
|
|
89
132
|
}
|
|
133
|
+
|
|
134
|
+
/** The cart the cart elements take as their `cart` attribute: its lines and sums, never the cart token. */
|
|
135
|
+
export interface CartProjection {
|
|
136
|
+
items: Cart['items']
|
|
137
|
+
subtotalMinor: number
|
|
138
|
+
currency: string
|
|
139
|
+
itemCount: number
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const CART_COOKIE = 'nymbal.cart'
|
|
143
|
+
|
|
144
|
+
/** The `nymbal.cart` value in a request's Cookie header, or null. */
|
|
145
|
+
function cartToken(cookie: string | null): string | null {
|
|
146
|
+
for (const pair of (cookie ?? '').split(';')) {
|
|
147
|
+
const [name, ...value] = pair.trim().split('=')
|
|
148
|
+
if (name === CART_COOKIE && value.length > 0) return value.join('=')
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The shopper's cart, read with the request's `nymbal.cart` cookie, as the projection the elements take. Null without
|
|
155
|
+
* the cookie: `GET /api/cart` with none would make a cart the page could not keep, so the element renders its shell
|
|
156
|
+
* and the browser fills it. The cookie reaches this server when the store and the API share a host or a proxy.
|
|
157
|
+
*/
|
|
158
|
+
export async function fetchCart(cookie: string | null): Promise<CartProjection | null> {
|
|
159
|
+
const token = cartToken(cookie)
|
|
160
|
+
if (token === null) return null
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(`${API_URL}/api/cart`, {
|
|
163
|
+
headers: { Cookie: `${CART_COOKIE}=${token}` },
|
|
164
|
+
})
|
|
165
|
+
if (!res.ok) return null
|
|
166
|
+
const { items, subtotalMinor, currency } = ((await res.json()) as { data: Cart }).data
|
|
167
|
+
return {
|
|
168
|
+
items,
|
|
169
|
+
subtotalMinor,
|
|
170
|
+
currency,
|
|
171
|
+
itemCount: items.reduce((n, item) => n + item.qty, 0),
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error('[api] fetchCart failed', err)
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The search the search page shows for `q` (and its category), as the search store would hold it. */
|
|
180
|
+
export async function fetchSearch(
|
|
181
|
+
q: string,
|
|
182
|
+
category: string | null,
|
|
183
|
+
): Promise<ProductSearchResult | null> {
|
|
184
|
+
const url = new URL('/api/products/search', API_URL)
|
|
185
|
+
url.searchParams.set('q', q)
|
|
186
|
+
if (category) url.searchParams.set('category', category)
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(url.toString())
|
|
189
|
+
if (!res.ok) return null
|
|
190
|
+
return ((await res.json()) as { data: ProductSearchResult }).data ?? null
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error('[api] fetchSearch failed', err)
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { renderElement } from '@nymbalhq/web-components/ssr'
|
|
2
|
+
import './server-elements'
|
|
3
|
+
|
|
4
|
+
// The store's header and footer, rendered by nymbal-site-header and nymbal-site-footer: edit the links here.
|
|
5
|
+
|
|
6
|
+
export const HEADER = {
|
|
7
|
+
logo: 'Nymbal',
|
|
8
|
+
'account-href': '/account',
|
|
9
|
+
links: JSON.stringify([
|
|
10
|
+
{ label: 'Apparel', href: '/category/apparel' },
|
|
11
|
+
{ label: 'Home', href: '/category/home' },
|
|
12
|
+
{ label: 'Electronics', href: '/category/electronics' },
|
|
13
|
+
{ label: 'Books', href: '/category/books' },
|
|
14
|
+
{ label: 'All Products', href: '/products' },
|
|
15
|
+
]),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const FOOTER = {
|
|
19
|
+
columns: JSON.stringify([
|
|
20
|
+
{
|
|
21
|
+
heading: 'Shop',
|
|
22
|
+
links: [
|
|
23
|
+
{ label: 'All Products', href: '/products' },
|
|
24
|
+
{ label: 'Apparel', href: '/category/apparel' },
|
|
25
|
+
{ label: 'Home', href: '/category/home' },
|
|
26
|
+
{ label: 'Electronics', href: '/category/electronics' },
|
|
27
|
+
{ label: 'Books', href: '/category/books' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
heading: 'Account',
|
|
32
|
+
links: [
|
|
33
|
+
{ label: 'Login', href: '/account/login' },
|
|
34
|
+
{ label: 'Register', href: '/account/register' },
|
|
35
|
+
{ label: 'Orders', href: '/account/orders' },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
heading: 'About',
|
|
40
|
+
text: 'Nymbal is a modern headless commerce platform built for developers. Fast, flexible, and fully customisable.',
|
|
41
|
+
},
|
|
42
|
+
]),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The header's light-DOM children, each rendered by the server with the slot it fills: the search bar (when the store
|
|
47
|
+
* searches) and the mini-cart with the shopper's count (none in a shared, cached header). HTML, not markup: Astro
|
|
48
|
+
* would take `slot` on a component's child as a slot of NymbalElement's own.
|
|
49
|
+
*/
|
|
50
|
+
export function headerSlots(canSearch: boolean, cartCount?: number): string {
|
|
51
|
+
const search = canSearch
|
|
52
|
+
? renderElement('nymbal-search-bar', {
|
|
53
|
+
attributes: { slot: 'search', placeholder: 'Search products...', 'debounce-ms': '300' },
|
|
54
|
+
})
|
|
55
|
+
: ''
|
|
56
|
+
const attributes: Record<string, string> = { slot: 'cart' }
|
|
57
|
+
if (cartCount !== undefined) attributes['count'] = String(cartCount)
|
|
58
|
+
return search + renderElement('nymbal-mini-cart', { attributes })
|
|
59
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import '../elements/index.js'
|
|
1
2
|
import { createNymbalClient } from '@nymbalhq/sdk'
|
|
2
|
-
import { registerClient
|
|
3
|
+
import { registerClient } from '@nymbalhq/web-components'
|
|
3
4
|
|
|
4
5
|
const client = createNymbalClient({
|
|
5
6
|
baseUrl: import.meta.env.PUBLIC_NYMBAL_API_URL ?? 'http://localhost:3001',
|
|
@@ -7,7 +8,8 @@ const client = createNymbalClient({
|
|
|
7
8
|
})
|
|
8
9
|
|
|
9
10
|
registerClient(client)
|
|
10
|
-
|
|
11
|
+
// A payment driver of the project's own goes here, before any element is defined: registerPaymentDriver(name, driver)
|
|
12
|
+
// from @nymbalhq/sdk, named by the driver of the payments adapter's client half.
|
|
11
13
|
client.cart.load()
|
|
12
14
|
|
|
13
15
|
export { client }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DenormalisedProduct } from '@nymbalhq/sdk'
|
|
2
|
+
import type { Category } from '@nymbalhq/types'
|
|
3
|
+
import { loadCapabilities, loadCategories, loadProduct } from './api'
|
|
4
|
+
|
|
5
|
+
// A fragment is cached by a CDN for hours, so it carries only what the API sent: when the API fails, the fragment
|
|
6
|
+
// answers an error no cache keeps, never a fallback (no categories, no search bar) a CDN would hold for its TTL.
|
|
7
|
+
|
|
8
|
+
const NOT_STORED = { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }
|
|
9
|
+
|
|
10
|
+
function apiFailed(fragment: string, err: unknown): Response {
|
|
11
|
+
console.error(`[fragments] ${fragment}: the API failed`, err)
|
|
12
|
+
return new Response('The store API is unavailable', { status: 502, headers: NOT_STORED })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The header fragment's data: whether the store searches; a 502 `no-store` response when the API failed. */
|
|
16
|
+
export async function headerFragment(): Promise<{ canSearch: boolean } | Response> {
|
|
17
|
+
try {
|
|
18
|
+
const capabilities = await loadCapabilities()
|
|
19
|
+
return { canSearch: capabilities.search.capabilities.includes('search') }
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return apiFailed('header', err)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The nav fragment's data: the API's categories; a 502 `no-store` response when the API failed. */
|
|
26
|
+
export async function navFragment(): Promise<{ categories: Category[] } | Response> {
|
|
27
|
+
try {
|
|
28
|
+
return { categories: await loadCategories() }
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return apiFailed('nav', err)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The product fragment's data: the API's product; a 404 `no-store` response for a product the API does not have (a CDN
|
|
36
|
+
* would apply its default 404 TTL to a 404 with no Cache-Control), a 502 `no-store` one when the API failed.
|
|
37
|
+
*/
|
|
38
|
+
export async function productFragment(
|
|
39
|
+
slug: string,
|
|
40
|
+
): Promise<{ product: DenormalisedProduct } | Response> {
|
|
41
|
+
try {
|
|
42
|
+
const product = await loadProduct(slug)
|
|
43
|
+
if (product === null) return new Response('Not found', { status: 404, headers: NOT_STORED })
|
|
44
|
+
return { product }
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return apiFailed('product', err)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { registerServerElements } from '@nymbalhq/web-components/ssr'
|
|
2
|
+
import { serverElements } from '../elements/index.js'
|
|
3
|
+
|
|
4
|
+
// The store's own elements render on the server under their tags, as the browser defines them there.
|
|
5
|
+
registerServerElements(serverElements)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineMiddleware } from 'astro:middleware'
|
|
2
|
+
|
|
3
|
+
/** Whether an `Accept-Encoding` value accepts gzip: a `gzip` entry (or, without one, `*`) with a q-value above 0. */
|
|
4
|
+
function acceptsGzip(header: string): boolean {
|
|
5
|
+
let star = false
|
|
6
|
+
for (const entry of header.split(',')) {
|
|
7
|
+
const [coding = '', ...params] = entry.split(';').map((part) => part.trim().toLowerCase())
|
|
8
|
+
const q = params.find((param) => param.startsWith('q='))
|
|
9
|
+
const accepted = q === undefined || Number(q.slice(2)) > 0
|
|
10
|
+
if (coding === 'gzip' || coding === 'x-gzip') return accepted
|
|
11
|
+
if (coding === '*') star = accepted
|
|
12
|
+
}
|
|
13
|
+
return star
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The standalone Node adapter compresses nothing, so a page's HTML (its styles inlined) went over the wire whole.
|
|
17
|
+
// An HTML response is gzipped as a stream when the client accepts it; everything else passes through unchanged, and a
|
|
18
|
+
// proxy that compresses sees the encoding and passes it on. Every HTML response varies on Accept-Encoding, the
|
|
19
|
+
// uncompressed one included, so a cache keeps both forms apart.
|
|
20
|
+
export const onRequest = defineMiddleware(async (context, next) => {
|
|
21
|
+
const response = await next()
|
|
22
|
+
const type = response.headers.get('content-type') ?? ''
|
|
23
|
+
if (!type.startsWith('text/html') || response.headers.has('content-encoding')) return response
|
|
24
|
+
const headers = new Headers(response.headers)
|
|
25
|
+
headers.append('vary', 'Accept-Encoding')
|
|
26
|
+
let body = response.body
|
|
27
|
+
if (body !== null && acceptsGzip(context.request.headers.get('accept-encoding') ?? '')) {
|
|
28
|
+
headers.set('content-encoding', 'gzip')
|
|
29
|
+
headers.delete('content-length')
|
|
30
|
+
body = body.pipeThrough(new CompressionStream('gzip'))
|
|
31
|
+
}
|
|
32
|
+
return new Response(body, {
|
|
33
|
+
status: response.status,
|
|
34
|
+
statusText: response.statusText,
|
|
35
|
+
headers,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -1,110 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
import '../../styles/pages/account.css'
|
|
3
|
-
import '
|
|
3
|
+
import NymbalElement from '@nymbalhq/web-components/astro'
|
|
4
4
|
import AccountLayout from '../../layouts/AccountLayout.astro'
|
|
5
|
-
import AddressForm from '../../components/AddressForm.astro'
|
|
6
5
|
---
|
|
7
6
|
|
|
8
7
|
<AccountLayout title="Addresses">
|
|
9
8
|
<h1 class="account-page-title">Addresses</h1>
|
|
10
9
|
|
|
11
|
-
<
|
|
12
|
-
<p style="color: var(--nymbal-color-text-muted, #71717a); font-size: var(--nymbal-font-size-sm, 0.875rem);">
|
|
13
|
-
Loading addresses...
|
|
14
|
-
</p>
|
|
15
|
-
</div>
|
|
16
|
-
|
|
17
|
-
<div style="margin-top: var(--nymbal-spacing-xl, 2rem);">
|
|
18
|
-
<h2 class="section-title">Add New Address</h2>
|
|
19
|
-
<form id="add-address-form" class="account-form" data-testid="add-address-form">
|
|
20
|
-
<AddressForm id="new-address" />
|
|
21
|
-
<div class="account-form-actions">
|
|
22
|
-
<button type="submit" class="btn btn-primary" data-testid="save-address">Save Address</button>
|
|
23
|
-
</div>
|
|
24
|
-
</form>
|
|
25
|
-
<p class="form-message" id="address-form-message" style="display:none; margin-top: var(--nymbal-spacing-md, 1rem); font-size: var(--nymbal-font-size-sm, 0.875rem);"></p>
|
|
26
|
-
</div>
|
|
10
|
+
<NymbalElement tag="nymbal-address-book" />
|
|
27
11
|
</AccountLayout>
|
|
28
12
|
|
|
29
13
|
<script>
|
|
30
|
-
import
|
|
31
|
-
import {
|
|
32
|
-
import { escapeHtml } from '../../lib/format.js'
|
|
33
|
-
import { submitOnce } from '../../lib/submit-once.js'
|
|
34
|
-
|
|
35
|
-
const container = document.getElementById('addresses-container')
|
|
36
|
-
const form = document.getElementById('add-address-form') as HTMLFormElement | null
|
|
37
|
-
const messageEl = document.getElementById('address-form-message')
|
|
38
|
-
|
|
39
|
-
function renderAddress(addr: CustomerAddress): string {
|
|
40
|
-
const isDefault = addr.isDefaultShipping || addr.isDefaultBilling
|
|
41
|
-
return `
|
|
42
|
-
<div class="address-card ${isDefault ? 'default' : ''}" data-testid="address-${escapeHtml(addr.id)}">
|
|
43
|
-
${isDefault ? '<span class="address-card-badge">Default</span>' : ''}
|
|
44
|
-
<div class="address-card-line">${escapeHtml(`${addr.firstName} ${addr.lastName}`)}</div>
|
|
45
|
-
<div class="address-card-line">${escapeHtml(addr.addressLine1)}</div>
|
|
46
|
-
${addr.addressLine2 ? `<div class="address-card-line">${escapeHtml(addr.addressLine2)}</div>` : ''}
|
|
47
|
-
<div class="address-card-line">${escapeHtml(`${addr.city}, ${addr.region} ${addr.postalCode}`)}</div>
|
|
48
|
-
<div class="address-card-line">${escapeHtml(addr.country)}</div>
|
|
49
|
-
</div>
|
|
50
|
-
`
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function loadAddresses() {
|
|
54
|
-
if (!container) return
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
const addresses = await client.customer.addresses.list()
|
|
58
|
-
|
|
59
|
-
if (addresses.length === 0) {
|
|
60
|
-
container.innerHTML = `
|
|
61
|
-
<div class="account-empty">
|
|
62
|
-
<h2 class="account-empty-title">No saved addresses</h2>
|
|
63
|
-
<p class="account-empty-text">Add an address below to get started.</p>
|
|
64
|
-
</div>
|
|
65
|
-
`
|
|
66
|
-
return
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
container.innerHTML = `<div class="address-list">${addresses.map(renderAddress).join('')}</div>`
|
|
70
|
-
} catch {
|
|
71
|
-
container.innerHTML = `
|
|
72
|
-
<div class="account-empty">
|
|
73
|
-
<p class="account-empty-text">Unable to load addresses.</p>
|
|
74
|
-
</div>
|
|
75
|
-
`
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
submitOnce(form, async () => {
|
|
80
|
-
if (!form || !messageEl) return
|
|
81
|
-
|
|
82
|
-
const formData = new FormData(form)
|
|
83
|
-
const field = (name: string) => String(formData.get(name) ?? '').trim()
|
|
84
|
-
const address: Address = {
|
|
85
|
-
firstName: field('firstName'),
|
|
86
|
-
lastName: field('lastName'),
|
|
87
|
-
addressLine1: field('addressLine1'),
|
|
88
|
-
city: field('city'),
|
|
89
|
-
region: field('region'),
|
|
90
|
-
postalCode: field('postalCode'),
|
|
91
|
-
country: field('country'),
|
|
92
|
-
...(field('addressLine2') && { addressLine2: field('addressLine2') }),
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
await client.customer.addresses.create(address)
|
|
97
|
-
messageEl.textContent = 'Address saved successfully.'
|
|
98
|
-
messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
|
|
99
|
-
messageEl.style.display = 'block'
|
|
100
|
-
form.reset()
|
|
101
|
-
await loadAddresses()
|
|
102
|
-
} catch {
|
|
103
|
-
messageEl.textContent = 'Failed to save address. Please try again.'
|
|
104
|
-
messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
|
|
105
|
-
messageEl.style.display = 'block'
|
|
106
|
-
}
|
|
107
|
-
})
|
|
14
|
+
import '../../lib/client.js'
|
|
15
|
+
import { NymbalAddressBook } from '@nymbalhq/web-components/elements/nymbal-address-book'
|
|
108
16
|
|
|
109
|
-
|
|
17
|
+
NymbalAddressBook.define()
|
|
110
18
|
</script>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import '../../styles/pages/account.css'
|
|
3
|
+
import NymbalElement from '@nymbalhq/web-components/astro'
|
|
3
4
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
4
5
|
---
|
|
5
6
|
|
|
@@ -9,39 +10,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
|
9
10
|
<h1 class="auth-card-title">Sign In</h1>
|
|
10
11
|
<p class="auth-card-subtitle">Welcome back. Enter your credentials to continue.</p>
|
|
11
12
|
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
<form id="login-form" data-testid="login-form">
|
|
15
|
-
<div class="form-group">
|
|
16
|
-
<label for="login-email">Email</label>
|
|
17
|
-
<input
|
|
18
|
-
type="email"
|
|
19
|
-
id="login-email"
|
|
20
|
-
name="email"
|
|
21
|
-
required
|
|
22
|
-
autocomplete="email"
|
|
23
|
-
placeholder="you@example.com"
|
|
24
|
-
data-testid="login-email"
|
|
25
|
-
/>
|
|
26
|
-
</div>
|
|
27
|
-
|
|
28
|
-
<div class="form-group">
|
|
29
|
-
<label for="login-password">Password</label>
|
|
30
|
-
<input
|
|
31
|
-
type="password"
|
|
32
|
-
id="login-password"
|
|
33
|
-
name="password"
|
|
34
|
-
required
|
|
35
|
-
autocomplete="current-password"
|
|
36
|
-
placeholder="Your password"
|
|
37
|
-
data-testid="login-password"
|
|
38
|
-
/>
|
|
39
|
-
</div>
|
|
40
|
-
|
|
41
|
-
<button type="submit" class="btn btn-primary" data-testid="login-submit">
|
|
42
|
-
Sign In
|
|
43
|
-
</button>
|
|
44
|
-
</form>
|
|
13
|
+
<NymbalElement tag="nymbal-login-form" />
|
|
45
14
|
|
|
46
15
|
<p class="auth-footer">
|
|
47
16
|
Don't have an account? <a href="/account/register">Create one</a>
|
|
@@ -51,5 +20,9 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
|
51
20
|
</BaseLayout>
|
|
52
21
|
|
|
53
22
|
<script>
|
|
54
|
-
import '../../
|
|
23
|
+
import '../../lib/client.js'
|
|
24
|
+
import { NymbalLoginForm } from '@nymbalhq/web-components/elements/nymbal-login-form'
|
|
25
|
+
import '../../scripts/signed-in-redirect.js'
|
|
26
|
+
|
|
27
|
+
NymbalLoginForm.define()
|
|
55
28
|
</script>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import '../../styles/pages/account.css'
|
|
3
|
+
import NymbalElement from '@nymbalhq/web-components/astro'
|
|
3
4
|
import AccountLayout from '../../layouts/AccountLayout.astro'
|
|
4
5
|
---
|
|
5
6
|
|
|
@@ -7,63 +8,12 @@ import AccountLayout from '../../layouts/AccountLayout.astro'
|
|
|
7
8
|
<h1 class="account-page-title">Profile</h1>
|
|
8
9
|
<p class="account-page-subtitle">Update your personal information.</p>
|
|
9
10
|
|
|
10
|
-
<
|
|
11
|
-
<div class="account-form-row">
|
|
12
|
-
<div class="form-group">
|
|
13
|
-
<label for="profile-firstName">First Name</label>
|
|
14
|
-
<input
|
|
15
|
-
type="text"
|
|
16
|
-
id="profile-firstName"
|
|
17
|
-
name="firstName"
|
|
18
|
-
autocomplete="given-name"
|
|
19
|
-
data-testid="profile-firstName"
|
|
20
|
-
/>
|
|
21
|
-
</div>
|
|
22
|
-
|
|
23
|
-
<div class="form-group">
|
|
24
|
-
<label for="profile-lastName">Last Name</label>
|
|
25
|
-
<input
|
|
26
|
-
type="text"
|
|
27
|
-
id="profile-lastName"
|
|
28
|
-
name="lastName"
|
|
29
|
-
autocomplete="family-name"
|
|
30
|
-
data-testid="profile-lastName"
|
|
31
|
-
/>
|
|
32
|
-
</div>
|
|
33
|
-
</div>
|
|
34
|
-
|
|
35
|
-
<div class="form-group">
|
|
36
|
-
<label for="profile-email">Email</label>
|
|
37
|
-
<input
|
|
38
|
-
type="email"
|
|
39
|
-
id="profile-email"
|
|
40
|
-
name="email"
|
|
41
|
-
disabled
|
|
42
|
-
autocomplete="email"
|
|
43
|
-
data-testid="profile-email"
|
|
44
|
-
style="opacity: 0.6; cursor: not-allowed;"
|
|
45
|
-
/>
|
|
46
|
-
</div>
|
|
47
|
-
|
|
48
|
-
<div class="form-group">
|
|
49
|
-
<label for="profile-phone">Phone</label>
|
|
50
|
-
<input
|
|
51
|
-
type="tel"
|
|
52
|
-
id="profile-phone"
|
|
53
|
-
name="phone"
|
|
54
|
-
autocomplete="tel"
|
|
55
|
-
data-testid="profile-phone"
|
|
56
|
-
/>
|
|
57
|
-
</div>
|
|
58
|
-
|
|
59
|
-
<div class="account-form-actions">
|
|
60
|
-
<button type="submit" class="btn btn-primary" data-testid="profile-save">Save Changes</button>
|
|
61
|
-
</div>
|
|
62
|
-
|
|
63
|
-
<p class="form-message" id="profile-message" style="display:none; margin-top: var(--nymbal-spacing-md, 1rem); font-size: var(--nymbal-font-size-sm, 0.875rem);"></p>
|
|
64
|
-
</form>
|
|
11
|
+
<NymbalElement tag="nymbal-profile-form" attributes={{ class: 'account-form' }} />
|
|
65
12
|
</AccountLayout>
|
|
66
13
|
|
|
67
14
|
<script>
|
|
68
|
-
import '../../
|
|
15
|
+
import '../../lib/client.js'
|
|
16
|
+
import { NymbalProfileForm } from '@nymbalhq/web-components/elements/nymbal-profile-form'
|
|
17
|
+
|
|
18
|
+
NymbalProfileForm.define()
|
|
69
19
|
</script>
|