create-nymbal-app 0.0.0 → 0.2.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/bin/create-nymbal-app.mjs +9 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +419 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -7
- package/scaffold/project/astro/src/pages/index.astro +57 -0
- package/scaffold/templates/astro/README.md +26 -0
- package/scaffold/templates/astro/_dot.env.example +6 -0
- package/scaffold/templates/astro/_dot.gitignore +16 -0
- package/scaffold/templates/astro/_dot.node-version +1 -0
- package/scaffold/templates/astro/_dot.npmrc +1 -0
- package/scaffold/templates/astro/astro.config.mjs +14 -0
- package/scaffold/templates/astro/package.json +59 -0
- package/scaffold/templates/astro/src/components/AddressForm.astro +107 -0
- package/scaffold/templates/astro/src/components/Breadcrumb.astro +20 -0
- package/scaffold/templates/astro/src/components/CategoryNav.astro +27 -0
- package/scaffold/templates/astro/src/components/CheckoutSteps.astro +29 -0
- package/scaffold/templates/astro/src/components/Footer.astro +108 -0
- package/scaffold/templates/astro/src/components/Header.astro +192 -0
- package/scaffold/templates/astro/src/components/HeroSection.astro +17 -0
- package/scaffold/templates/astro/src/components/ImageGallery.astro +42 -0
- package/scaffold/templates/astro/src/components/NewsletterSignup.astro +39 -0
- package/scaffold/templates/astro/src/components/OrderSummary.astro +53 -0
- package/scaffold/templates/astro/src/components/Pagination.astro +13 -0
- package/scaffold/templates/astro/src/components/PriceDisplay.astro +25 -0
- package/scaffold/templates/astro/src/components/ProductCard.astro +44 -0
- package/scaffold/templates/astro/src/components/RelatedProducts.astro +28 -0
- package/scaffold/templates/astro/src/components/ReviewForm.astro +100 -0
- package/scaffold/templates/astro/src/components/ReviewList.astro +41 -0
- package/scaffold/templates/astro/src/components/SkipNav.astro +3 -0
- package/scaffold/templates/astro/src/components/SortDropdown.astro +26 -0
- package/scaffold/templates/astro/src/components/StockBadge.astro +32 -0
- package/scaffold/templates/astro/src/env.d.ts +2 -0
- package/scaffold/templates/astro/src/layouts/AccountLayout.astro +168 -0
- package/scaffold/templates/astro/src/layouts/BaseLayout.astro +55 -0
- package/scaffold/templates/astro/src/lib/api.ts +89 -0
- package/scaffold/templates/astro/src/lib/client.ts +13 -0
- package/scaffold/templates/astro/src/lib/format.ts +17 -0
- package/scaffold/templates/astro/src/lib/order-status.ts +14 -0
- package/scaffold/templates/astro/src/lib/seo.ts +62 -0
- package/scaffold/templates/astro/src/pages/404.astro +18 -0
- package/scaffold/templates/astro/src/pages/500.astro +17 -0
- package/scaffold/templates/astro/src/pages/account/addresses.astro +110 -0
- package/scaffold/templates/astro/src/pages/account/index.astro +67 -0
- package/scaffold/templates/astro/src/pages/account/login.astro +55 -0
- package/scaffold/templates/astro/src/pages/account/orders/[orderNumber].astro +108 -0
- package/scaffold/templates/astro/src/pages/account/orders.astro +81 -0
- package/scaffold/templates/astro/src/pages/account/profile.astro +131 -0
- package/scaffold/templates/astro/src/pages/account/register.astro +94 -0
- package/scaffold/templates/astro/src/pages/cart.astro +16 -0
- package/scaffold/templates/astro/src/pages/category/[slug].astro +73 -0
- package/scaffold/templates/astro/src/pages/checkout.astro +100 -0
- package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -0
- package/scaffold/templates/astro/src/pages/fragments/header.astro +5 -0
- package/scaffold/templates/astro/src/pages/fragments/nav.astro +9 -0
- package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +14 -0
- package/scaffold/templates/astro/src/pages/index.astro +49 -0
- package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +127 -0
- package/scaffold/templates/astro/src/pages/product/[slug].astro +107 -0
- package/scaffold/templates/astro/src/pages/products/index.astro +62 -0
- package/scaffold/templates/astro/src/pages/search.astro +37 -0
- package/scaffold/templates/astro/src/scripts/account-guard.ts +22 -0
- package/scaffold/templates/astro/src/scripts/auth-forms.ts +89 -0
- package/scaffold/templates/astro/src/scripts/cart-page.ts +143 -0
- package/scaffold/templates/astro/src/scripts/checkout.ts +277 -0
- package/scaffold/templates/astro/src/scripts/image-gallery.ts +22 -0
- package/scaffold/templates/astro/src/scripts/init-client.ts +16 -0
- package/scaffold/templates/astro/src/scripts/order-status.ts +27 -0
- package/scaffold/templates/astro/src/scripts/pdp.ts +67 -0
- package/scaffold/templates/astro/src/scripts/plp.ts +109 -0
- package/scaffold/templates/astro/src/scripts/search.ts +145 -0
- package/scaffold/templates/astro/src/styles/global.css +362 -0
- package/scaffold/templates/astro/src/styles/pages/account.css +331 -0
- package/scaffold/templates/astro/src/styles/pages/cart.css +243 -0
- package/scaffold/templates/astro/src/styles/pages/checkout.css +270 -0
- package/scaffold/templates/astro/src/styles/pages/confirmation.css +142 -0
- package/scaffold/templates/astro/src/styles/pages/error.css +69 -0
- package/scaffold/templates/astro/src/styles/pages/home.css +269 -0
- package/scaffold/templates/astro/src/styles/pages/pdp.css +272 -0
- package/scaffold/templates/astro/src/styles/pages/plp.css +213 -0
- package/scaffold/templates/astro/src/styles/pages/search.css +152 -0
- package/scaffold/templates/astro/tsconfig.json +3 -0
- package/scaffold/templates/nextjs/README.md +26 -0
- package/scaffold/templates/nextjs/_dot.env.example +6 -0
- package/scaffold/templates/nextjs/_dot.gitignore +17 -0
- package/scaffold/templates/nextjs/_dot.node-version +1 -0
- package/scaffold/templates/nextjs/_dot.npmrc +1 -0
- package/scaffold/templates/nextjs/app/account/addresses/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/account/layout.tsx +57 -0
- package/scaffold/templates/nextjs/app/account/login/page.tsx +23 -0
- package/scaffold/templates/nextjs/app/account/orders/[orderNumber]/page.tsx +192 -0
- package/scaffold/templates/nextjs/app/account/orders/page.tsx +123 -0
- package/scaffold/templates/nextjs/app/account/page.tsx +59 -0
- package/scaffold/templates/nextjs/app/account/profile/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/account/register/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/cart/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +58 -0
- package/scaffold/templates/nextjs/app/checkout/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/error.tsx +49 -0
- package/scaffold/templates/nextjs/app/globals.css +473 -0
- package/scaffold/templates/nextjs/app/layout.tsx +29 -0
- package/scaffold/templates/nextjs/app/not-found.tsx +38 -0
- package/scaffold/templates/nextjs/app/order/[orderNumber]/page.tsx +127 -0
- package/scaffold/templates/nextjs/app/page.tsx +63 -0
- package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +111 -0
- package/scaffold/templates/nextjs/app/products/page.tsx +40 -0
- package/scaffold/templates/nextjs/app/search/page.tsx +24 -0
- package/scaffold/templates/nextjs/components/client/AddressManager.tsx +181 -0
- package/scaffold/templates/nextjs/components/client/AuthGuard.tsx +55 -0
- package/scaffold/templates/nextjs/components/client/CartDrawerToggle.tsx +39 -0
- package/scaffold/templates/nextjs/components/client/CartPage.tsx +120 -0
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +523 -0
- package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +68 -0
- package/scaffold/templates/nextjs/components/client/LoginForm.tsx +89 -0
- package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +12 -0
- package/scaffold/templates/nextjs/components/client/NewsletterSignup.tsx +74 -0
- package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +24 -0
- package/scaffold/templates/nextjs/components/client/OrderStatusRefresh.tsx +48 -0
- package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +148 -0
- package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +61 -0
- package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +112 -0
- package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +131 -0
- package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +116 -0
- package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +27 -0
- package/scaffold/templates/nextjs/components/client/SearchResults.tsx +141 -0
- package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +35 -0
- package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +36 -0
- package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +29 -0
- package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +60 -0
- package/scaffold/templates/nextjs/components/server/Footer.module.css +64 -0
- package/scaffold/templates/nextjs/components/server/Footer.tsx +79 -0
- package/scaffold/templates/nextjs/components/server/Header.module.css +147 -0
- package/scaffold/templates/nextjs/components/server/Header.tsx +102 -0
- package/scaffold/templates/nextjs/components/server/HeroSection.tsx +18 -0
- package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +79 -0
- package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +31 -0
- package/scaffold/templates/nextjs/components/server/ProductCard.tsx +59 -0
- package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +31 -0
- package/scaffold/templates/nextjs/components/server/ReviewList.tsx +62 -0
- package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -0
- package/scaffold/templates/nextjs/components/server/StockBadge.tsx +19 -0
- package/scaffold/templates/nextjs/lib/api.ts +89 -0
- package/scaffold/templates/nextjs/lib/client.ts +23 -0
- package/scaffold/templates/nextjs/lib/format.ts +7 -0
- package/scaffold/templates/nextjs/lib/order-status.ts +14 -0
- package/scaffold/templates/nextjs/lib/seo.ts +73 -0
- package/scaffold/templates/nextjs/next.config.mjs +9 -0
- package/scaffold/templates/nextjs/package.json +64 -0
- package/scaffold/templates/nextjs/styles/pages/account.module.css +377 -0
- package/scaffold/templates/nextjs/styles/pages/cart.module.css +240 -0
- package/scaffold/templates/nextjs/styles/pages/checkout.module.css +404 -0
- package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +237 -0
- package/scaffold/templates/nextjs/styles/pages/error.module.css +132 -0
- package/scaffold/templates/nextjs/styles/pages/home.module.css +285 -0
- package/scaffold/templates/nextjs/styles/pages/pdp.module.css +319 -0
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +280 -0
- package/scaffold/templates/nextjs/styles/pages/search.module.css +192 -0
- package/scaffold/templates/nextjs/tsconfig.json +41 -0
- package/README.md +0 -4
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../../styles/pages/pdp.css'
|
|
3
|
+
import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
4
|
+
import Breadcrumb from '../../components/Breadcrumb.astro'
|
|
5
|
+
import ImageGallery from '../../components/ImageGallery.astro'
|
|
6
|
+
import PriceDisplay from '../../components/PriceDisplay.astro'
|
|
7
|
+
import StockBadge from '../../components/StockBadge.astro'
|
|
8
|
+
import ReviewList from '../../components/ReviewList.astro'
|
|
9
|
+
import ReviewForm from '../../components/ReviewForm.astro'
|
|
10
|
+
import RelatedProducts from '../../components/RelatedProducts.astro'
|
|
11
|
+
import { fetchProduct, fetchReviews, fetchProducts } from '../../lib/api'
|
|
12
|
+
import { productJsonLd, breadcrumbJsonLd } from '../../lib/seo'
|
|
13
|
+
|
|
14
|
+
const { slug } = Astro.params
|
|
15
|
+
|
|
16
|
+
const product = await fetchProduct(slug!)
|
|
17
|
+
if (!product) return Astro.redirect('/404')
|
|
18
|
+
|
|
19
|
+
const [reviewsResult, relatedResult] = await Promise.all([
|
|
20
|
+
fetchReviews(product.id),
|
|
21
|
+
fetchProducts({
|
|
22
|
+
limit: 4,
|
|
23
|
+
category: product.categoryIds?.[0] ?? undefined,
|
|
24
|
+
}),
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
const reviews = reviewsResult.items ?? []
|
|
28
|
+
const relatedProducts = (relatedResult.items ?? []).filter((p) => p.id !== product.id).slice(0, 4)
|
|
29
|
+
|
|
30
|
+
const images = Array.isArray(product.media) && product.media.length > 0
|
|
31
|
+
? product.media.map((m: any) => ({
|
|
32
|
+
url: typeof m === 'string' ? m : m.url,
|
|
33
|
+
alt: typeof m === 'string' ? product.name : (m.alt ?? product.name),
|
|
34
|
+
}))
|
|
35
|
+
: [{ url: `https://picsum.photos/seed/${product.slug}/600/600`, alt: product.name }]
|
|
36
|
+
|
|
37
|
+
const defaultVariant = product.variants?.[0]
|
|
38
|
+
const stock = defaultVariant?.stock ?? (product.inStock ? 10 : 0)
|
|
39
|
+
|
|
40
|
+
const jsonLd = productJsonLd(product, Astro.url.origin)
|
|
41
|
+
const breadcrumb = breadcrumbJsonLd([
|
|
42
|
+
{ name: 'Home', url: Astro.url.origin },
|
|
43
|
+
{ name: 'Products', url: `${Astro.url.origin}/products` },
|
|
44
|
+
{ name: product.name, url: `${Astro.url.origin}/product/${product.slug}` },
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
// The selector derives its option groups from the product's variants.
|
|
48
|
+
const hasVariantChoice = (product.variants?.length ?? 0) > 1
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
<BaseLayout title={`${product.name} | Nymbal`} description={product.shortDescription ?? product.description ?? ''} jsonLd={jsonLd}>
|
|
52
|
+
<div class="container section" data-product-slug={product.slug}>
|
|
53
|
+
<Breadcrumb
|
|
54
|
+
items={[
|
|
55
|
+
{ label: 'Home', href: '/' },
|
|
56
|
+
{ label: 'Products', href: '/products' },
|
|
57
|
+
{ label: product.name },
|
|
58
|
+
]}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<div class="pdp-layout">
|
|
62
|
+
<ImageGallery images={images} />
|
|
63
|
+
|
|
64
|
+
<div class="pdp-info">
|
|
65
|
+
<h1 class="pdp-title" data-testid="product-title">{product.name}</h1>
|
|
66
|
+
|
|
67
|
+
<PriceDisplay
|
|
68
|
+
priceMinor={defaultVariant?.priceMinor ?? product.priceMinor}
|
|
69
|
+
currency={product.currency ?? 'GBP'}
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
<StockBadge stock={stock} />
|
|
73
|
+
|
|
74
|
+
<div class="pdp-actions">
|
|
75
|
+
{hasVariantChoice && (
|
|
76
|
+
<nymbal-variant-selector></nymbal-variant-selector>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
<div class="pdp-add-row">
|
|
80
|
+
<nymbal-quantity-selector value="1" min="1" max="99"></nymbal-quantity-selector>
|
|
81
|
+
<nymbal-add-to-cart
|
|
82
|
+
variant-id={defaultVariant?.id ?? product.id}
|
|
83
|
+
></nymbal-add-to-cart>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{product.description && (
|
|
88
|
+
<div class="pdp-description" data-testid="product-description">
|
|
89
|
+
<Fragment set:html={product.description} />
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<section class="pdp-reviews">
|
|
96
|
+
<h2 class="pdp-reviews-title">Customer Reviews</h2>
|
|
97
|
+
<ReviewList reviews={reviews} />
|
|
98
|
+
<ReviewForm productId={product.id} />
|
|
99
|
+
</section>
|
|
100
|
+
|
|
101
|
+
<RelatedProducts products={relatedProducts} />
|
|
102
|
+
</div>
|
|
103
|
+
</BaseLayout>
|
|
104
|
+
|
|
105
|
+
<script>
|
|
106
|
+
import '../../scripts/pdp.js'
|
|
107
|
+
</script>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../../styles/pages/plp.css'
|
|
3
|
+
import '../../styles/pages/home.css'
|
|
4
|
+
import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
5
|
+
import Breadcrumb from '../../components/Breadcrumb.astro'
|
|
6
|
+
import ProductCard from '../../components/ProductCard.astro'
|
|
7
|
+
import SortDropdown from '../../components/SortDropdown.astro'
|
|
8
|
+
import Pagination from '../../components/Pagination.astro'
|
|
9
|
+
import { fetchProducts, fetchCategories, sortFromUrl } from '../../lib/api'
|
|
10
|
+
|
|
11
|
+
const sort = sortFromUrl(Astro.url)
|
|
12
|
+
|
|
13
|
+
const [productResult, categories] = await Promise.all([
|
|
14
|
+
fetchProducts({ limit: 24, sort }),
|
|
15
|
+
fetchCategories(),
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
const products = productResult.items
|
|
19
|
+
const facets = productResult.facets ?? []
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<BaseLayout title="All Products | Nymbal" description="Browse our full product collection.">
|
|
23
|
+
<div class="container section">
|
|
24
|
+
<Breadcrumb items={[{ label: 'Home', href: '/' }, { label: 'Products' }]} />
|
|
25
|
+
|
|
26
|
+
<div class="plp-header">
|
|
27
|
+
<h1 class="plp-title">All Products</h1>
|
|
28
|
+
<p class="plp-count" id="product-count">{products.length} products</p>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="plp-layout">
|
|
32
|
+
<aside class="plp-sidebar">
|
|
33
|
+
<nymbal-product-filter
|
|
34
|
+
facets={JSON.stringify(facets)}
|
|
35
|
+
></nymbal-product-filter>
|
|
36
|
+
</aside>
|
|
37
|
+
|
|
38
|
+
<div class="plp-grid-area">
|
|
39
|
+
<SortDropdown sort={sort} />
|
|
40
|
+
|
|
41
|
+
<div class="product-grid" id="product-grid">
|
|
42
|
+
{products.map((product) => (
|
|
43
|
+
<ProductCard product={product} />
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{products.length === 0 && (
|
|
48
|
+
<div class="plp-empty">
|
|
49
|
+
<h2 class="plp-empty-title">No products found</h2>
|
|
50
|
+
<p class="plp-empty-text">Try adjusting your filters.</p>
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
<Pagination />
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</BaseLayout>
|
|
59
|
+
|
|
60
|
+
<script>
|
|
61
|
+
import '../../scripts/plp.js'
|
|
62
|
+
</script>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../styles/pages/search.css'
|
|
3
|
+
import '../styles/pages/home.css'
|
|
4
|
+
import BaseLayout from '../layouts/BaseLayout.astro'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<BaseLayout title="Search | Nymbal" description="Search our product catalogue.">
|
|
8
|
+
<div class="search-hero">
|
|
9
|
+
<h1 class="search-hero-title">Search</h1>
|
|
10
|
+
<div class="search-bar-wrapper">
|
|
11
|
+
<input
|
|
12
|
+
type="search"
|
|
13
|
+
id="search-input"
|
|
14
|
+
class="search-input-large"
|
|
15
|
+
placeholder="Search for products..."
|
|
16
|
+
aria-label="Search products"
|
|
17
|
+
data-testid="search-page-input"
|
|
18
|
+
autocomplete="off"
|
|
19
|
+
/>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="container section">
|
|
24
|
+
<p class="search-query-label" id="search-query-label" style="display:none; margin-bottom: var(--nymbal-spacing-md, 1rem);"></p>
|
|
25
|
+
<div id="search-results" data-testid="search-results">
|
|
26
|
+
<div class="search-empty">
|
|
27
|
+
<div class="search-empty-icon" aria-hidden="true">🔍</div>
|
|
28
|
+
<h2 class="search-empty-title">Search our store</h2>
|
|
29
|
+
<p class="search-empty-text">Type a keyword to find products.</p>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</BaseLayout>
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
import '../scripts/search.js'
|
|
37
|
+
</script>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
|
|
3
|
+
export async function guardAuth(): Promise<boolean> {
|
|
4
|
+
let state = client.auth.getState()
|
|
5
|
+
|
|
6
|
+
if (!state.isAuthenticated) {
|
|
7
|
+
try {
|
|
8
|
+
await client.auth.loadProfile()
|
|
9
|
+
state = client.auth.getState()
|
|
10
|
+
} catch {
|
|
11
|
+
// will redirect below
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!state.isAuthenticated) {
|
|
16
|
+
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
|
|
17
|
+
window.location.href = `/account/login?redirect=${redirect}`
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
|
|
3
|
+
function getRedirectUrl(): string {
|
|
4
|
+
const params = new URLSearchParams(window.location.search)
|
|
5
|
+
return params.get('redirect') ?? '/account'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function showFormError(formId: string, message: string) {
|
|
9
|
+
const errorEl = document.getElementById(`${formId}-error`)
|
|
10
|
+
if (errorEl) {
|
|
11
|
+
errorEl.textContent = message
|
|
12
|
+
errorEl.style.display = 'block'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hideFormError(formId: string) {
|
|
17
|
+
const errorEl = document.getElementById(`${formId}-error`)
|
|
18
|
+
if (errorEl) {
|
|
19
|
+
errorEl.style.display = 'none'
|
|
20
|
+
errorEl.textContent = ''
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Login form handler
|
|
25
|
+
const loginForm = document.getElementById('login-form') as HTMLFormElement | null
|
|
26
|
+
loginForm?.addEventListener('submit', async (e) => {
|
|
27
|
+
e.preventDefault()
|
|
28
|
+
hideFormError('login')
|
|
29
|
+
|
|
30
|
+
const email = (document.getElementById('login-email') as HTMLInputElement)?.value
|
|
31
|
+
const password = (document.getElementById('login-password') as HTMLInputElement)?.value
|
|
32
|
+
|
|
33
|
+
if (!email || !password) {
|
|
34
|
+
showFormError('login', 'Please enter your email and password.')
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const submitBtn = loginForm.querySelector('[data-testid="login-submit"]') as HTMLButtonElement | null
|
|
39
|
+
if (submitBtn) {
|
|
40
|
+
submitBtn.disabled = true
|
|
41
|
+
submitBtn.textContent = 'Signing in...'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await client.auth.login(email, password)
|
|
46
|
+
window.location.href = getRedirectUrl()
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
showFormError('login', err.message ?? 'Invalid email or password.')
|
|
49
|
+
if (submitBtn) {
|
|
50
|
+
submitBtn.disabled = false
|
|
51
|
+
submitBtn.textContent = 'Sign In'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Register form handler
|
|
57
|
+
const registerForm = document.getElementById('register-form') as HTMLFormElement | null
|
|
58
|
+
registerForm?.addEventListener('submit', async (e) => {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
hideFormError('register')
|
|
61
|
+
|
|
62
|
+
const email = (document.getElementById('register-email') as HTMLInputElement)?.value
|
|
63
|
+
const password = (document.getElementById('register-password') as HTMLInputElement)?.value
|
|
64
|
+
const firstName = (document.getElementById('register-firstName') as HTMLInputElement)?.value
|
|
65
|
+
const lastName = (document.getElementById('register-lastName') as HTMLInputElement)?.value
|
|
66
|
+
const phone = (document.getElementById('register-phone') as HTMLInputElement)?.value
|
|
67
|
+
|
|
68
|
+
if (!email || !password) {
|
|
69
|
+
showFormError('register', 'Please enter your email and password.')
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const submitBtn = registerForm.querySelector('[data-testid="register-submit"]') as HTMLButtonElement | null
|
|
74
|
+
if (submitBtn) {
|
|
75
|
+
submitBtn.disabled = true
|
|
76
|
+
submitBtn.textContent = 'Creating account...'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await client.auth.register({ email, password, firstName, lastName, phone })
|
|
81
|
+
window.location.href = getRedirectUrl()
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
showFormError('register', err.message ?? 'Registration failed. Please try again.')
|
|
84
|
+
if (submitBtn) {
|
|
85
|
+
submitBtn.disabled = false
|
|
86
|
+
submitBtn.textContent = 'Create Account'
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
import { formatPrice } from '../lib/format.js'
|
|
3
|
+
|
|
4
|
+
const container = document.getElementById('cart-content')
|
|
5
|
+
if (!container) throw new Error('Missing #cart-content')
|
|
6
|
+
|
|
7
|
+
function renderCart() {
|
|
8
|
+
const state = client.cart.getState()
|
|
9
|
+
const items = state.items ?? []
|
|
10
|
+
const currency = state.currency || 'GBP'
|
|
11
|
+
|
|
12
|
+
if (items.length === 0) {
|
|
13
|
+
container!.innerHTML = `
|
|
14
|
+
<div class="cart-empty">
|
|
15
|
+
<h1 class="cart-empty-title">Your cart is empty</h1>
|
|
16
|
+
<p class="cart-empty-text">Looks like you haven't added anything yet.</p>
|
|
17
|
+
<a href="/products" class="btn btn-primary" data-testid="continue-shopping">Continue Shopping</a>
|
|
18
|
+
</div>
|
|
19
|
+
`
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const subtotal = items.reduce((sum: number, item: any) => sum + item.priceMinor * item.qty, 0)
|
|
24
|
+
|
|
25
|
+
const itemsHtml = items
|
|
26
|
+
.map(
|
|
27
|
+
(item: any) => `
|
|
28
|
+
<div class="cart-line-item" data-testid="cart-item-${item.variantId}">
|
|
29
|
+
<img
|
|
30
|
+
src="${item.imageUrl || `https://picsum.photos/seed/${item.productId}/200/200`}"
|
|
31
|
+
alt="${item.productName}"
|
|
32
|
+
class="cart-line-image"
|
|
33
|
+
width="100"
|
|
34
|
+
height="100"
|
|
35
|
+
loading="lazy"
|
|
36
|
+
/>
|
|
37
|
+
<div class="cart-line-info">
|
|
38
|
+
<a href="/product/${item.productId}" class="cart-line-name">${item.productName}</a>
|
|
39
|
+
${item.variantName ? `<span class="cart-line-variant">${item.variantName}</span>` : ''}
|
|
40
|
+
<span class="cart-line-price-each">${formatPrice(item.priceMinor, currency)} each</span>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
class="cart-line-remove"
|
|
44
|
+
data-variant-id="${item.variantId}"
|
|
45
|
+
data-testid="cart-remove-${item.variantId}"
|
|
46
|
+
>
|
|
47
|
+
Remove
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="cart-line-quantity">
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
class="cart-line-quantity-btn"
|
|
54
|
+
data-action="decrement"
|
|
55
|
+
data-variant-id="${item.variantId}"
|
|
56
|
+
aria-label="Decrease quantity"
|
|
57
|
+
>-</button>
|
|
58
|
+
<span class="cart-line-quantity-value">${item.qty}</span>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
class="cart-line-quantity-btn"
|
|
62
|
+
data-action="increment"
|
|
63
|
+
data-variant-id="${item.variantId}"
|
|
64
|
+
aria-label="Increase quantity"
|
|
65
|
+
>+</button>
|
|
66
|
+
</div>
|
|
67
|
+
<span class="cart-line-total">${formatPrice(item.priceMinor * item.qty, currency)}</span>
|
|
68
|
+
</div>
|
|
69
|
+
`,
|
|
70
|
+
)
|
|
71
|
+
.join('')
|
|
72
|
+
|
|
73
|
+
container!.innerHTML = `
|
|
74
|
+
<div class="cart-layout">
|
|
75
|
+
<div>
|
|
76
|
+
<div class="cart-header">
|
|
77
|
+
<h1 class="cart-title">Your Cart <span class="cart-item-count">(${items.length} ${items.length === 1 ? 'item' : 'items'})</span></h1>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="cart-items" data-testid="cart-items">
|
|
80
|
+
${itemsHtml}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="cart-summary" data-testid="cart-summary">
|
|
84
|
+
<h2 class="cart-summary-title">Order Summary</h2>
|
|
85
|
+
<dl>
|
|
86
|
+
<div class="cart-summary-row">
|
|
87
|
+
<dt>Subtotal</dt>
|
|
88
|
+
<dd>${formatPrice(subtotal, currency)}</dd>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="cart-summary-row">
|
|
91
|
+
<dt>Shipping</dt>
|
|
92
|
+
<dd>Calculated at checkout</dd>
|
|
93
|
+
</div>
|
|
94
|
+
</dl>
|
|
95
|
+
<div class="cart-summary-total">
|
|
96
|
+
<span>Estimated Total</span>
|
|
97
|
+
<span>${formatPrice(subtotal, currency)}</span>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="cart-summary-actions">
|
|
100
|
+
<a href="/checkout" class="btn btn-primary" data-testid="checkout-btn">Proceed to Checkout</a>
|
|
101
|
+
<a href="/products" class="btn btn-secondary">Continue Shopping</a>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
`
|
|
106
|
+
|
|
107
|
+
bindCartActions()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bindCartActions() {
|
|
111
|
+
container!.querySelectorAll('.cart-line-remove').forEach((btn) => {
|
|
112
|
+
btn.addEventListener('click', () => {
|
|
113
|
+
const variantId = (btn as HTMLElement).dataset.variantId
|
|
114
|
+
if (variantId) client.cart.removeItem(variantId)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
container!.querySelectorAll('.cart-line-quantity-btn').forEach((btn) => {
|
|
119
|
+
btn.addEventListener('click', () => {
|
|
120
|
+
const el = btn as HTMLElement
|
|
121
|
+
const variantId = el.dataset.variantId
|
|
122
|
+
const action = el.dataset.action
|
|
123
|
+
if (!variantId) return
|
|
124
|
+
|
|
125
|
+
const state = client.cart.getState()
|
|
126
|
+
const item = (state.items ?? []).find((i: any) => i.variantId === variantId)
|
|
127
|
+
if (!item) return
|
|
128
|
+
|
|
129
|
+
if (action === 'increment') {
|
|
130
|
+
client.cart.updateQuantity(variantId, item.qty + 1)
|
|
131
|
+
} else if (action === 'decrement') {
|
|
132
|
+
if (item.qty <= 1) {
|
|
133
|
+
client.cart.removeItem(variantId)
|
|
134
|
+
} else {
|
|
135
|
+
client.cart.updateQuantity(variantId, item.qty - 1)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
client.cart.subscribe(renderCart)
|
|
143
|
+
renderCart()
|