@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 CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Command } from 'commander';
2
- /** CLI version, kept in sync with `package.json`. */
3
- export declare const CLI_VERSION = "2.0.0";
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
- /** CLI version, kept in sync with `package.json`. */
6
- export const CLI_VERSION = '2.0.0';
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
  *
@@ -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,qDAAqD;AACrD,MAAM,CAAC,MAAM,WAAW,GAAG,OAAO,CAAC;AAEnC;;;;;;GAMG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAE9B,OAAO;SACJ,IAAI,CAAC,WAAW,CAAC;SACjB,WAAW,CAAC,iEAAiE,CAAC;SAC9E,OAAO,CAAC,WAAW,CAAC,CAAC;IAExB,OAAO,CAAC,UAAU,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACxC,OAAO,CAAC,UAAU,CAAC,4BAA4B,EAAE,CAAC,CAAC;IACnD,OAAO,CAAC,UAAU,CAAC,mBAAmB,EAAE,CAAC,CAAC;IAC1C,OAAO,CAAC,UAAU,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACxC,OAAO,CAAC,UAAU,CAAC,mBAAmB,EAAE,CAAC,CAAC;IAC1C,OAAO,CAAC,UAAU,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACxC,OAAO,CAAC,UAAU,CAAC,mBAAmB,EAAE,CAAC,CAAC;IAC1C,OAAO,CAAC,UAAU,CAAC,kBAAkB,EAAE,CAAC,CAAC;IACzC,OAAO,CAAC,UAAU,CAAC,gBAAgB,EAAE,CAAC,CAAC;IACvC,OAAO,CAAC,UAAU,CAAC,mBAAmB,EAAE,CAAC,CAAC;IAE1C,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,GAAG,CAAC,OAAiB,OAAO,CAAC,IAAI;IACrD,MAAM,OAAO,GAAG,aAAa,EAAE,CAAC;IAChC,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IACjC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACvE,UAAU,CAAC,UAAU,OAAO,EAAE,CAAC,CAAC;QAChC,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IACvB,CAAC;AACH,CAAC"}
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, copies the bundled storefront template into it,
17
- * writes the ForgeCart config and `.env.local`, then prints next steps. Throws a
18
- * clear Error on any failure (missing token, missing template, copy failure).
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('Storefront scaffolded successfully.');
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
- print(' npm install');
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, copies the bundled storefront template into it,
66
- * writes the ForgeCart config and `.env.local`, then prints next steps. Throws a
67
- * clear Error on any failure (missing token, missing template, copy failure).
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
- printNextSteps(name ?? '.', config.hasApplication);
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;AAC9D,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAErC,0DAA0D;AAC1D,MAAM,CAAC,MAAM,oBAAoB,GAAG,2BAA2B,CAAC;AAqBhE;;;;;GAKG;AACH,SAAS,wBAAwB;IAC/B,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACrD,OAAO,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,YAAY,CAAC,CAAC;AACpE,CAAC;AAED,kDAAkD;AAClD,KAAK,UAAU,WAAW,CAAC,IAAY;IACrC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;QAC9B,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,kDAAkD;AAClD,SAAS,WAAW,CAAC,YAAoB,EAAE,OAAoB;IAC7D,OAAO;QACL,OAAO,EAAE,KAAK;QACd,YAAY;QACZ,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,cAAc,EAAE,OAAO,CAAC,WAAW,IAAI,KAAK;QAC5C,cAAc,EAAE,GAAG;KACpB,CAAC;AACJ,CAAC;AAED,qEAAqE;AACrE,SAAS,aAAa,CAAC,YAAoB,EAAE,MAAc;IACzD,OAAO;QACL,2BAA2B,YAAY,EAAE;QACzC,0BAA0B,MAAM,EAAE;QAClC,EAAE;KACH,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,0CAA0C;AAC1C,SAAS,cAAc,CAAC,SAAiB,EAAE,WAAoB;IAC7D,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,KAAK,CAAC,qCAAqC,CAAC,CAAC;IAC7C,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,KAAK,CAAC,aAAa,CAAC,CAAC;IACrB,KAAK,CAAC,QAAQ,SAAS,EAAE,CAAC,CAAC;IAC3B,KAAK,CAAC,eAAe,CAAC,CAAC;IACvB,KAAK,CAAC,eAAe,CAAC,CAAC;IACvB,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,IAAI,WAAW,EAAE,CAAC;QAChB,KAAK,CACH,2EAA2E;YACzE,0BAA0B,CAC7B,CAAC;QACF,KAAK,CAAC,EAAE,CAAC,CAAC;IACZ,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,IAAwB,EAAE,OAAoB;IAC1E,MAAM,YAAY,GAAG,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAI,CAAC,YAAY,EAAE,CAAC;QAClB,MAAM,IAAI,KAAK,CACb,4EAA4E,CAC7E,CAAC;IACJ,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;IACtE,MAAM,WAAW,GAAG,wBAAwB,EAAE,CAAC;IAC/C,IAAI,CAAC,CAAC,MAAM,WAAW,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC;QACtC,MAAM,IAAI,KAAK,CAAC,oCAAoC,WAAW,GAAG,CAAC,CAAC;IACtE,CAAC;IAED,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5C,MAAM,EAAE,CAAC,WAAW,EAAE,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAEtD,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;IAChD,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5C,MAAM,MAAM,GAAG,WAAW,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;IAClD,MAAM,SAAS,CACb,IAAI,CAAC,SAAS,EAAE,aAAa,CAAC,EAC9B,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EACtC,MAAM,CACP,CAAC;IAEF,MAAM,SAAS,CACb,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,EAC7B,aAAa,CAAC,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,EAC3C,MAAM,CACP,CAAC;IAEF,cAAc,CAAC,IAAI,IAAI,GAAG,EAAE,MAAM,CAAC,cAAc,CAAC,CAAC;AACrD,CAAC;AAED,uCAAuC;AACvC,MAAM,UAAU,iBAAiB;IAC/B,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC;SACvB,WAAW,CAAC,yDAAyD,CAAC;SACtE,QAAQ,CAAC,QAAQ,EAAE,2DAA2D,CAAC;SAC/E,MAAM,CAAC,qBAAqB,EAAE,oCAAoC,CAAC;SACnE,MAAM,CACL,iBAAiB,EACjB,yDAAyD,EACzD,oBAAoB,CACrB;SACA,MAAM,CAAC,mBAAmB,EAAE,uDAAuD,CAAC;SACpF,MAAM,CAAC,KAAK,EAAE,IAAwB,EAAE,OAAoB,EAAE,EAAE;QAC/D,MAAM,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAC/B,CAAC,CAAC,CAAC;AACP,CAAC"}
1
+ {"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/commands/init.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgecart/cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "CLI for scaffolding and operating ForgeCart channel storefronts",
6
6
  "bin": {
@@ -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 { lines, subtotal, itemCount, setQuantity, removeLine, clear } = useCart();
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
- <div className="flex items-center justify-between">
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.variantId} className="flex items-center gap-4 p-4">
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.imageUrl ? (
35
+ {line.featuredAsset?.preview ? (
44
36
  // eslint-disable-next-line @next/next/no-img-element
45
37
  <img
46
- src={line.imageUrl}
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
- <Link
55
- href={`/products/${line.productSlug}`}
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.variantId}`}>
51
+ <label className="sr-only" htmlFor={`qty-${line.id}`}>
65
52
  Quantity
66
53
  </label>
67
54
  <input
68
- id={`qty-${line.variantId}`}
55
+ id={`qty-${line.id}`}
69
56
  type="number"
70
57
  min={1}
71
58
  value={line.quantity}
72
- onChange={(e) =>
73
- setQuantity(line.variantId, Number.parseInt(e.target.value, 10) || 1)
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.unitPrice * line.quantity)}
66
+ {formatPrice(line.linePriceWithTax)}
81
67
  </div>
82
68
 
83
69
  <button
84
70
  type="button"
85
- onClick={() => removeLine(line.variantId)}
86
- className="text-sm text-gray-400 hover:text-red-600"
87
- aria-label={`Remove ${line.name}`}
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 { AddToCartButton } from '../../../components/AddToCartButton';
5
- import { formatPrice, getProductBySlug } from '../../../lib/forgecart';
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
- {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
- )}
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: StorefrontProduct }) {
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
- * 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
+ * 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
- lines: CartLine[];
25
+ cart: Order | null;
29
26
  itemCount: number;
30
27
  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;
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 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());
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
- // 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
- [],
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. Every field below exists on the shop schema's
64
- * `Product` / `ProductVariant` / `Asset` / `ProductList` types (available via
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: StorefrontProduct | null;
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<StorefrontProductList> {
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<StorefrontProduct | null> {
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<StorefrontProduct | null> {
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<StorefrontProduct[]> {
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: StorefrontProduct): number | null {
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
- }