@forgecart/cli 2.0.0 → 2.1.1
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/src/cli.d.ts +5 -2
- package/dist/src/cli.js +26 -2
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/init.d.ts +22 -3
- package/dist/src/commands/init.js +100 -9
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/src/app/cart/page.tsx +21 -43
- package/templates/storefront/src/app/layout.tsx +4 -2
- package/templates/storefront/src/app/products/[slug]/page.tsx +5 -40
- package/templates/storefront/src/components/Header.tsx +12 -1
- package/templates/storefront/src/components/ProductCard.tsx +3 -6
- package/templates/storefront/src/components/ProductPurchase.tsx +91 -0
- package/templates/storefront/src/lib/cart-actions.ts +148 -0
- package/templates/storefront/src/lib/cart-context.tsx +53 -81
- package/templates/storefront/src/lib/forgecart.ts +26 -41
- package/templates/storefront/src/components/AddToCartButton.tsx +0 -30
package/dist/src/cli.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* CLI version, read from `package.json` (the single source of truth) so the
|
|
4
|
+
* `--version` output can never drift from the published version.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CLI_VERSION: string;
|
|
4
7
|
/**
|
|
5
8
|
* Build the `forgecart` commander program with the full command surface.
|
|
6
9
|
*
|
package/dist/src/cli.js
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
1
4
|
import { Command } from 'commander';
|
|
2
5
|
import { createInitCommand } from './commands/init.js';
|
|
3
6
|
import { createDeleteCommand, createDeployCommand, createEnvCommand, createInitApplicationCommand, createListCommand, createLogsCommand, createScaleCommand, createStatusCommand, createWhoamiCommand, } from './commands/placeholders.js';
|
|
4
7
|
import { printError } from './output.js';
|
|
5
|
-
/**
|
|
6
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the package manifest from this module's location. The depth differs
|
|
10
|
+
* by layout — `src/cli.ts` (dev) sits one level below the package root,
|
|
11
|
+
* `dist/src/cli.js` (published) two — and `dist/` never contains a
|
|
12
|
+
* `package.json`, so the first existing candidate is always the right one.
|
|
13
|
+
*/
|
|
14
|
+
function readPackageVersion() {
|
|
15
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const candidates = [
|
|
17
|
+
path.join(here, '..', 'package.json'),
|
|
18
|
+
path.join(here, '..', '..', 'package.json'),
|
|
19
|
+
];
|
|
20
|
+
const manifest = candidates.find((candidate) => fs.existsSync(candidate));
|
|
21
|
+
if (!manifest) {
|
|
22
|
+
throw new Error(`package.json not found near ${here}; cannot resolve the CLI version.`);
|
|
23
|
+
}
|
|
24
|
+
return JSON.parse(fs.readFileSync(manifest, 'utf-8')).version;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* CLI version, read from `package.json` (the single source of truth) so the
|
|
28
|
+
* `--version` output can never drift from the published version.
|
|
29
|
+
*/
|
|
30
|
+
export const CLI_VERSION = readPackageVersion();
|
|
7
31
|
/**
|
|
8
32
|
* Build the `forgecart` commander program with the full command surface.
|
|
9
33
|
*
|
package/dist/src/cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../../src/cli.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,4BAA4B,EAC5B,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,mBAAmB,GACpB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../../src/cli.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
/** Default shop API base. Overridable via `--api-url`. */
|
|
3
3
|
export declare const DEFAULT_SHOP_API_URL = "https://api.forgecart.com";
|
|
4
|
+
/**
|
|
5
|
+
* Environment variable naming a dependency-warmed storefront template — a copy
|
|
6
|
+
* of the bundled template with `node_modules` (and the generated
|
|
7
|
+
* `package-lock.json`) already installed. The workspace-pod image bakes one at
|
|
8
|
+
* build time (`docker/workspace-pod/Dockerfile`) and exports this variable so a
|
|
9
|
+
* pod scaffold copies warm dependencies instead of paying a cold `npm install`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const WORKSPACE_TEMPLATE_DIR_ENV = "WORKSPACE_TEMPLATE_DIR";
|
|
12
|
+
/**
|
|
13
|
+
* Marker file, relative to the scaffolded storefront root, recording that
|
|
14
|
+
* `node_modules` was copied fully installed from a warmed template whose
|
|
15
|
+
* manifest matches this CLI's bundled template. Orchestrators (the agent's
|
|
16
|
+
* `ChannelWorkspaceService`) skip their `npm install` step when the marker is
|
|
17
|
+
* present right after a fresh scaffold; its absence always falls back to a
|
|
18
|
+
* regular install, so older images and cold scaffolds keep working unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEPS_READY_MARKER_PATH = ".forgecart/deps-ready.json";
|
|
4
21
|
/** Resolved options for `forgecart init`. */
|
|
5
22
|
export interface InitOptions {
|
|
6
23
|
/** Channel token (required). */
|
|
@@ -13,9 +30,11 @@ export interface InitOptions {
|
|
|
13
30
|
/**
|
|
14
31
|
* Run `forgecart init`.
|
|
15
32
|
*
|
|
16
|
-
* Resolves the target directory,
|
|
17
|
-
*
|
|
18
|
-
*
|
|
33
|
+
* Resolves the target directory, scaffolds the storefront from the warmed
|
|
34
|
+
* template when one is available (see {@link resolveScaffoldPlan}) or the
|
|
35
|
+
* bundled template otherwise, writes the ForgeCart config and `.env.local`, and
|
|
36
|
+
* prints next steps. Throws a clear Error on any failure (missing token,
|
|
37
|
+
* missing template, copy failure).
|
|
19
38
|
*/
|
|
20
39
|
export declare function runInit(name: string | undefined, options: InitOptions): Promise<void>;
|
|
21
40
|
/** Build the `init [name]` command. */
|
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
import { mkdir, cp, writeFile, stat } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, cp, readFile, rm, writeFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
5
|
import { print } from '../output.js';
|
|
6
6
|
/** Default shop API base. Overridable via `--api-url`. */
|
|
7
7
|
export const DEFAULT_SHOP_API_URL = 'https://api.forgecart.com';
|
|
8
|
+
/**
|
|
9
|
+
* Environment variable naming a dependency-warmed storefront template — a copy
|
|
10
|
+
* of the bundled template with `node_modules` (and the generated
|
|
11
|
+
* `package-lock.json`) already installed. The workspace-pod image bakes one at
|
|
12
|
+
* build time (`docker/workspace-pod/Dockerfile`) and exports this variable so a
|
|
13
|
+
* pod scaffold copies warm dependencies instead of paying a cold `npm install`.
|
|
14
|
+
*/
|
|
15
|
+
export const WORKSPACE_TEMPLATE_DIR_ENV = 'WORKSPACE_TEMPLATE_DIR';
|
|
16
|
+
/**
|
|
17
|
+
* Marker file, relative to the scaffolded storefront root, recording that
|
|
18
|
+
* `node_modules` was copied fully installed from a warmed template whose
|
|
19
|
+
* manifest matches this CLI's bundled template. Orchestrators (the agent's
|
|
20
|
+
* `ChannelWorkspaceService`) skip their `npm install` step when the marker is
|
|
21
|
+
* present right after a fresh scaffold; its absence always falls back to a
|
|
22
|
+
* regular install, so older images and cold scaffolds keep working unchanged.
|
|
23
|
+
*/
|
|
24
|
+
export const DEPS_READY_MARKER_PATH = '.forgecart/deps-ready.json';
|
|
8
25
|
/**
|
|
9
26
|
* Absolute path to the bundled storefront scaffold.
|
|
10
27
|
*
|
|
@@ -25,6 +42,67 @@ async function isDirectory(path) {
|
|
|
25
42
|
return false;
|
|
26
43
|
}
|
|
27
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Read a `package.json` and normalize it for content comparison. Returns null
|
|
47
|
+
* when the file is missing or not valid JSON — callers treat that as "manifests
|
|
48
|
+
* do not provably match", which always falls back to an install.
|
|
49
|
+
*/
|
|
50
|
+
async function readNormalizedManifest(manifestPath) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.stringify(JSON.parse(await readFile(manifestPath, 'utf8')));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Decide how to source the scaffold. The warmed template is used only when
|
|
60
|
+
* {@link WORKSPACE_TEMPLATE_DIR_ENV} points at a directory that actually
|
|
61
|
+
* contains an installed `node_modules`; full reuse (skipping the install)
|
|
62
|
+
* additionally requires its manifest to match the bundled template's, byte for
|
|
63
|
+
* semantic byte. Every failure mode degrades to the cold bundled path, so
|
|
64
|
+
* images without a warmed template — and machines where the variable is unset —
|
|
65
|
+
* behave exactly as before.
|
|
66
|
+
*/
|
|
67
|
+
async function resolveScaffoldPlan(bundledTemplateDir) {
|
|
68
|
+
const bundled = { source: 'bundled', templateDir: bundledTemplateDir };
|
|
69
|
+
const warmedDir = process.env[WORKSPACE_TEMPLATE_DIR_ENV];
|
|
70
|
+
if (!warmedDir)
|
|
71
|
+
return bundled;
|
|
72
|
+
if (!(await isDirectory(warmedDir)))
|
|
73
|
+
return bundled;
|
|
74
|
+
const warmedNodeModulesDir = join(warmedDir, 'node_modules');
|
|
75
|
+
if (!(await isDirectory(warmedNodeModulesDir)))
|
|
76
|
+
return bundled;
|
|
77
|
+
const warmedManifest = await readNormalizedManifest(join(warmedDir, 'package.json'));
|
|
78
|
+
const bundledManifest = await readNormalizedManifest(join(bundledTemplateDir, 'package.json'));
|
|
79
|
+
if (!warmedManifest || !bundledManifest || warmedManifest !== bundledManifest) {
|
|
80
|
+
return {
|
|
81
|
+
source: 'bundled-with-warmed-modules',
|
|
82
|
+
templateDir: bundledTemplateDir,
|
|
83
|
+
warmedNodeModulesDir,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { source: 'warmed', templateDir: warmedDir };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Record — or clear — the dependencies-ready marker. It is written only for a
|
|
90
|
+
* full warmed scaffold; any other plan removes a marker a previous scaffold may
|
|
91
|
+
* have left in the same directory, so an orchestrator never skips its install
|
|
92
|
+
* based on stale state.
|
|
93
|
+
*/
|
|
94
|
+
async function writeDepsReadyMarker(targetDir, plan) {
|
|
95
|
+
const markerPath = join(targetDir, DEPS_READY_MARKER_PATH);
|
|
96
|
+
if (plan.source !== 'warmed') {
|
|
97
|
+
await rm(markerPath, { force: true });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const marker = {
|
|
101
|
+
warmedFrom: plan.templateDir,
|
|
102
|
+
warmedAt: new Date().toISOString(),
|
|
103
|
+
};
|
|
104
|
+
await writeFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
|
|
105
|
+
}
|
|
28
106
|
/** Build the `.forgecart/config.json` payload. */
|
|
29
107
|
function buildConfig(channelToken, options) {
|
|
30
108
|
return {
|
|
@@ -44,13 +122,17 @@ function buildEnvLocal(channelToken, apiUrl) {
|
|
|
44
122
|
].join('\n');
|
|
45
123
|
}
|
|
46
124
|
/** Print the post-scaffold next steps. */
|
|
47
|
-
function printNextSteps(targetDir, application) {
|
|
125
|
+
function printNextSteps(targetDir, application, depsInstalled) {
|
|
48
126
|
print('');
|
|
49
|
-
print(
|
|
127
|
+
print(depsInstalled
|
|
128
|
+
? 'Storefront scaffolded successfully (dependencies pre-installed from the warmed template).'
|
|
129
|
+
: 'Storefront scaffolded successfully.');
|
|
50
130
|
print('');
|
|
51
131
|
print('Next steps:');
|
|
52
132
|
print(` cd ${targetDir}`);
|
|
53
|
-
|
|
133
|
+
if (!depsInstalled) {
|
|
134
|
+
print(' npm install');
|
|
135
|
+
}
|
|
54
136
|
print(' npm run dev');
|
|
55
137
|
print('');
|
|
56
138
|
if (application) {
|
|
@@ -62,9 +144,11 @@ function printNextSteps(targetDir, application) {
|
|
|
62
144
|
/**
|
|
63
145
|
* Run `forgecart init`.
|
|
64
146
|
*
|
|
65
|
-
* Resolves the target directory,
|
|
66
|
-
*
|
|
67
|
-
*
|
|
147
|
+
* Resolves the target directory, scaffolds the storefront from the warmed
|
|
148
|
+
* template when one is available (see {@link resolveScaffoldPlan}) or the
|
|
149
|
+
* bundled template otherwise, writes the ForgeCart config and `.env.local`, and
|
|
150
|
+
* prints next steps. Throws a clear Error on any failure (missing token,
|
|
151
|
+
* missing template, copy failure).
|
|
68
152
|
*/
|
|
69
153
|
export async function runInit(name, options) {
|
|
70
154
|
const channelToken = options.token?.trim();
|
|
@@ -76,14 +160,21 @@ export async function runInit(name, options) {
|
|
|
76
160
|
if (!(await isDirectory(templateDir))) {
|
|
77
161
|
throw new Error(`Storefront template not found at ${templateDir}.`);
|
|
78
162
|
}
|
|
163
|
+
const plan = await resolveScaffoldPlan(templateDir);
|
|
79
164
|
await mkdir(targetDir, { recursive: true });
|
|
80
|
-
await cp(templateDir, targetDir, { recursive: true });
|
|
165
|
+
await cp(plan.templateDir, targetDir, { recursive: true });
|
|
166
|
+
if (plan.source === 'bundled-with-warmed-modules') {
|
|
167
|
+
await cp(plan.warmedNodeModulesDir, join(targetDir, 'node_modules'), {
|
|
168
|
+
recursive: true,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
81
171
|
const configDir = join(targetDir, '.forgecart');
|
|
82
172
|
await mkdir(configDir, { recursive: true });
|
|
83
173
|
const config = buildConfig(channelToken, options);
|
|
84
174
|
await writeFile(join(configDir, 'config.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
85
175
|
await writeFile(join(targetDir, '.env.local'), buildEnvLocal(channelToken, options.apiUrl), 'utf8');
|
|
86
|
-
|
|
176
|
+
await writeDepsReadyMarker(targetDir, plan);
|
|
177
|
+
printNextSteps(name ?? '.', config.hasApplication, plan.source === 'warmed');
|
|
87
178
|
}
|
|
88
179
|
/** Build the `init [name]` command. */
|
|
89
180
|
export function createInitCommand() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/commands/init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/commands/init.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from 'next/link';
|
|
4
4
|
|
|
5
|
-
import { formatPrice } from '../../lib/forgecart';
|
|
6
5
|
import { useCart } from '../../lib/cart-context';
|
|
6
|
+
import { formatPrice } from '../../lib/forgecart';
|
|
7
7
|
|
|
8
8
|
export default function CartPage() {
|
|
9
|
-
const {
|
|
9
|
+
const { cart, itemCount, subtotal, setQuantity, remove, pending } = useCart();
|
|
10
|
+
const lines = cart?.lines ?? [];
|
|
10
11
|
|
|
11
12
|
if (itemCount === 0) {
|
|
12
13
|
return (
|
|
@@ -25,66 +26,52 @@ export default function CartPage() {
|
|
|
25
26
|
|
|
26
27
|
return (
|
|
27
28
|
<div className="space-y-6">
|
|
28
|
-
<
|
|
29
|
-
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
|
|
30
|
-
<button
|
|
31
|
-
type="button"
|
|
32
|
-
onClick={clear}
|
|
33
|
-
className="text-sm text-gray-500 hover:text-gray-900"
|
|
34
|
-
>
|
|
35
|
-
Clear cart
|
|
36
|
-
</button>
|
|
37
|
-
</div>
|
|
29
|
+
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
|
|
38
30
|
|
|
39
31
|
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
|
40
32
|
{lines.map((line) => (
|
|
41
|
-
<li key={line.
|
|
33
|
+
<li key={line.id} className="flex items-center gap-4 p-4">
|
|
42
34
|
<div className="h-16 w-16 shrink-0 overflow-hidden rounded bg-gray-100">
|
|
43
|
-
{line.
|
|
35
|
+
{line.featuredAsset?.preview ? (
|
|
44
36
|
// eslint-disable-next-line @next/next/no-img-element
|
|
45
37
|
<img
|
|
46
|
-
src={line.
|
|
47
|
-
alt={line.name}
|
|
38
|
+
src={line.featuredAsset.preview}
|
|
39
|
+
alt={line.productVariant.name}
|
|
48
40
|
className="h-full w-full object-cover"
|
|
49
41
|
/>
|
|
50
42
|
) : null}
|
|
51
43
|
</div>
|
|
52
44
|
|
|
53
45
|
<div className="min-w-0 flex-1">
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
className="truncate font-medium text-gray-900 hover:underline"
|
|
57
|
-
>
|
|
58
|
-
{line.name}
|
|
59
|
-
</Link>
|
|
60
|
-
<p className="text-sm text-gray-500">{formatPrice(line.unitPrice)} each</p>
|
|
46
|
+
<p className="truncate font-medium text-gray-900">{line.productVariant.name}</p>
|
|
47
|
+
<p className="text-sm text-gray-500">{formatPrice(line.unitPriceWithTax)} each</p>
|
|
61
48
|
</div>
|
|
62
49
|
|
|
63
50
|
<div className="flex items-center gap-2">
|
|
64
|
-
<label className="sr-only" htmlFor={`qty-${line.
|
|
51
|
+
<label className="sr-only" htmlFor={`qty-${line.id}`}>
|
|
65
52
|
Quantity
|
|
66
53
|
</label>
|
|
67
54
|
<input
|
|
68
|
-
id={`qty-${line.
|
|
55
|
+
id={`qty-${line.id}`}
|
|
69
56
|
type="number"
|
|
70
57
|
min={1}
|
|
71
58
|
value={line.quantity}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
className="w-16 rounded border border-gray-300 px-2 py-1 text-sm"
|
|
59
|
+
disabled={pending}
|
|
60
|
+
onChange={(e) => setQuantity(line.id, Number.parseInt(e.target.value, 10) || 1)}
|
|
61
|
+
className="w-16 rounded border border-gray-300 px-2 py-1 text-sm disabled:opacity-50"
|
|
76
62
|
/>
|
|
77
63
|
</div>
|
|
78
64
|
|
|
79
65
|
<div className="w-24 text-right font-medium text-gray-900">
|
|
80
|
-
{formatPrice(line.
|
|
66
|
+
{formatPrice(line.linePriceWithTax)}
|
|
81
67
|
</div>
|
|
82
68
|
|
|
83
69
|
<button
|
|
84
70
|
type="button"
|
|
85
|
-
onClick={() =>
|
|
86
|
-
|
|
87
|
-
|
|
71
|
+
onClick={() => remove(line.id)}
|
|
72
|
+
disabled={pending}
|
|
73
|
+
className="text-sm text-gray-400 hover:text-red-600 disabled:opacity-50"
|
|
74
|
+
aria-label={`Remove ${line.productVariant.name}`}
|
|
88
75
|
>
|
|
89
76
|
Remove
|
|
90
77
|
</button>
|
|
@@ -94,17 +81,8 @@ export default function CartPage() {
|
|
|
94
81
|
|
|
95
82
|
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
|
|
96
83
|
<span className="text-sm text-gray-600">Subtotal ({itemCount} items)</span>
|
|
97
|
-
<span className="text-lg font-semibold text-gray-900">
|
|
98
|
-
{formatPrice(subtotal)}
|
|
99
|
-
</span>
|
|
84
|
+
<span className="text-lg font-semibold text-gray-900">{formatPrice(subtotal)}</span>
|
|
100
85
|
</div>
|
|
101
|
-
|
|
102
|
-
<p className="text-sm text-gray-500">
|
|
103
|
-
This is a starter cart with local state. Wire it to the ForgeCart{' '}
|
|
104
|
-
<code className="rounded bg-gray-100 px-1">cart</code> /{' '}
|
|
105
|
-
<code className="rounded bg-gray-100 px-1">checkout</code> APIs to take real
|
|
106
|
-
orders.
|
|
107
|
-
</p>
|
|
108
86
|
</div>
|
|
109
87
|
);
|
|
110
88
|
}
|
|
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
|
|
4
4
|
import { Header } from '../components/Header';
|
|
5
|
+
import { getCart } from '../lib/cart-actions';
|
|
5
6
|
import { CartProvider } from '../lib/cart-context';
|
|
6
7
|
|
|
7
8
|
import './globals.css';
|
|
@@ -11,11 +12,12 @@ export const metadata: Metadata = {
|
|
|
11
12
|
description: 'A ForgeCart channel storefront.',
|
|
12
13
|
};
|
|
13
14
|
|
|
14
|
-
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
15
|
+
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
16
|
+
const initialCart = await getCart();
|
|
15
17
|
return (
|
|
16
18
|
<html lang="en">
|
|
17
19
|
<body className="min-h-screen bg-gray-50 text-gray-900 antialiased">
|
|
18
|
-
<CartProvider>
|
|
20
|
+
<CartProvider initialCart={initialCart}>
|
|
19
21
|
<Header />
|
|
20
22
|
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
|
|
21
23
|
<footer className="border-t border-gray-200 bg-white">
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import Link from 'next/link';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { ProductPurchase } from '../../../components/ProductPurchase';
|
|
5
|
+
import { getProductBySlug } from '../../../lib/forgecart';
|
|
6
6
|
|
|
7
7
|
export const dynamic = 'force-dynamic';
|
|
8
8
|
|
|
@@ -19,8 +19,6 @@ export default async function ProductDetailPage({
|
|
|
19
19
|
notFound();
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const defaultVariant = product.variants[0] ?? null;
|
|
23
|
-
|
|
24
22
|
return (
|
|
25
23
|
<div className="space-y-6">
|
|
26
24
|
<Link href="/products" className="text-sm text-gray-600 hover:text-gray-900">
|
|
@@ -44,15 +42,7 @@ export default async function ProductDetailPage({
|
|
|
44
42
|
</div>
|
|
45
43
|
|
|
46
44
|
<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
|
-
)}
|
|
45
|
+
<h1 className="text-2xl font-bold tracking-tight text-gray-900">{product.name}</h1>
|
|
56
46
|
|
|
57
47
|
{product.description && (
|
|
58
48
|
<div
|
|
@@ -62,33 +52,8 @@ export default async function ProductDetailPage({
|
|
|
62
52
|
/>
|
|
63
53
|
)}
|
|
64
54
|
|
|
65
|
-
{
|
|
66
|
-
|
|
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
|
-
)}
|
|
55
|
+
{/* Pick a variant (Size/Color) -> price + add-to-cart. */}
|
|
56
|
+
<ProductPurchase product={product} />
|
|
92
57
|
</div>
|
|
93
58
|
</div>
|
|
94
59
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import Link from 'next/link';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
4
5
|
|
|
5
6
|
import { useCart } from '../lib/cart-context';
|
|
6
7
|
|
|
@@ -8,6 +9,16 @@ import { useCart } from '../lib/cart-context';
|
|
|
8
9
|
export function Header() {
|
|
9
10
|
const { itemCount } = useCart();
|
|
10
11
|
|
|
12
|
+
// The count is seeded from the server's `initialCart`, but a cookie set after
|
|
13
|
+
// that render can leave the client with a different count — rendering the
|
|
14
|
+
// badge on the server would then hydration-mismatch. Gate it on a
|
|
15
|
+
// post-hydration mount flag so the server emits no badge; it appears with the
|
|
16
|
+
// live count only once the client takes over.
|
|
17
|
+
const [mounted, setMounted] = useState(false);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setMounted(true);
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
11
22
|
return (
|
|
12
23
|
<header className="border-b border-gray-200 bg-white">
|
|
13
24
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
|
@@ -23,7 +34,7 @@ export function Header() {
|
|
|
23
34
|
</Link>
|
|
24
35
|
<Link href="/cart" className="relative hover:text-gray-900">
|
|
25
36
|
Cart
|
|
26
|
-
{itemCount > 0 && (
|
|
37
|
+
{mounted && itemCount > 0 && (
|
|
27
38
|
<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
39
|
{itemCount}
|
|
29
40
|
</span>
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
+
import type { Product } from '@forgecart/sdk/shop';
|
|
1
2
|
import Link from 'next/link';
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
-
formatPrice,
|
|
5
|
-
getStartingPrice,
|
|
6
|
-
type StorefrontProduct,
|
|
7
|
-
} from '../lib/forgecart';
|
|
4
|
+
import { formatPrice, getStartingPrice } from '../lib/forgecart';
|
|
8
5
|
|
|
9
6
|
/** A product tile linking to its detail page. Used by the home and grid pages. */
|
|
10
|
-
export function ProductCard({ product }: { product:
|
|
7
|
+
export function ProductCard({ product }: { product: Product }) {
|
|
11
8
|
const startingPrice = getStartingPrice(product);
|
|
12
9
|
|
|
13
10
|
return (
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Product, ProductVariant } from '@forgecart/sdk/shop';
|
|
4
|
+
import { useMemo, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { useCart } from '../lib/cart-context';
|
|
7
|
+
import { formatPrice } from '../lib/forgecart';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Variant picker + add-to-cart for the product detail page.
|
|
11
|
+
*
|
|
12
|
+
* The customer picks one value per option group (Size, Color, …); the matching
|
|
13
|
+
* variant drives the displayed price and what gets added to the cart — you add a
|
|
14
|
+
* **variant**, never the product. Single-variant products skip the selectors.
|
|
15
|
+
*/
|
|
16
|
+
export function ProductPurchase({ product }: { product: Product }) {
|
|
17
|
+
const { add, pending } = useCart();
|
|
18
|
+
const [added, setAdded] = useState(false);
|
|
19
|
+
|
|
20
|
+
// Default selection = the first variant's option values, so a price shows
|
|
21
|
+
// immediately. Keyed by option group id -> selected option id.
|
|
22
|
+
const [selected, setSelected] = useState<Record<string, string>>(() => {
|
|
23
|
+
const sel: Record<string, string> = {};
|
|
24
|
+
for (const option of product.variants[0]?.options ?? []) {
|
|
25
|
+
sel[option.groupId] = option.id;
|
|
26
|
+
}
|
|
27
|
+
return sel;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const variant: ProductVariant | undefined = useMemo(() => {
|
|
31
|
+
if (product.variants.length <= 1) {
|
|
32
|
+
return product.variants[0];
|
|
33
|
+
}
|
|
34
|
+
return product.variants.find(
|
|
35
|
+
(v) => v.options.length > 0 && v.options.every((o) => selected[o.groupId] === o.id),
|
|
36
|
+
);
|
|
37
|
+
}, [product.variants, selected]);
|
|
38
|
+
|
|
39
|
+
const onAdd = () => {
|
|
40
|
+
if (!variant) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
add(variant.id, 1);
|
|
44
|
+
setAdded(true);
|
|
45
|
+
window.setTimeout(() => setAdded(false), 1500);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="space-y-4">
|
|
50
|
+
{variant ? (
|
|
51
|
+
<p className="text-xl font-semibold text-gray-900">{formatPrice(variant.priceWithTax)}</p>
|
|
52
|
+
) : (
|
|
53
|
+
<p className="text-sm text-gray-500">Select options to see the price.</p>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
{product.optionGroups.map((group) => (
|
|
57
|
+
<div key={group.id}>
|
|
58
|
+
<h3 className="mb-1 text-sm font-medium text-gray-700">{group.name}</h3>
|
|
59
|
+
<div className="flex flex-wrap gap-2">
|
|
60
|
+
{group.options.map((opt) => {
|
|
61
|
+
const active = selected[group.id] === opt.id;
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
key={opt.id}
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={() => setSelected((s) => ({ ...s, [group.id]: opt.id }))}
|
|
67
|
+
className={`rounded-md border px-3 py-1.5 text-sm transition ${
|
|
68
|
+
active
|
|
69
|
+
? 'border-gray-900 bg-gray-900 text-white'
|
|
70
|
+
: 'border-gray-300 text-gray-700 hover:border-gray-900'
|
|
71
|
+
}`}
|
|
72
|
+
>
|
|
73
|
+
{opt.name}
|
|
74
|
+
</button>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={onAdd}
|
|
84
|
+
disabled={!variant || pending}
|
|
85
|
+
className="rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
86
|
+
>
|
|
87
|
+
{pending ? 'Adding…' : added ? 'Added!' : variant ? 'Add to cart' : 'Unavailable'}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import type { Order } from '@forgecart/sdk/shop';
|
|
4
|
+
import { cookies } from 'next/headers';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server-side cart, backed by the ForgeCart shop **order** API (the real cart),
|
|
8
|
+
* not browser storage. Every export here is a Server Action returning the live
|
|
9
|
+
* `Order` (the SDK type) so callers render straight off it.
|
|
10
|
+
*
|
|
11
|
+
* The anonymous active order is tracked by a session token: the shop API returns
|
|
12
|
+
* a fresh token in the `forgecart-auth-token` response header on the first
|
|
13
|
+
* mutation, which we persist into the httpOnly `forgecart-session` cookie and
|
|
14
|
+
* send back as a bearer token on every subsequent request. The channel token and
|
|
15
|
+
* the session never reach the browser — all cart calls run here, on the server.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
|
|
19
|
+
const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
20
|
+
const SESSION_COOKIE = 'forgecart-session';
|
|
21
|
+
|
|
22
|
+
/** The order fields the storefront renders (a subset of the SDK `Order` type). */
|
|
23
|
+
const ORDER_FIELDS = `
|
|
24
|
+
id
|
|
25
|
+
totalQuantity
|
|
26
|
+
subTotalWithTax
|
|
27
|
+
totalWithTax
|
|
28
|
+
currencyCode
|
|
29
|
+
lines {
|
|
30
|
+
id
|
|
31
|
+
quantity
|
|
32
|
+
unitPriceWithTax
|
|
33
|
+
linePriceWithTax
|
|
34
|
+
productVariant {
|
|
35
|
+
id
|
|
36
|
+
name
|
|
37
|
+
}
|
|
38
|
+
featuredAsset {
|
|
39
|
+
preview
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
interface GraphQLResponse<T> {
|
|
45
|
+
data?: T;
|
|
46
|
+
errors?: { message: string }[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Call the channel's shop API for a cart operation.
|
|
51
|
+
*
|
|
52
|
+
* `persist` MUST be `false` when called from a Server Component (the initial
|
|
53
|
+
* cart read): Server Components cannot set cookies — only Server Actions (the
|
|
54
|
+
* mutations) can, so they capture/refresh the session token.
|
|
55
|
+
*/
|
|
56
|
+
async function shopFetch<T>(
|
|
57
|
+
query: string,
|
|
58
|
+
variables: Record<string, unknown>,
|
|
59
|
+
persist: boolean,
|
|
60
|
+
): Promise<GraphQLResponse<T>> {
|
|
61
|
+
const store = await cookies();
|
|
62
|
+
const session = store.get(SESSION_COOKIE)?.value;
|
|
63
|
+
|
|
64
|
+
const headers: Record<string, string> = {
|
|
65
|
+
'content-type': 'application/json',
|
|
66
|
+
'forgecart-token': CHANNEL_TOKEN,
|
|
67
|
+
};
|
|
68
|
+
if (session) {
|
|
69
|
+
headers.authorization = `Bearer ${session}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const res = await fetch(SHOP_API_URL, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers,
|
|
75
|
+
body: JSON.stringify({ query, variables }),
|
|
76
|
+
cache: 'no-store',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (persist) {
|
|
80
|
+
const token = res.headers.get('forgecart-auth-token');
|
|
81
|
+
if (token && token !== session) {
|
|
82
|
+
store.set(SESSION_COOKIE, token, {
|
|
83
|
+
httpOnly: true,
|
|
84
|
+
sameSite: 'lax',
|
|
85
|
+
path: '/',
|
|
86
|
+
maxAge: 60 * 60 * 24 * 30,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (await res.json()) as GraphQLResponse<T>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function unwrap<T>(res: GraphQLResponse<T>): T {
|
|
95
|
+
if (res.errors?.length) {
|
|
96
|
+
throw new Error(res.errors[0].message);
|
|
97
|
+
}
|
|
98
|
+
if (!res.data) {
|
|
99
|
+
throw new Error('Empty response from shop API');
|
|
100
|
+
}
|
|
101
|
+
return res.data;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read the active order (cart). Safe to call from a Server Component. */
|
|
105
|
+
export async function getCart(): Promise<Order | null> {
|
|
106
|
+
const res = await shopFetch<{ activeOrder: Order | null }>(
|
|
107
|
+
`query { activeOrder { ${ORDER_FIELDS} } }`,
|
|
108
|
+
{},
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
return res.data?.activeOrder ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Add a variant to the cart (creates the order + session on first call). */
|
|
115
|
+
export async function addToCart(variantId: string, quantity = 1): Promise<Order | null> {
|
|
116
|
+
const data = unwrap(
|
|
117
|
+
await shopFetch<{ addItemToOrder: Order }>(
|
|
118
|
+
`mutation($input: ShopAddItemToOrderInput!) { addItemToOrder(input: $input) { ${ORDER_FIELDS} } }`,
|
|
119
|
+
{ input: { variantId, quantity } },
|
|
120
|
+
true,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
return data.addItemToOrder;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Set the quantity of a cart line (0 removes it). */
|
|
127
|
+
export async function updateLine(lineId: string, quantity: number): Promise<Order | null> {
|
|
128
|
+
const data = unwrap(
|
|
129
|
+
await shopFetch<{ adjustOrderLine: Order }>(
|
|
130
|
+
`mutation($input: ShopAdjustOrderLineInput!) { adjustOrderLine(input: $input) { ${ORDER_FIELDS} } }`,
|
|
131
|
+
{ input: { lineId, quantity } },
|
|
132
|
+
true,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
return data.adjustOrderLine;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Remove a line from the cart. */
|
|
139
|
+
export async function removeLine(lineId: string): Promise<Order | null> {
|
|
140
|
+
const data = unwrap(
|
|
141
|
+
await shopFetch<{ removeOrderLine: Order }>(
|
|
142
|
+
`mutation($lineId: ID!) { removeOrderLine(lineId: $lineId) { ${ORDER_FIELDS} } }`,
|
|
143
|
+
{ lineId },
|
|
144
|
+
true,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
return data.removeOrderLine;
|
|
148
|
+
}
|
|
@@ -1,107 +1,79 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import type { Order } from '@forgecart/sdk/shop';
|
|
3
4
|
import {
|
|
4
5
|
createContext,
|
|
5
6
|
useCallback,
|
|
6
7
|
useContext,
|
|
7
|
-
useEffect,
|
|
8
8
|
useMemo,
|
|
9
9
|
useState,
|
|
10
|
+
useTransition,
|
|
10
11
|
type ReactNode,
|
|
11
12
|
} from 'react';
|
|
12
13
|
|
|
14
|
+
import { addToCart, getCart, removeLine as removeLineAction, updateLine } from './cart-actions';
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `
|
|
17
|
+
* Cart state backed by the ForgeCart shop order API (see `cart-actions.ts`).
|
|
18
|
+
*
|
|
19
|
+
* `cart` is the live `Order` (the SDK type). The provider is seeded with the
|
|
20
|
+
* server-fetched active order (`initialCart`) so the first paint is correct,
|
|
21
|
+
* then every mutation calls a Server Action and replaces it with the order the
|
|
22
|
+
* API returns. `pending` is true while a cart mutation is in flight.
|
|
17
23
|
*/
|
|
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
24
|
interface CartContextValue {
|
|
28
|
-
|
|
25
|
+
cart: Order | null;
|
|
29
26
|
itemCount: number;
|
|
30
27
|
subtotal: number;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
pending: boolean;
|
|
29
|
+
add: (variantId: string, quantity?: number) => void;
|
|
30
|
+
setQuantity: (lineId: string, quantity: number) => void;
|
|
31
|
+
remove: (lineId: string) => void;
|
|
32
|
+
refresh: () => void;
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
const STORAGE_KEY = 'forgecart.cart';
|
|
38
|
-
|
|
39
35
|
const CartContext = createContext<CartContextValue | null>(null);
|
|
40
36
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// Hydrate from localStorage once on mount (avoids SSR hydration mismatch).
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
setLines(readStoredLines());
|
|
37
|
+
export function CartProvider({
|
|
38
|
+
initialCart,
|
|
39
|
+
children,
|
|
40
|
+
}: {
|
|
41
|
+
initialCart: Order | null;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
}) {
|
|
44
|
+
const [cart, setCart] = useState<Order | null>(initialCart);
|
|
45
|
+
const [pending, startTransition] = useTransition();
|
|
46
|
+
|
|
47
|
+
const run = useCallback((op: () => Promise<Order | null>) => {
|
|
48
|
+
startTransition(async () => {
|
|
49
|
+
setCart(await op());
|
|
50
|
+
});
|
|
59
51
|
}, []);
|
|
60
52
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
},
|
|
83
|
-
[],
|
|
53
|
+
const add = useCallback(
|
|
54
|
+
(variantId: string, quantity = 1) => run(() => addToCart(variantId, quantity)),
|
|
55
|
+
[run],
|
|
56
|
+
);
|
|
57
|
+
const setQuantity = useCallback(
|
|
58
|
+
(lineId: string, quantity: number) => run(() => updateLine(lineId, quantity)),
|
|
59
|
+
[run],
|
|
60
|
+
);
|
|
61
|
+
const remove = useCallback((lineId: string) => run(() => removeLineAction(lineId)), [run]);
|
|
62
|
+
const refresh = useCallback(() => run(() => getCart()), [run]);
|
|
63
|
+
|
|
64
|
+
const value = useMemo<CartContextValue>(
|
|
65
|
+
() => ({
|
|
66
|
+
cart,
|
|
67
|
+
itemCount: cart?.totalQuantity ?? 0,
|
|
68
|
+
subtotal: cart?.subTotalWithTax ?? 0,
|
|
69
|
+
pending,
|
|
70
|
+
add,
|
|
71
|
+
setQuantity,
|
|
72
|
+
remove,
|
|
73
|
+
refresh,
|
|
74
|
+
}),
|
|
75
|
+
[cart, pending, add, setQuantity, remove, refresh],
|
|
84
76
|
);
|
|
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
77
|
|
|
106
78
|
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
|
107
79
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ForgeCartShopClient } from '@forgecart/sdk';
|
|
2
|
+
import type { Product, ProductList } from '@forgecart/sdk/shop';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Server-side ForgeCart shop client.
|
|
@@ -60,39 +61,10 @@ export const shopClient = (): ForgeCartShopClient => getShopClient();
|
|
|
60
61
|
* `.shopProduct`) only select `id` + `optionGroups`, which is not enough to
|
|
61
62
|
* render a storefront. We therefore use the SDK's supported raw-query escape
|
|
62
63
|
* hatch (`ForgeCartShopClient.query<T>(document, variables)`) with documents
|
|
63
|
-
* that select the display fields
|
|
64
|
-
* `
|
|
65
|
-
* `@forgecart/sdk/shop`).
|
|
64
|
+
* that select the display fields, typed against the SDK's own `Product` /
|
|
65
|
+
* `ProductList` types (`@forgecart/sdk/shop`).
|
|
66
66
|
* ------------------------------------------------------------------ */
|
|
67
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
68
|
const PRODUCT_FIELDS = `
|
|
97
69
|
id
|
|
98
70
|
name
|
|
@@ -102,12 +74,28 @@ const PRODUCT_FIELDS = `
|
|
|
102
74
|
id
|
|
103
75
|
preview
|
|
104
76
|
}
|
|
77
|
+
optionGroups {
|
|
78
|
+
id
|
|
79
|
+
name
|
|
80
|
+
code
|
|
81
|
+
options {
|
|
82
|
+
id
|
|
83
|
+
name
|
|
84
|
+
code
|
|
85
|
+
}
|
|
86
|
+
}
|
|
105
87
|
variants {
|
|
106
88
|
id
|
|
107
89
|
name
|
|
108
90
|
sku
|
|
109
91
|
price
|
|
110
92
|
priceWithTax
|
|
93
|
+
options {
|
|
94
|
+
id
|
|
95
|
+
name
|
|
96
|
+
code
|
|
97
|
+
groupId
|
|
98
|
+
}
|
|
111
99
|
}
|
|
112
100
|
`;
|
|
113
101
|
|
|
@@ -127,14 +115,11 @@ const PRODUCT_BY_ID_QUERY = `query StorefrontProductById($id: ID) {
|
|
|
127
115
|
}`;
|
|
128
116
|
|
|
129
117
|
interface ProductsQueryResult {
|
|
130
|
-
products:
|
|
131
|
-
items: StorefrontProduct[];
|
|
132
|
-
totalItems: number;
|
|
133
|
-
};
|
|
118
|
+
products: Pick<ProductList, 'items' | 'totalItems'>;
|
|
134
119
|
}
|
|
135
120
|
|
|
136
121
|
interface ProductQueryResult {
|
|
137
|
-
product:
|
|
122
|
+
product: Product | null;
|
|
138
123
|
}
|
|
139
124
|
|
|
140
125
|
/**
|
|
@@ -144,7 +129,7 @@ interface ProductQueryResult {
|
|
|
144
129
|
*/
|
|
145
130
|
export async function getProducts(
|
|
146
131
|
options: { take?: number; skip?: number } = {},
|
|
147
|
-
): Promise<
|
|
132
|
+
): Promise<{ items: Product[]; totalItems: number }> {
|
|
148
133
|
const { take = 24, skip = 0 } = options;
|
|
149
134
|
const result = await getShopClient().query<ProductsQueryResult>(PRODUCTS_QUERY, {
|
|
150
135
|
options: { take, skip },
|
|
@@ -156,7 +141,7 @@ export async function getProducts(
|
|
|
156
141
|
}
|
|
157
142
|
|
|
158
143
|
/** Fetch a single product by its URL slug. Returns `null` if not found. */
|
|
159
|
-
export async function getProductBySlug(slug: string): Promise<
|
|
144
|
+
export async function getProductBySlug(slug: string): Promise<Product | null> {
|
|
160
145
|
const result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_SLUG_QUERY, {
|
|
161
146
|
slug,
|
|
162
147
|
});
|
|
@@ -164,7 +149,7 @@ export async function getProductBySlug(slug: string): Promise<StorefrontProduct
|
|
|
164
149
|
}
|
|
165
150
|
|
|
166
151
|
/** Fetch a single product by id. Returns `null` if not found. */
|
|
167
|
-
export async function getProductById(id: string): Promise<
|
|
152
|
+
export async function getProductById(id: string): Promise<Product | null> {
|
|
168
153
|
const result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_ID_QUERY, {
|
|
169
154
|
id,
|
|
170
155
|
});
|
|
@@ -174,13 +159,13 @@ export async function getProductById(id: string): Promise<StorefrontProduct | nu
|
|
|
174
159
|
/**
|
|
175
160
|
* Convenience: fetch a small set of products to feature on the home page.
|
|
176
161
|
*/
|
|
177
|
-
export async function getFeaturedProducts(count = 4): Promise<
|
|
162
|
+
export async function getFeaturedProducts(count = 4): Promise<Product[]> {
|
|
178
163
|
const { items } = await getProducts({ take: count });
|
|
179
164
|
return items;
|
|
180
165
|
}
|
|
181
166
|
|
|
182
167
|
/** Lowest variant price for a product, in minor units, or `null` if none. */
|
|
183
|
-
export function getStartingPrice(product:
|
|
168
|
+
export function getStartingPrice(product: Product): number | null {
|
|
184
169
|
if (product.variants.length === 0) {
|
|
185
170
|
return null;
|
|
186
171
|
}
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
}
|