@forgecart/cli 1.1.0 → 2.0.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/bin/forgecart.d.ts +2 -0
- package/dist/bin/forgecart.js +4 -0
- package/dist/bin/forgecart.js.map +1 -0
- package/dist/src/cli.d.ts +14 -1
- package/dist/src/cli.js +30 -15
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/init.d.ts +20 -2
- package/dist/src/commands/init.js +88 -307
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/placeholders.d.ts +19 -0
- package/dist/src/commands/placeholders.js +109 -0
- package/dist/src/commands/placeholders.js.map +1 -0
- package/dist/src/output.d.ts +11 -0
- package/dist/src/output.js +16 -0
- package/dist/src/output.js.map +1 -0
- package/package.json +21 -11
- package/templates/storefront/.env.example +12 -0
- package/templates/storefront/.forgecartignore +2 -0
- package/templates/storefront/Procfile +1 -0
- package/templates/storefront/README.md +87 -0
- package/templates/storefront/next.config.js +14 -0
- package/templates/storefront/package.json +25 -0
- package/templates/storefront/postcss.config.js +6 -0
- package/templates/storefront/src/app/cart/page.tsx +110 -0
- package/templates/storefront/src/app/globals.css +13 -0
- package/templates/storefront/src/app/layout.tsx +30 -0
- package/templates/storefront/src/app/page.tsx +49 -0
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +20 -0
- package/templates/storefront/src/app/products/[slug]/page.tsx +96 -0
- package/templates/storefront/src/app/products/page.tsx +27 -0
- package/templates/storefront/src/components/AddToCartButton.tsx +30 -0
- package/templates/storefront/src/components/Header.tsx +36 -0
- package/templates/storefront/src/components/ProductCard.tsx +43 -0
- package/templates/storefront/src/lib/cart-context.tsx +116 -0
- package/templates/storefront/src/lib/forgecart.ts +202 -0
- package/{dist/templates/storefront-additions → templates/storefront}/tailwind.config.js +1 -1
- package/templates/storefront/tsconfig.json +23 -0
- package/LICENSE +0 -21
- package/README.md +0 -179
- package/bin/forgecart.js +0 -3
- package/dist/package.json +0 -50
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/commands/delete.d.ts +0 -3
- package/dist/src/commands/delete.d.ts.map +0 -1
- package/dist/src/commands/delete.js +0 -60
- package/dist/src/commands/delete.js.map +0 -1
- package/dist/src/commands/deploy.d.ts +0 -3
- package/dist/src/commands/deploy.d.ts.map +0 -1
- package/dist/src/commands/deploy.js +0 -243
- package/dist/src/commands/deploy.js.map +0 -1
- package/dist/src/commands/env.d.ts +0 -3
- package/dist/src/commands/env.d.ts.map +0 -1
- package/dist/src/commands/env.js +0 -158
- package/dist/src/commands/env.js.map +0 -1
- package/dist/src/commands/init.d.ts.map +0 -1
- package/dist/src/commands/list.d.ts +0 -3
- package/dist/src/commands/list.d.ts.map +0 -1
- package/dist/src/commands/list.js +0 -65
- package/dist/src/commands/list.js.map +0 -1
- package/dist/src/commands/login.d.ts +0 -3
- package/dist/src/commands/login.d.ts.map +0 -1
- package/dist/src/commands/login.js +0 -120
- package/dist/src/commands/login.js.map +0 -1
- package/dist/src/commands/logs.d.ts +0 -3
- package/dist/src/commands/logs.d.ts.map +0 -1
- package/dist/src/commands/logs.js +0 -88
- package/dist/src/commands/logs.js.map +0 -1
- package/dist/src/commands/scale.d.ts +0 -3
- package/dist/src/commands/scale.d.ts.map +0 -1
- package/dist/src/commands/scale.js +0 -49
- package/dist/src/commands/scale.js.map +0 -1
- package/dist/src/commands/status.d.ts +0 -3
- package/dist/src/commands/status.d.ts.map +0 -1
- package/dist/src/commands/status.js +0 -90
- package/dist/src/commands/status.js.map +0 -1
- package/dist/src/commands/whoami.d.ts +0 -3
- package/dist/src/commands/whoami.d.ts.map +0 -1
- package/dist/src/commands/whoami.js +0 -59
- package/dist/src/commands/whoami.js.map +0 -1
- package/dist/src/index.d.ts +0 -3
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js +0 -5
- package/dist/src/index.js.map +0 -1
- package/dist/src/lib/api-client.d.ts +0 -34
- package/dist/src/lib/api-client.d.ts.map +0 -1
- package/dist/src/lib/api-client.js +0 -141
- package/dist/src/lib/api-client.js.map +0 -1
- package/dist/src/lib/archive.d.ts +0 -9
- package/dist/src/lib/archive.d.ts.map +0 -1
- package/dist/src/lib/archive.js +0 -118
- package/dist/src/lib/archive.js.map +0 -1
- package/dist/src/lib/config-updater.d.ts +0 -10
- package/dist/src/lib/config-updater.d.ts.map +0 -1
- package/dist/src/lib/config-updater.js +0 -34
- package/dist/src/lib/config-updater.js.map +0 -1
- package/dist/src/lib/config.d.ts +0 -53
- package/dist/src/lib/config.d.ts.map +0 -1
- package/dist/src/lib/config.js +0 -162
- package/dist/src/lib/config.js.map +0 -1
- package/dist/src/lib/env-sync.d.ts +0 -44
- package/dist/src/lib/env-sync.d.ts.map +0 -1
- package/dist/src/lib/env-sync.js +0 -207
- package/dist/src/lib/env-sync.js.map +0 -1
- package/dist/src/lib/logger.d.ts +0 -15
- package/dist/src/lib/logger.d.ts.map +0 -1
- package/dist/src/lib/logger.js +0 -54
- package/dist/src/lib/logger.js.map +0 -1
- package/dist/src/lib/nx-generator.d.ts +0 -22
- package/dist/src/lib/nx-generator.d.ts.map +0 -1
- package/dist/src/lib/nx-generator.js +0 -345
- package/dist/src/lib/nx-generator.js.map +0 -1
- package/dist/src/lib/standalone.d.ts +0 -28
- package/dist/src/lib/standalone.d.ts.map +0 -1
- package/dist/src/lib/standalone.js +0 -107
- package/dist/src/lib/standalone.js.map +0 -1
- package/dist/src/types/index.d.ts +0 -80
- package/dist/src/types/index.d.ts.map +0 -1
- package/dist/src/types/index.js +0 -14
- package/dist/src/types/index.js.map +0 -1
- package/dist/src/utils/spinner.d.ts +0 -16
- package/dist/src/utils/spinner.d.ts.map +0 -1
- package/dist/src/utils/spinner.js +0 -88
- package/dist/src/utils/spinner.js.map +0 -1
- package/dist/src/utils/validation.d.ts +0 -12
- package/dist/src/utils/validation.d.ts.map +0 -1
- package/dist/src/utils/validation.js +0 -82
- package/dist/src/utils/validation.js.map +0 -1
- package/dist/templates/application-additions/.forgecartignore +0 -37
- package/dist/templates/application-additions/Procfile +0 -1
- package/dist/templates/application-additions/src/app/app.module.ts +0 -16
- package/dist/templates/application-additions/src/services/forgecart.service.ts +0 -62
- package/dist/templates/application-additions/src/services/index.ts +0 -2
- package/dist/templates/application-additions/src/services/product-event-listener.service.ts +0 -150
- package/dist/templates/application-e2e-additions/src/support/global-setup.ts +0 -16
- package/dist/templates/application-e2e-additions/src/support/test-setup.ts +0 -9
- package/dist/templates/storefront-additions/.forgecartignore +0 -64
- package/dist/templates/storefront-additions/Procfile +0 -1
- package/dist/templates/storefront-additions/package.json +0 -11
- package/dist/templates/storefront-additions/postcss.config.js +0 -5
- package/dist/templates/storefront-additions/src/app/cart/page.tsx +0 -77
- package/dist/templates/storefront-additions/src/app/global.css +0 -1
- package/dist/templates/storefront-additions/src/app/layout.tsx +0 -25
- package/dist/templates/storefront-additions/src/app/page.tsx +0 -43
- package/dist/templates/storefront-additions/src/components/AddToCartButton.tsx +0 -29
- package/dist/templates/storefront-additions/src/components/CartItem.tsx +0 -82
- package/dist/templates/storefront-additions/src/components/Header.tsx +0 -42
- package/dist/templates/storefront-additions/src/components/ProductCard.tsx +0 -57
- package/dist/templates/storefront-additions/src/components/QuantitySelector.tsx +0 -35
- package/dist/templates/storefront-additions/src/context/CartContext.tsx +0 -158
- package/dist/templates/storefront-additions/src/lib/forgecart.ts +0 -22
- package/dist/templates/storefront-e2e-additions/playwright.config.ts +0 -26
- package/dist/templates/storefront-e2e-additions/src/example.spec.ts +0 -11
- package/dist/templates/workspace/.nxignore +0 -2
- package/templates/application-additions/.forgecartignore +0 -37
- package/templates/application-additions/Procfile +0 -1
- package/templates/application-additions/src/app/app.module.ts +0 -16
- package/templates/application-additions/src/services/forgecart.service.ts +0 -62
- package/templates/application-additions/src/services/index.ts +0 -2
- package/templates/application-additions/src/services/product-event-listener.service.ts +0 -150
- package/templates/application-e2e-additions/src/support/global-setup.ts +0 -16
- package/templates/application-e2e-additions/src/support/test-setup.ts +0 -9
- package/templates/storefront-additions/.forgecartignore +0 -64
- package/templates/storefront-additions/Procfile +0 -1
- package/templates/storefront-additions/package.json +0 -11
- package/templates/storefront-additions/postcss.config.js +0 -5
- package/templates/storefront-additions/src/app/cart/page.tsx +0 -77
- package/templates/storefront-additions/src/app/global.css +0 -1
- package/templates/storefront-additions/src/app/layout.tsx +0 -25
- package/templates/storefront-additions/src/app/page.tsx +0 -43
- package/templates/storefront-additions/src/components/AddToCartButton.tsx +0 -29
- package/templates/storefront-additions/src/components/CartItem.tsx +0 -82
- package/templates/storefront-additions/src/components/Header.tsx +0 -42
- package/templates/storefront-additions/src/components/ProductCard.tsx +0 -57
- package/templates/storefront-additions/src/components/QuantitySelector.tsx +0 -35
- package/templates/storefront-additions/src/context/CartContext.tsx +0 -158
- package/templates/storefront-additions/src/lib/forgecart.ts +0 -22
- package/templates/storefront-additions/tailwind.config.js +0 -8
- package/templates/storefront-e2e-additions/playwright.config.ts +0 -26
- package/templates/storefront-e2e-additions/src/example.spec.ts +0 -11
- package/templates/workspace/.nxignore +0 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
|
|
4
|
+
import { AddToCartButton } from '../../../components/AddToCartButton';
|
|
5
|
+
import { formatPrice, getProductBySlug } from '../../../lib/forgecart';
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic';
|
|
8
|
+
|
|
9
|
+
// Next.js 15: route params are delivered as a Promise.
|
|
10
|
+
export default async function ProductDetailPage({
|
|
11
|
+
params,
|
|
12
|
+
}: {
|
|
13
|
+
params: Promise<{ slug: string }>;
|
|
14
|
+
}) {
|
|
15
|
+
const { slug } = await params;
|
|
16
|
+
const product = await getProductBySlug(slug);
|
|
17
|
+
|
|
18
|
+
if (!product) {
|
|
19
|
+
notFound();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaultVariant = product.variants[0] ?? null;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="space-y-6">
|
|
26
|
+
<Link href="/products" className="text-sm text-gray-600 hover:text-gray-900">
|
|
27
|
+
← Back to products
|
|
28
|
+
</Link>
|
|
29
|
+
|
|
30
|
+
<div className="grid gap-8 md:grid-cols-2">
|
|
31
|
+
<div className="aspect-square overflow-hidden rounded-lg border border-gray-200 bg-gray-100">
|
|
32
|
+
{product.featuredAsset ? (
|
|
33
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
34
|
+
<img
|
|
35
|
+
src={product.featuredAsset.preview}
|
|
36
|
+
alt={product.name}
|
|
37
|
+
className="h-full w-full object-cover"
|
|
38
|
+
/>
|
|
39
|
+
) : (
|
|
40
|
+
<div className="flex h-full w-full items-center justify-center text-sm text-gray-400">
|
|
41
|
+
No image
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div className="space-y-4">
|
|
47
|
+
<h1 className="text-2xl font-bold tracking-tight text-gray-900">
|
|
48
|
+
{product.name}
|
|
49
|
+
</h1>
|
|
50
|
+
|
|
51
|
+
{defaultVariant && (
|
|
52
|
+
<p className="text-xl font-semibold text-gray-900">
|
|
53
|
+
{formatPrice(defaultVariant.priceWithTax)}
|
|
54
|
+
</p>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{product.description && (
|
|
58
|
+
<div
|
|
59
|
+
className="prose prose-sm max-w-none text-gray-600"
|
|
60
|
+
// Product descriptions may contain rich text from the dashboard.
|
|
61
|
+
dangerouslySetInnerHTML={{ __html: product.description }}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
{defaultVariant ? (
|
|
66
|
+
<AddToCartButton
|
|
67
|
+
line={{
|
|
68
|
+
variantId: defaultVariant.id,
|
|
69
|
+
productSlug: product.slug,
|
|
70
|
+
name: product.name,
|
|
71
|
+
unitPrice: defaultVariant.priceWithTax,
|
|
72
|
+
imageUrl: product.featuredAsset?.preview ?? null,
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
) : (
|
|
76
|
+
<p className="text-sm text-gray-500">Currently unavailable.</p>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{product.variants.length > 1 && (
|
|
80
|
+
<div className="pt-2">
|
|
81
|
+
<h2 className="mb-2 text-sm font-medium text-gray-700">Variants</h2>
|
|
82
|
+
<ul className="space-y-1 text-sm text-gray-600">
|
|
83
|
+
{product.variants.map((variant) => (
|
|
84
|
+
<li key={variant.id} className="flex justify-between gap-4">
|
|
85
|
+
<span>{variant.name}</span>
|
|
86
|
+
<span>{formatPrice(variant.priceWithTax)}</span>
|
|
87
|
+
</li>
|
|
88
|
+
))}
|
|
89
|
+
</ul>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ProductCard } from '../../components/ProductCard';
|
|
2
|
+
import { getProducts } from '../../lib/forgecart';
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic';
|
|
5
|
+
|
|
6
|
+
export default async function ProductsPage() {
|
|
7
|
+
const { items, totalItems } = await getProducts({ take: 24 });
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="space-y-6">
|
|
11
|
+
<div className="flex items-baseline justify-between">
|
|
12
|
+
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Products</h1>
|
|
13
|
+
<span className="text-sm text-gray-500">{totalItems} items</span>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
{items.length === 0 ? (
|
|
17
|
+
<p className="text-gray-500">No products yet. Add some in your dashboard.</p>
|
|
18
|
+
) : (
|
|
19
|
+
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
|
20
|
+
{items.map((product) => (
|
|
21
|
+
<ProductCard key={product.id} product={product} />
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useCart, type CartLine } from '../lib/cart-context';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Adds a specific variant to the local cart. Rendered on the product detail
|
|
9
|
+
* page (a Server Component) with the variant's display data passed in as props.
|
|
10
|
+
*/
|
|
11
|
+
export function AddToCartButton({ line }: { line: Omit<CartLine, 'quantity'> }) {
|
|
12
|
+
const { addLine } = useCart();
|
|
13
|
+
const [added, setAdded] = useState(false);
|
|
14
|
+
|
|
15
|
+
const onClick = () => {
|
|
16
|
+
addLine(line, 1);
|
|
17
|
+
setAdded(true);
|
|
18
|
+
window.setTimeout(() => setAdded(false), 1500);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
onClick={onClick}
|
|
25
|
+
className="rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700"
|
|
26
|
+
>
|
|
27
|
+
{added ? 'Added!' : 'Add to cart'}
|
|
28
|
+
</button>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
|
|
5
|
+
import { useCart } from '../lib/cart-context';
|
|
6
|
+
|
|
7
|
+
/** Top navigation: Home / Products / Cart, with a live cart item count. */
|
|
8
|
+
export function Header() {
|
|
9
|
+
const { itemCount } = useCart();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<header className="border-b border-gray-200 bg-white">
|
|
13
|
+
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
|
14
|
+
<Link href="/" className="text-lg font-bold tracking-tight text-gray-900">
|
|
15
|
+
Storefront
|
|
16
|
+
</Link>
|
|
17
|
+
<nav className="flex items-center gap-6 text-sm font-medium text-gray-600">
|
|
18
|
+
<Link href="/" className="hover:text-gray-900">
|
|
19
|
+
Home
|
|
20
|
+
</Link>
|
|
21
|
+
<Link href="/products" className="hover:text-gray-900">
|
|
22
|
+
Products
|
|
23
|
+
</Link>
|
|
24
|
+
<Link href="/cart" className="relative hover:text-gray-900">
|
|
25
|
+
Cart
|
|
26
|
+
{itemCount > 0 && (
|
|
27
|
+
<span className="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-gray-900 px-1.5 text-xs font-semibold text-white">
|
|
28
|
+
{itemCount}
|
|
29
|
+
</span>
|
|
30
|
+
)}
|
|
31
|
+
</Link>
|
|
32
|
+
</nav>
|
|
33
|
+
</div>
|
|
34
|
+
</header>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatPrice,
|
|
5
|
+
getStartingPrice,
|
|
6
|
+
type StorefrontProduct,
|
|
7
|
+
} from '../lib/forgecart';
|
|
8
|
+
|
|
9
|
+
/** A product tile linking to its detail page. Used by the home and grid pages. */
|
|
10
|
+
export function ProductCard({ product }: { product: StorefrontProduct }) {
|
|
11
|
+
const startingPrice = getStartingPrice(product);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Link
|
|
15
|
+
href={`/products/${product.slug}`}
|
|
16
|
+
className="group flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white transition hover:shadow-md"
|
|
17
|
+
>
|
|
18
|
+
<div className="aspect-square overflow-hidden bg-gray-100">
|
|
19
|
+
{product.featuredAsset ? (
|
|
20
|
+
// Plain <img> keeps the template dependency-free of next/image config.
|
|
21
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
22
|
+
<img
|
|
23
|
+
src={product.featuredAsset.preview}
|
|
24
|
+
alt={product.name}
|
|
25
|
+
className="h-full w-full object-cover transition group-hover:scale-105"
|
|
26
|
+
/>
|
|
27
|
+
) : (
|
|
28
|
+
<div className="flex h-full w-full items-center justify-center text-sm text-gray-400">
|
|
29
|
+
No image
|
|
30
|
+
</div>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
<div className="flex flex-1 flex-col gap-1 p-4">
|
|
34
|
+
<h3 className="font-medium text-gray-900">{product.name}</h3>
|
|
35
|
+
{startingPrice !== null && (
|
|
36
|
+
<p className="text-sm text-gray-600">
|
|
37
|
+
From {formatPrice(startingPrice)}
|
|
38
|
+
</p>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
</Link>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from 'react';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A line in the local cart. The MVP cart lives entirely in the browser
|
|
15
|
+
* (localStorage); wiring it to the ForgeCart `cart` API is left as a follow-up.
|
|
16
|
+
* `unitPrice` is in minor currency units (cents).
|
|
17
|
+
*/
|
|
18
|
+
export interface CartLine {
|
|
19
|
+
variantId: string;
|
|
20
|
+
productSlug: string;
|
|
21
|
+
name: string;
|
|
22
|
+
unitPrice: number;
|
|
23
|
+
quantity: number;
|
|
24
|
+
imageUrl: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CartContextValue {
|
|
28
|
+
lines: CartLine[];
|
|
29
|
+
itemCount: number;
|
|
30
|
+
subtotal: number;
|
|
31
|
+
addLine: (line: Omit<CartLine, 'quantity'>, quantity?: number) => void;
|
|
32
|
+
setQuantity: (variantId: string, quantity: number) => void;
|
|
33
|
+
removeLine: (variantId: string) => void;
|
|
34
|
+
clear: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const STORAGE_KEY = 'forgecart.cart';
|
|
38
|
+
|
|
39
|
+
const CartContext = createContext<CartContextValue | null>(null);
|
|
40
|
+
|
|
41
|
+
function readStoredLines(): CartLine[] {
|
|
42
|
+
if (typeof window === 'undefined') {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
47
|
+
return raw ? (JSON.parse(raw) as CartLine[]) : [];
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function CartProvider({ children }: { children: ReactNode }) {
|
|
54
|
+
const [lines, setLines] = useState<CartLine[]>([]);
|
|
55
|
+
|
|
56
|
+
// Hydrate from localStorage once on mount (avoids SSR hydration mismatch).
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
setLines(readStoredLines());
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// Persist on every change.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (typeof window === 'undefined') {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(lines));
|
|
67
|
+
}, [lines]);
|
|
68
|
+
|
|
69
|
+
const addLine = useCallback(
|
|
70
|
+
(line: Omit<CartLine, 'quantity'>, quantity = 1) => {
|
|
71
|
+
setLines((prev) => {
|
|
72
|
+
const existing = prev.find((l) => l.variantId === line.variantId);
|
|
73
|
+
if (existing) {
|
|
74
|
+
return prev.map((l) =>
|
|
75
|
+
l.variantId === line.variantId
|
|
76
|
+
? { ...l, quantity: l.quantity + quantity }
|
|
77
|
+
: l,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return [...prev, { ...line, quantity }];
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
[],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const setQuantity = useCallback((variantId: string, quantity: number) => {
|
|
87
|
+
setLines((prev) =>
|
|
88
|
+
quantity <= 0
|
|
89
|
+
? prev.filter((l) => l.variantId !== variantId)
|
|
90
|
+
: prev.map((l) => (l.variantId === variantId ? { ...l, quantity } : l)),
|
|
91
|
+
);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const removeLine = useCallback((variantId: string) => {
|
|
95
|
+
setLines((prev) => prev.filter((l) => l.variantId !== variantId));
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const clear = useCallback(() => setLines([]), []);
|
|
99
|
+
|
|
100
|
+
const value = useMemo<CartContextValue>(() => {
|
|
101
|
+
const itemCount = lines.reduce((sum, l) => sum + l.quantity, 0);
|
|
102
|
+
const subtotal = lines.reduce((sum, l) => sum + l.unitPrice * l.quantity, 0);
|
|
103
|
+
return { lines, itemCount, subtotal, addLine, setQuantity, removeLine, clear };
|
|
104
|
+
}, [lines, addLine, setQuantity, removeLine, clear]);
|
|
105
|
+
|
|
106
|
+
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Access the cart. Must be used inside a `<CartProvider>`. */
|
|
110
|
+
export function useCart(): CartContextValue {
|
|
111
|
+
const ctx = useContext(CartContext);
|
|
112
|
+
if (!ctx) {
|
|
113
|
+
throw new Error('useCart must be used within a CartProvider');
|
|
114
|
+
}
|
|
115
|
+
return ctx;
|
|
116
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { ForgeCartShopClient } from '@forgecart/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server-side ForgeCart shop client.
|
|
5
|
+
*
|
|
6
|
+
* The storefront talks to the ForgeCart shop GraphQL API on behalf of a single
|
|
7
|
+
* channel. The channel token is sent as the `forgecart-token` header on every
|
|
8
|
+
* request (the SDK adds it from `channelToken`), and the endpoint points at the
|
|
9
|
+
* channel's shop-api.
|
|
10
|
+
*
|
|
11
|
+
* Both values come from the environment (`.env.local`, written by
|
|
12
|
+
* `forgecart init`):
|
|
13
|
+
* - FORGECART_SHOP_API_URL -> endpoint
|
|
14
|
+
* - FORGECART_CHANNEL_TOKEN -> channelToken
|
|
15
|
+
*
|
|
16
|
+
* This module is server-only: it is imported exclusively from Server
|
|
17
|
+
* Components, so the channel token never reaches the browser. The construction
|
|
18
|
+
* mirrors the dashboard's `SdkClientService` (see
|
|
19
|
+
* app/dashboard/src/service/sdk-client.service.ts), adapted to read from env.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
|
|
23
|
+
const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
24
|
+
|
|
25
|
+
let client: ForgeCartShopClient | null = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lazily construct and memoize the shop client.
|
|
29
|
+
*
|
|
30
|
+
* Constructing per-request would open a fresh websocket transport each time, so
|
|
31
|
+
* we keep a singleton for the lifetime of the server process.
|
|
32
|
+
*/
|
|
33
|
+
export function getShopClient(): ForgeCartShopClient {
|
|
34
|
+
if (!SHOP_API_URL) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'FORGECART_SHOP_API_URL is not set. Add it to .env.local (forgecart init writes it for you).',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (!CHANNEL_TOKEN) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'FORGECART_CHANNEL_TOKEN is not set. Add it to .env.local (forgecart init writes it for you).',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (!client) {
|
|
45
|
+
client = new ForgeCartShopClient({
|
|
46
|
+
endpoint: SHOP_API_URL,
|
|
47
|
+
channelToken: CHANNEL_TOKEN,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return client;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Convenience singleton for direct use in Server Components. */
|
|
54
|
+
export const shopClient = (): ForgeCartShopClient => getShopClient();
|
|
55
|
+
|
|
56
|
+
/* ------------------------------------------------------------------ *
|
|
57
|
+
* Typed product helpers
|
|
58
|
+
*
|
|
59
|
+
* The generated typed operations (`shopClient.product.shopProducts` /
|
|
60
|
+
* `.shopProduct`) only select `id` + `optionGroups`, which is not enough to
|
|
61
|
+
* render a storefront. We therefore use the SDK's supported raw-query escape
|
|
62
|
+
* hatch (`ForgeCartShopClient.query<T>(document, variables)`) with documents
|
|
63
|
+
* that select the display fields. Every field below exists on the shop schema's
|
|
64
|
+
* `Product` / `ProductVariant` / `Asset` / `ProductList` types (available via
|
|
65
|
+
* `@forgecart/sdk/shop`).
|
|
66
|
+
* ------------------------------------------------------------------ */
|
|
67
|
+
|
|
68
|
+
/** A single purchasable variant of a product. Prices are minor units (cents). */
|
|
69
|
+
export interface StorefrontVariant {
|
|
70
|
+
id: string;
|
|
71
|
+
name: string;
|
|
72
|
+
sku: string | null;
|
|
73
|
+
/** Price in minor currency units (e.g. cents). */
|
|
74
|
+
price: number;
|
|
75
|
+
/** Price including tax, in minor currency units. */
|
|
76
|
+
priceWithTax: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A product as the storefront renders it. */
|
|
80
|
+
export interface StorefrontProduct {
|
|
81
|
+
id: string;
|
|
82
|
+
name: string;
|
|
83
|
+
slug: string;
|
|
84
|
+
description: string | null;
|
|
85
|
+
/** Preview URL of the featured image, if any. */
|
|
86
|
+
featuredAsset: { id: string; preview: string } | null;
|
|
87
|
+
variants: StorefrontVariant[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A page of products plus the total count for pagination. */
|
|
91
|
+
export interface StorefrontProductList {
|
|
92
|
+
items: StorefrontProduct[];
|
|
93
|
+
totalItems: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const PRODUCT_FIELDS = `
|
|
97
|
+
id
|
|
98
|
+
name
|
|
99
|
+
slug
|
|
100
|
+
description
|
|
101
|
+
featuredAsset {
|
|
102
|
+
id
|
|
103
|
+
preview
|
|
104
|
+
}
|
|
105
|
+
variants {
|
|
106
|
+
id
|
|
107
|
+
name
|
|
108
|
+
sku
|
|
109
|
+
price
|
|
110
|
+
priceWithTax
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const PRODUCTS_QUERY = `query StorefrontProducts($options: ListQueryOptions) {
|
|
115
|
+
products(options: $options) {
|
|
116
|
+
items {${PRODUCT_FIELDS}}
|
|
117
|
+
totalItems
|
|
118
|
+
}
|
|
119
|
+
}`;
|
|
120
|
+
|
|
121
|
+
const PRODUCT_BY_SLUG_QUERY = `query StorefrontProduct($slug: String) {
|
|
122
|
+
product(slug: $slug) {${PRODUCT_FIELDS}}
|
|
123
|
+
}`;
|
|
124
|
+
|
|
125
|
+
const PRODUCT_BY_ID_QUERY = `query StorefrontProductById($id: ID) {
|
|
126
|
+
product(id: $id) {${PRODUCT_FIELDS}}
|
|
127
|
+
}`;
|
|
128
|
+
|
|
129
|
+
interface ProductsQueryResult {
|
|
130
|
+
products: {
|
|
131
|
+
items: StorefrontProduct[];
|
|
132
|
+
totalItems: number;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface ProductQueryResult {
|
|
137
|
+
product: StorefrontProduct | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Fetch a page of products for the channel.
|
|
142
|
+
*
|
|
143
|
+
* `take` / `skip` map onto the shop API's `ListQueryOptions`.
|
|
144
|
+
*/
|
|
145
|
+
export async function getProducts(
|
|
146
|
+
options: { take?: number; skip?: number } = {},
|
|
147
|
+
): Promise<StorefrontProductList> {
|
|
148
|
+
const { take = 24, skip = 0 } = options;
|
|
149
|
+
const result = await getShopClient().query<ProductsQueryResult>(PRODUCTS_QUERY, {
|
|
150
|
+
options: { take, skip },
|
|
151
|
+
});
|
|
152
|
+
return {
|
|
153
|
+
items: result.products.items,
|
|
154
|
+
totalItems: result.products.totalItems,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Fetch a single product by its URL slug. Returns `null` if not found. */
|
|
159
|
+
export async function getProductBySlug(slug: string): Promise<StorefrontProduct | null> {
|
|
160
|
+
const result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_SLUG_QUERY, {
|
|
161
|
+
slug,
|
|
162
|
+
});
|
|
163
|
+
return result.product ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Fetch a single product by id. Returns `null` if not found. */
|
|
167
|
+
export async function getProductById(id: string): Promise<StorefrontProduct | null> {
|
|
168
|
+
const result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_ID_QUERY, {
|
|
169
|
+
id,
|
|
170
|
+
});
|
|
171
|
+
return result.product ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Convenience: fetch a small set of products to feature on the home page.
|
|
176
|
+
*/
|
|
177
|
+
export async function getFeaturedProducts(count = 4): Promise<StorefrontProduct[]> {
|
|
178
|
+
const { items } = await getProducts({ take: count });
|
|
179
|
+
return items;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Lowest variant price for a product, in minor units, or `null` if none. */
|
|
183
|
+
export function getStartingPrice(product: StorefrontProduct): number | null {
|
|
184
|
+
if (product.variants.length === 0) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return product.variants.reduce(
|
|
188
|
+
(min, v) => (v.priceWithTax < min ? v.priceWithTax : min),
|
|
189
|
+
product.variants[0].priceWithTax,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Format a minor-unit price (cents) as a currency string. Defaults to USD;
|
|
195
|
+
* pass a different ISO currency code as needed.
|
|
196
|
+
*/
|
|
197
|
+
export function formatPrice(minorUnits: number, currency = 'USD'): string {
|
|
198
|
+
return new Intl.NumberFormat('en-US', {
|
|
199
|
+
style: 'currency',
|
|
200
|
+
currency,
|
|
201
|
+
}).format(minorUnits / 100);
|
|
202
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "ES2022"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": {
|
|
18
|
+
"@/*": ["./src/*"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
22
|
+
"exclude": ["node_modules"]
|
|
23
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2022 Vendure
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|