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,63 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
import { fetchProducts, fetchCategories } from '@/lib/api'
|
|
3
|
+
import { organizationJsonLd } from '@/lib/seo'
|
|
4
|
+
import { HeroSection } from '@/components/server/HeroSection'
|
|
5
|
+
import { CategoryNav } from '@/components/server/CategoryNav'
|
|
6
|
+
import { ProductCard } from '@/components/server/ProductCard'
|
|
7
|
+
import { NewsletterSignup } from '@/components/client/NewsletterSignup'
|
|
8
|
+
import styles from '@/styles/pages/home.module.css'
|
|
9
|
+
|
|
10
|
+
export default async function HomePage() {
|
|
11
|
+
const [{ items: products }, categories] = await Promise.all([
|
|
12
|
+
fetchProducts({ limit: 8 }),
|
|
13
|
+
fetchCategories(),
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
const jsonLd = organizationJsonLd(
|
|
17
|
+
'Nymbal Store',
|
|
18
|
+
process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<script
|
|
24
|
+
type="application/ld+json"
|
|
25
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
<HeroSection />
|
|
29
|
+
|
|
30
|
+
<CategoryNav categories={categories} />
|
|
31
|
+
|
|
32
|
+
<section className={`container ${styles.featuredSection}`}>
|
|
33
|
+
<div className={styles.featuredHeader}>
|
|
34
|
+
<h2 className={styles.featuredTitle}>Featured Products</h2>
|
|
35
|
+
<Link href="/products" className={styles.featuredViewAll}>
|
|
36
|
+
View All
|
|
37
|
+
</Link>
|
|
38
|
+
</div>
|
|
39
|
+
{products.length > 0 ? (
|
|
40
|
+
<div className="product-grid">
|
|
41
|
+
{products.map((product) => (
|
|
42
|
+
<ProductCard
|
|
43
|
+
key={product.id}
|
|
44
|
+
product={{
|
|
45
|
+
slug: product.slug,
|
|
46
|
+
name: product.name,
|
|
47
|
+
priceMinor: product.priceMinor ?? null,
|
|
48
|
+
priceRange: product.priceRange ?? null,
|
|
49
|
+
currency: product.currency ?? null,
|
|
50
|
+
media: product.media,
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
) : (
|
|
56
|
+
<p className="text-muted text-center">No products available yet. Check back soon.</p>
|
|
57
|
+
)}
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<NewsletterSignup />
|
|
61
|
+
</>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { notFound } from 'next/navigation'
|
|
3
|
+
import { fetchProduct, fetchProducts, fetchReviews } from '@/lib/api'
|
|
4
|
+
import { productJsonLd } from '@/lib/seo'
|
|
5
|
+
import { Breadcrumb } from '@/components/server/Breadcrumb'
|
|
6
|
+
import { ReviewList } from '@/components/server/ReviewList'
|
|
7
|
+
import { RelatedProducts } from '@/components/server/RelatedProducts'
|
|
8
|
+
import { ImageGallery } from '@/components/client/ImageGallery'
|
|
9
|
+
import { ProductInteractive } from '@/components/client/ProductInteractive'
|
|
10
|
+
import { ReviewForm } from '@/components/client/ReviewForm'
|
|
11
|
+
import styles from '@/styles/pages/pdp.module.css'
|
|
12
|
+
|
|
13
|
+
interface ProductPageProps {
|
|
14
|
+
params: Promise<{ slug: string }>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function generateMetadata({
|
|
18
|
+
params,
|
|
19
|
+
}: ProductPageProps): Promise<Metadata> {
|
|
20
|
+
const { slug } = await params
|
|
21
|
+
const product = await fetchProduct(slug)
|
|
22
|
+
|
|
23
|
+
if (!product) {
|
|
24
|
+
return { title: 'Product Not Found' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
title: product.name,
|
|
29
|
+
description: product.shortDescription ?? product.description ?? `Buy ${product.name}.`,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default async function ProductPage({ params }: ProductPageProps) {
|
|
34
|
+
const { slug } = await params
|
|
35
|
+
const product = await fetchProduct(slug)
|
|
36
|
+
|
|
37
|
+
if (!product) notFound()
|
|
38
|
+
|
|
39
|
+
const [{ items: reviews }, { items: relatedProducts }] = await Promise.all([
|
|
40
|
+
fetchReviews(product.id),
|
|
41
|
+
fetchProducts({ limit: 4 }),
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const related = relatedProducts.filter((p) => p.id !== product.id).slice(0, 4)
|
|
45
|
+
|
|
46
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
|
|
47
|
+
const jsonLd = productJsonLd(product, baseUrl)
|
|
48
|
+
|
|
49
|
+
const mediaArray = Array.isArray(product.media) ? product.media : []
|
|
50
|
+
const images = mediaArray.length > 0
|
|
51
|
+
? mediaArray.map((m: Record<string, string>) => ({
|
|
52
|
+
url: m.url,
|
|
53
|
+
alt: m.alt ?? product.name,
|
|
54
|
+
}))
|
|
55
|
+
: [{ url: `https://picsum.photos/seed/${product.slug}/600/600`, alt: product.name }]
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<script
|
|
60
|
+
type="application/ld+json"
|
|
61
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<div className={`container ${styles.page}`}>
|
|
65
|
+
<Breadcrumb
|
|
66
|
+
items={[
|
|
67
|
+
{ label: 'Home', href: '/' },
|
|
68
|
+
{ label: 'Products', href: '/products' },
|
|
69
|
+
{ label: product.name },
|
|
70
|
+
]}
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<div className={styles.productLayout}>
|
|
74
|
+
<ImageGallery images={images} />
|
|
75
|
+
|
|
76
|
+
<div className={styles.info}>
|
|
77
|
+
<ProductInteractive product={product} />
|
|
78
|
+
|
|
79
|
+
{product.description && (
|
|
80
|
+
<div className={styles.description}>
|
|
81
|
+
<p>{product.description}</p>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
<div className={styles.detailsSection}>
|
|
86
|
+
<details className={styles.detailsItem}>
|
|
87
|
+
<summary className={styles.detailsSummary}>Details</summary>
|
|
88
|
+
<div className={styles.detailsContent}>
|
|
89
|
+
{product.description ?? 'No additional details available.'}
|
|
90
|
+
</div>
|
|
91
|
+
</details>
|
|
92
|
+
<details className={styles.detailsItem}>
|
|
93
|
+
<summary className={styles.detailsSummary}>
|
|
94
|
+
Shipping & Returns
|
|
95
|
+
</summary>
|
|
96
|
+
<div className={styles.detailsContent}>
|
|
97
|
+
Free standard shipping on orders over $50. Returns accepted within
|
|
98
|
+
30 days of purchase.
|
|
99
|
+
</div>
|
|
100
|
+
</details>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<ReviewList reviews={reviews} />
|
|
106
|
+
<ReviewForm productId={product.id} />
|
|
107
|
+
<RelatedProducts products={related} />
|
|
108
|
+
</div>
|
|
109
|
+
</>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { fetchProducts, fetchCategories, sortFromSearchParams } from '@/lib/api'
|
|
3
|
+
import { Breadcrumb } from '@/components/server/Breadcrumb'
|
|
4
|
+
import { ProductGrid } from '@/components/client/ProductGrid'
|
|
5
|
+
import styles from '@/styles/pages/plp.module.css'
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: 'All Products',
|
|
9
|
+
description: 'Browse our full collection of products.',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ProductsPageProps {
|
|
13
|
+
searchParams: Promise<{ sort?: string | string[] }>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default async function ProductsPage({ searchParams }: ProductsPageProps) {
|
|
17
|
+
const sort = sortFromSearchParams(await searchParams)
|
|
18
|
+
const [{ items, nextCursor }, categories] = await Promise.all([
|
|
19
|
+
fetchProducts({ limit: 24, sort }),
|
|
20
|
+
fetchCategories(),
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className={`container ${styles.page}`}>
|
|
25
|
+
<Breadcrumb
|
|
26
|
+
items={[
|
|
27
|
+
{ label: 'Home', href: '/' },
|
|
28
|
+
{ label: 'Products' },
|
|
29
|
+
]}
|
|
30
|
+
/>
|
|
31
|
+
<h1 className={styles.pageTitle}>All Products</h1>
|
|
32
|
+
<ProductGrid
|
|
33
|
+
initialProducts={items}
|
|
34
|
+
initialCursor={nextCursor}
|
|
35
|
+
initialSort={sort}
|
|
36
|
+
categories={categories}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { Suspense } from 'react'
|
|
3
|
+
import { SearchResults } from '@/components/client/SearchResults'
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'Search',
|
|
7
|
+
description: 'Search our product catalog.',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function SearchPage() {
|
|
11
|
+
return (
|
|
12
|
+
<Suspense
|
|
13
|
+
fallback={
|
|
14
|
+
<div className="loading-center">
|
|
15
|
+
<div className="spinner spinner-lg" role="status">
|
|
16
|
+
<span className="visually-hidden">Loading...</span>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
}
|
|
20
|
+
>
|
|
21
|
+
<SearchResults />
|
|
22
|
+
</Suspense>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
|
4
|
+
import { useNymbalClient } from '@nymbalhq/react'
|
|
5
|
+
import type { Address, CustomerAddress } from '@nymbalhq/types'
|
|
6
|
+
import styles from '@/styles/pages/account.module.css'
|
|
7
|
+
|
|
8
|
+
// The same fields and test ids as the Astro template's AddressForm (`new-address-<name>`).
|
|
9
|
+
const FIELDS: Array<{
|
|
10
|
+
name: keyof Address
|
|
11
|
+
label: string
|
|
12
|
+
autoComplete: string
|
|
13
|
+
required: boolean
|
|
14
|
+
full?: boolean
|
|
15
|
+
placeholder?: string
|
|
16
|
+
}> = [
|
|
17
|
+
{ name: 'firstName', label: 'First Name', autoComplete: 'given-name', required: true },
|
|
18
|
+
{ name: 'lastName', label: 'Last Name', autoComplete: 'family-name', required: true },
|
|
19
|
+
{
|
|
20
|
+
name: 'addressLine1',
|
|
21
|
+
label: 'Address Line 1',
|
|
22
|
+
autoComplete: 'address-line1',
|
|
23
|
+
required: true,
|
|
24
|
+
full: true,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'addressLine2',
|
|
28
|
+
label: 'Address Line 2 (optional)',
|
|
29
|
+
autoComplete: 'address-line2',
|
|
30
|
+
required: false,
|
|
31
|
+
full: true,
|
|
32
|
+
},
|
|
33
|
+
{ name: 'city', label: 'City', autoComplete: 'address-level2', required: true },
|
|
34
|
+
{ name: 'region', label: 'State / County', autoComplete: 'address-level1', required: true },
|
|
35
|
+
{ name: 'postalCode', label: 'Postcode', autoComplete: 'postal-code', required: true },
|
|
36
|
+
{
|
|
37
|
+
name: 'country',
|
|
38
|
+
label: 'Country (2-letter code)',
|
|
39
|
+
autoComplete: 'country',
|
|
40
|
+
required: true,
|
|
41
|
+
placeholder: 'GB',
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
export function AddressManager() {
|
|
46
|
+
const client = useNymbalClient()
|
|
47
|
+
const [addresses, setAddresses] = useState<CustomerAddress[] | null>(null)
|
|
48
|
+
const [loadError, setLoadError] = useState(false)
|
|
49
|
+
const [saving, setSaving] = useState(false)
|
|
50
|
+
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
|
|
51
|
+
|
|
52
|
+
const load = useCallback(async () => {
|
|
53
|
+
try {
|
|
54
|
+
setAddresses(await client.customer.addresses.list())
|
|
55
|
+
setLoadError(false)
|
|
56
|
+
} catch {
|
|
57
|
+
setLoadError(true)
|
|
58
|
+
}
|
|
59
|
+
}, [client])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
void load()
|
|
63
|
+
}, [load])
|
|
64
|
+
|
|
65
|
+
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
const form = e.currentTarget
|
|
68
|
+
const data = new FormData(form)
|
|
69
|
+
const field = (name: string) => String(data.get(name) ?? '').trim()
|
|
70
|
+
const address: Address = {
|
|
71
|
+
firstName: field('firstName'),
|
|
72
|
+
lastName: field('lastName'),
|
|
73
|
+
addressLine1: field('addressLine1'),
|
|
74
|
+
city: field('city'),
|
|
75
|
+
region: field('region'),
|
|
76
|
+
postalCode: field('postalCode'),
|
|
77
|
+
country: field('country'),
|
|
78
|
+
...(field('addressLine2') && { addressLine2: field('addressLine2') }),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setSaving(true)
|
|
82
|
+
setMessage(null)
|
|
83
|
+
try {
|
|
84
|
+
await client.customer.addresses.create(address)
|
|
85
|
+
form.reset()
|
|
86
|
+
setMessage({ ok: true, text: 'Address saved successfully.' })
|
|
87
|
+
await load()
|
|
88
|
+
} catch {
|
|
89
|
+
setMessage({ ok: false, text: 'Failed to save address. Please try again.' })
|
|
90
|
+
} finally {
|
|
91
|
+
setSaving(false)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div>
|
|
97
|
+
<h2 className={styles.pageTitle}>Addresses</h2>
|
|
98
|
+
|
|
99
|
+
<div data-testid="addresses-list">
|
|
100
|
+
{addresses === null && !loadError && (
|
|
101
|
+
<p className="text-sm text-muted">Loading addresses...</p>
|
|
102
|
+
)}
|
|
103
|
+
{loadError && <p className="text-sm text-muted">Unable to load addresses.</p>}
|
|
104
|
+
{addresses !== null && addresses.length === 0 && (
|
|
105
|
+
<div className={styles.emptyOrders}>
|
|
106
|
+
<h3 className={styles.emptyOrdersTitle}>No saved addresses</h3>
|
|
107
|
+
<p className={styles.emptyOrdersText}>Add an address below to get started.</p>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
{addresses?.map((addr) => (
|
|
111
|
+
<div
|
|
112
|
+
key={addr.id}
|
|
113
|
+
data-testid={`address-${addr.id}`}
|
|
114
|
+
className={styles.formCard}
|
|
115
|
+
style={{ marginBottom: 'var(--nymbal-spacing-md)' }}
|
|
116
|
+
>
|
|
117
|
+
<p style={{ fontSize: 'var(--nymbal-font-size-sm)', lineHeight: 1.6 }}>
|
|
118
|
+
{addr.firstName} {addr.lastName}
|
|
119
|
+
<br />
|
|
120
|
+
{addr.addressLine1}
|
|
121
|
+
{addr.addressLine2 && <>, {addr.addressLine2}</>}
|
|
122
|
+
<br />
|
|
123
|
+
{addr.city}, {addr.region} {addr.postalCode}
|
|
124
|
+
<br />
|
|
125
|
+
{addr.country}
|
|
126
|
+
</p>
|
|
127
|
+
<div
|
|
128
|
+
style={{
|
|
129
|
+
display: 'flex',
|
|
130
|
+
gap: 'var(--nymbal-spacing-sm)',
|
|
131
|
+
marginTop: 'var(--nymbal-spacing-sm)',
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{addr.isDefaultShipping && <span className="badge">Default Shipping</span>}
|
|
135
|
+
{addr.isDefaultBilling && <span className="badge">Default Billing</span>}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div className={styles.formCard} style={{ marginTop: 'var(--nymbal-spacing-xl)' }}>
|
|
142
|
+
<h3 className={styles.formCardTitle}>Add New Address</h3>
|
|
143
|
+
{message && (
|
|
144
|
+
<div
|
|
145
|
+
className={`${styles.alert} ${message.ok ? styles.alertSuccess : styles.alertError}`}
|
|
146
|
+
>
|
|
147
|
+
{message.text}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
<form onSubmit={handleSubmit} data-testid="add-address-form">
|
|
151
|
+
<div className={styles.formGrid}>
|
|
152
|
+
{FIELDS.map((f) => (
|
|
153
|
+
<div key={f.name} className={f.full ? styles.formGridFull : undefined}>
|
|
154
|
+
<label htmlFor={`new-address-${f.name}`}>{f.label}</label>
|
|
155
|
+
<input
|
|
156
|
+
id={`new-address-${f.name}`}
|
|
157
|
+
name={f.name}
|
|
158
|
+
type="text"
|
|
159
|
+
required={f.required}
|
|
160
|
+
autoComplete={f.autoComplete}
|
|
161
|
+
placeholder={f.placeholder}
|
|
162
|
+
data-testid={`new-address-${f.name}`}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
<div className={styles.formActions}>
|
|
168
|
+
<button
|
|
169
|
+
type="submit"
|
|
170
|
+
className={styles.saveButton}
|
|
171
|
+
disabled={saving}
|
|
172
|
+
data-testid="save-address"
|
|
173
|
+
>
|
|
174
|
+
{saving ? 'Saving...' : 'Save Address'}
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
</form>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
4
|
+
import { useRouter, usePathname } from 'next/navigation'
|
|
5
|
+
import { useAuth } from '@nymbalhq/react'
|
|
6
|
+
import { restoreSession } from '@/lib/client'
|
|
7
|
+
|
|
8
|
+
export const PUBLIC_AUTH_PATHS = ['/account/login', '/account/register']
|
|
9
|
+
|
|
10
|
+
export function AuthGuard({ children }: { children: ReactNode }) {
|
|
11
|
+
const { isAuthenticated, loading } = useAuth()
|
|
12
|
+
const router = useRouter()
|
|
13
|
+
const pathname = usePathname()
|
|
14
|
+
// A stored token is only known to be valid once its profile has loaded; redirecting before that would sign a
|
|
15
|
+
// reloading shopper out.
|
|
16
|
+
const [restored, setRestored] = useState(false)
|
|
17
|
+
|
|
18
|
+
const isPublicAuthPath = PUBLIC_AUTH_PATHS.includes(pathname)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let active = true
|
|
22
|
+
void restoreSession().finally(() => {
|
|
23
|
+
if (active) setRestored(true)
|
|
24
|
+
})
|
|
25
|
+
return () => {
|
|
26
|
+
active = false
|
|
27
|
+
}
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (restored && !loading && !isAuthenticated && !isPublicAuthPath) {
|
|
32
|
+
router.replace(`/account/login?redirect=${encodeURIComponent(pathname)}`)
|
|
33
|
+
}
|
|
34
|
+
}, [restored, loading, isAuthenticated, isPublicAuthPath, pathname, router])
|
|
35
|
+
|
|
36
|
+
if (isPublicAuthPath) {
|
|
37
|
+
return <>{children}</>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!restored || loading) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="loading-center">
|
|
43
|
+
<div className="spinner spinner-lg" role="status">
|
|
44
|
+
<span className="visually-hidden">Loading...</span>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!isAuthenticated) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return <>{children}</>
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { CartDrawer } from '@nymbalhq/react'
|
|
6
|
+
|
|
7
|
+
export function CartDrawerToggle() {
|
|
8
|
+
const [open, setOpen] = useState(false)
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
|
|
11
|
+
const handleCheckoutClick = useCallback(() => {
|
|
12
|
+
setOpen(false)
|
|
13
|
+
router.push('/checkout')
|
|
14
|
+
}, [router])
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
function handleOpen() {
|
|
18
|
+
setOpen(true)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function handleClose() {
|
|
22
|
+
setOpen(false)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
window.addEventListener('nymbal:cart:open', handleOpen)
|
|
26
|
+
window.addEventListener('nymbal:cart:close', handleClose)
|
|
27
|
+
return () => {
|
|
28
|
+
window.removeEventListener('nymbal:cart:open', handleOpen)
|
|
29
|
+
window.removeEventListener('nymbal:cart:close', handleClose)
|
|
30
|
+
}
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<CartDrawer
|
|
35
|
+
open={open}
|
|
36
|
+
onCheckoutClick={handleCheckoutClick}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import Image from 'next/image'
|
|
5
|
+
import { useCart, QuantitySelector } from '@nymbalhq/react'
|
|
6
|
+
import { formatPrice } from '@/lib/format'
|
|
7
|
+
import styles from '@/styles/pages/cart.module.css'
|
|
8
|
+
|
|
9
|
+
export function CartPage() {
|
|
10
|
+
const { items, subtotalMinor, currency, removeItem, updateQuantity, loading } = useCart()
|
|
11
|
+
|
|
12
|
+
if (loading) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="loading-center">
|
|
15
|
+
<div className="spinner spinner-lg" role="status">
|
|
16
|
+
<span className="visually-hidden">Loading cart...</span>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!items || items.length === 0) {
|
|
23
|
+
return (
|
|
24
|
+
<div className={`container ${styles.page}`}>
|
|
25
|
+
<h1 className={styles.pageTitle}>Shopping Cart</h1>
|
|
26
|
+
<div className={styles.empty}>
|
|
27
|
+
<h2 className={styles.emptyTitle}>Your cart is empty</h2>
|
|
28
|
+
<p className={styles.emptyText}>
|
|
29
|
+
Looks like you have not added anything to your cart yet.
|
|
30
|
+
</p>
|
|
31
|
+
<Link href="/products" className="btn btn-primary" data-testid="continue-shopping">
|
|
32
|
+
Continue Shopping
|
|
33
|
+
</Link>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cartCurrency = currency ?? 'GBP'
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className={`container ${styles.page}`}>
|
|
43
|
+
<h1 className={styles.pageTitle}>Shopping Cart</h1>
|
|
44
|
+
<div className={styles.layout}>
|
|
45
|
+
<div className={styles.itemsSection}>
|
|
46
|
+
<div className={styles.itemsList}>
|
|
47
|
+
{items.map((item) => (
|
|
48
|
+
<div key={item.variantId} className={styles.item} data-testid={`cart-item-${item.variantId}`}>
|
|
49
|
+
<div className={styles.itemImage}>
|
|
50
|
+
{item.imageUrl ? (
|
|
51
|
+
<Image
|
|
52
|
+
src={item.imageUrl}
|
|
53
|
+
alt={item.productName}
|
|
54
|
+
width={96}
|
|
55
|
+
height={96}
|
|
56
|
+
/>
|
|
57
|
+
) : (
|
|
58
|
+
<span className="visually-hidden">No image</span>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
<div className={styles.itemDetails}>
|
|
62
|
+
<Link href={`/product/${item.productId}`} className={styles.itemName}>
|
|
63
|
+
{item.productName}
|
|
64
|
+
</Link>
|
|
65
|
+
{item.variantName && (
|
|
66
|
+
<span className={styles.itemVariant}>{item.variantName}</span>
|
|
67
|
+
)}
|
|
68
|
+
<div className={styles.itemActions}>
|
|
69
|
+
<QuantitySelector
|
|
70
|
+
value={item.qty}
|
|
71
|
+
min={1}
|
|
72
|
+
max={99}
|
|
73
|
+
onQuantityChange={(e) => updateQuantity(item.variantId, e.detail.value)}
|
|
74
|
+
/>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
className={styles.removeButton}
|
|
78
|
+
onClick={() => removeItem(item.variantId)}
|
|
79
|
+
aria-label={`Remove ${item.productName} from cart`}
|
|
80
|
+
>
|
|
81
|
+
Remove
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
<span className={styles.itemPrice}>
|
|
86
|
+
{formatPrice(item.priceMinor * item.qty, cartCurrency)}
|
|
87
|
+
</span>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div className={styles.summary}>
|
|
94
|
+
<h2 className={styles.summaryTitle}>Order Summary</h2>
|
|
95
|
+
<div className={styles.summaryRow}>
|
|
96
|
+
<span className={styles.summaryLabel}>Subtotal</span>
|
|
97
|
+
<span className={styles.summaryValue}>
|
|
98
|
+
{formatPrice(subtotalMinor ?? 0, cartCurrency)}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
<div className={styles.summaryRow}>
|
|
102
|
+
<span className={styles.summaryLabel}>Shipping</span>
|
|
103
|
+
<span className={styles.summaryValue}>Calculated at checkout</span>
|
|
104
|
+
</div>
|
|
105
|
+
<hr className={styles.summaryDivider} />
|
|
106
|
+
<div className={`${styles.summaryRow} ${styles.summaryTotal}`}>
|
|
107
|
+
<span>Estimated Total</span>
|
|
108
|
+
<span>{formatPrice(subtotalMinor ?? 0, cartCurrency)}</span>
|
|
109
|
+
</div>
|
|
110
|
+
<Link href="/checkout" className={styles.checkoutButton}>
|
|
111
|
+
Proceed to Checkout
|
|
112
|
+
</Link>
|
|
113
|
+
<Link href="/products" className={styles.continueShoppingLink}>
|
|
114
|
+
Continue Shopping
|
|
115
|
+
</Link>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|