create-nymbal-app 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/index.js +9 -10
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/scaffold/templates/astro/package.json +25 -26
  5. package/scaffold/templates/astro/src/components/Header.astro +7 -1
  6. package/scaffold/templates/astro/src/components/ReviewForm.astro +1 -27
  7. package/scaffold/templates/astro/src/components/StockBadge.astro +5 -22
  8. package/scaffold/templates/astro/src/layouts/AccountLayout.astro +2 -0
  9. package/scaffold/templates/astro/src/lib/api.ts +18 -4
  10. package/scaffold/templates/astro/src/lib/client.ts +2 -0
  11. package/scaffold/templates/astro/src/lib/submit-once.ts +44 -0
  12. package/scaffold/templates/astro/src/pages/account/addresses.astro +2 -2
  13. package/scaffold/templates/astro/src/pages/account/profile.astro +1 -63
  14. package/scaffold/templates/astro/src/pages/category/[slug].astro +26 -7
  15. package/scaffold/templates/astro/src/pages/checkout.astro +12 -4
  16. package/scaffold/templates/astro/src/pages/product/[slug].astro +4 -1
  17. package/scaffold/templates/astro/src/pages/products/index.astro +26 -7
  18. package/scaffold/templates/astro/src/pages/search.astro +11 -5
  19. package/scaffold/templates/astro/src/scripts/auth-forms.ts +15 -22
  20. package/scaffold/templates/astro/src/scripts/cart-page.ts +10 -35
  21. package/scaffold/templates/astro/src/scripts/checkout.ts +52 -58
  22. package/scaffold/templates/astro/src/scripts/pdp.ts +35 -44
  23. package/scaffold/templates/astro/src/scripts/plp.ts +6 -5
  24. package/scaffold/templates/astro/src/scripts/profile-form.ts +57 -0
  25. package/scaffold/templates/astro/src/scripts/review-form.ts +27 -0
  26. package/scaffold/templates/astro/src/scripts/search.ts +32 -8
  27. package/scaffold/templates/astro/src/styles/pages/cart.css +0 -28
  28. package/scaffold/templates/astro/src/styles/pages/pdp.css +6 -2
  29. package/scaffold/templates/astro/src/styles/pages/search.css +19 -0
  30. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +7 -4
  31. package/scaffold/templates/nextjs/app/checkout/page.tsx +9 -2
  32. package/scaffold/templates/nextjs/app/products/page.tsx +7 -7
  33. package/scaffold/templates/nextjs/components/client/CartPage.tsx +1 -0
  34. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +50 -91
  35. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +39 -50
  36. package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +41 -20
  37. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +87 -47
  38. package/scaffold/templates/nextjs/components/server/Header.tsx +9 -4
  39. package/scaffold/templates/nextjs/lib/api.ts +24 -4
  40. package/scaffold/templates/nextjs/lib/client.ts +3 -0
  41. package/scaffold/templates/nextjs/package.json +26 -28
  42. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +6 -2
  43. package/scaffold/templates/nextjs/styles/pages/plp.module.css +3 -2
  44. package/scaffold/templates/nextjs/styles/pages/search.module.css +24 -0
  45. package/scaffold/templates/astro/src/components/SortDropdown.astro +0 -26
  46. package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +0 -35
  47. package/scaffold/templates/nextjs/components/server/StockBadge.tsx +0 -19
package/dist/index.js CHANGED
@@ -124,7 +124,8 @@ function parseCliFlags(argv) {
124
124
  let value = match[2];
125
125
  if (value === void 0) {
126
126
  const next = args[i + 1];
127
- if (next === void 0 || next.startsWith("-")) throw new CliFlagError(`--${flag} needs a value`);
127
+ if (next === void 0 || next.startsWith("-"))
128
+ throw new CliFlagError(`--${flag} needs a value`);
128
129
  value = next;
129
130
  i++;
130
131
  }
@@ -167,8 +168,10 @@ async function main(argv = process.argv, deps = {}) {
167
168
  if (existsSync(targetDir2)) {
168
169
  const entries = await readdir(targetDir2);
169
170
  if (entries.length > 0) {
170
- process.stderr.write(`Error: Directory ${answers2.directory} already exists and is not empty.
171
- `);
171
+ process.stderr.write(
172
+ `Error: Directory ${answers2.directory} already exists and is not empty.
173
+ `
174
+ );
172
175
  process.exit(1);
173
176
  }
174
177
  } else {
@@ -252,9 +255,7 @@ async function main(argv = process.argv, deps = {}) {
252
255
  await installAndSeed(targetDir, pm, answers.seedDemo, run, admin);
253
256
  }
254
257
  const cmd = devCmd(pm);
255
- const nextSteps = [
256
- ` ${pc.cyan("cd")} ${answers.directory}`
257
- ];
258
+ const nextSteps = [` ${pc.cyan("cd")} ${answers.directory}`];
258
259
  if (!shouldInstall) {
259
260
  const [ic] = installCmd(pm);
260
261
  nextSteps.push(` ${pc.cyan(ic + " install")}`);
@@ -321,7 +322,7 @@ async function renameDotFiles(dir) {
321
322
  }
322
323
  }
323
324
  function renderConfig(answers) {
324
- return `import { defineConfig, env } from '@nymbalhq/config'
325
+ return `import { defineConfig } from '@nymbalhq/config'
325
326
 
326
327
  export default defineConfig({
327
328
  store: {
@@ -334,11 +335,9 @@ export default defineConfig({
334
335
  template: '${answers.template}',
335
336
 
336
337
  infrastructure: {
337
- cloud: 'local',
338
338
  commandStore: 'sqlite',
339
339
  documentStore: 'in-memory',
340
340
  eventBus: 'in-process',
341
- compute: 'in-process',
342
341
  },
343
342
 
344
343
  security: {
@@ -367,7 +366,7 @@ export default defineConfig({
367
366
  analytics: { provider: 'native' },
368
367
  shipping: { provider: 'native' },
369
368
  tax: { provider: 'native' },
370
- ai: { provider: 'anthropic', config: { apiKey: env('ANTHROPIC_API_KEY', 'anthropic-placeholder') } },
369
+ ai: { provider: 'none' },
371
370
  },
372
371
 
373
372
  http: {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { cp, readFile, mkdir, readdir, rm, writeFile } from 'node:fs/promises'\nimport { existsSync } from 'node:fs'\nimport { dirname, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { spawn, spawnSync } from 'node:child_process'\nimport { randomBytes } from 'node:crypto'\nimport { cancel, confirm, intro, isCancel, note, outro, select, text } from '@clack/prompts'\nimport pc from 'picocolors'\n\nconst HERE = dirname(fileURLToPath(import.meta.url))\nconst SCAFFOLD_ROOT = resolve(HERE, '..', 'scaffold')\n\nexport type PackageManager = 'npm' | 'pnpm' | 'yarn-classic' | 'yarn-berry' | 'bun'\n\ninterface Answers {\n name: string\n directory: string\n currency: 'GBP' | 'USD' | 'EUR'\n template: 'astro' | 'nextjs'\n seedDemo: boolean\n}\n\n/**\n * Runs a command in `cwd` (with `env` when given, else the process environment) and rejects on a non-zero exit.\n * Injected into `main` so tests do not shell out.\n */\nexport type Runner = (\n cmd: string,\n args: string[],\n cwd: string,\n env?: NodeJS.ProcessEnv,\n) => Promise<void>\n\n/** The port `renderConfig` gives the API, which serves the admin at /admin/. */\nconst API_PORT = 3001\n\ninterface AdminCredentials {\n email: string\n password: string\n}\n\n/** A fresh admin password per project (128 bits, base64url), so no two stores share the seed's default. */\nfunction newAdminCredentials(): AdminCredentials {\n return { email: 'admin@nymbal.local', password: randomBytes(16).toString('base64url') }\n}\n\n/** `.env` holds the admin credentials the seed (and a first `nymbal dev`) creates the admin from. */\nasync function writeAdminEnv(dir: string, admin: AdminCredentials): Promise<void> {\n const lines = [\n '# The admin account the seed creates. Keep this file out of version control (.gitignore lists it).',\n `NYMBAL_ADMIN_EMAIL=${admin.email}`,\n `NYMBAL_ADMIN_PASSWORD=${admin.password}`,\n '',\n ]\n await writeFile(resolve(dir, '.env'), lines.join('\\n'), { encoding: 'utf8', mode: 0o600 })\n}\n\nfunction adminSignIn(admin: AdminCredentials): string[] {\n return [\n `Admin: http://localhost:${API_PORT}/admin/`,\n ` email ${admin.email}`,\n ` password ${admin.password} (saved in .env)`,\n ]\n}\n\nexport interface MainDeps {\n run?: Runner\n}\n\n/**\n * The `packageManager` field for the package manager that launched us (`npm_config_user_agent`), read when called.\n * Without a usable user agent it is the installed npm; undefined when npm cannot be run either.\n */\nfunction packageManagerField(): string | undefined {\n const token = (process.env['npm_config_user_agent'] ?? '').split(' ')[0] ?? ''\n const match = /^(npm|pnpm|yarn|bun)\\/(\\d+\\.\\d+\\.\\d+\\S*)$/.exec(token)\n if (match) return `${match[1]}@${match[2]}`\n const npm = spawnSync('npm', ['-v'], { encoding: 'utf8', shell: process.platform === 'win32' })\n const version = npm.status === 0 ? npm.stdout.trim() : ''\n return version ? `npm@${version}` : undefined\n}\n\nexport function detectPackageManager(): PackageManager {\n const ua = process.env['npm_config_user_agent']\n if (!ua) return 'npm'\n const token = ua.split(' ')[0] ?? ''\n const slash = token.indexOf('/')\n const pm = slash >= 0 ? token.slice(0, slash) : token\n const version = slash >= 0 ? token.slice(slash + 1) : ''\n if (pm === 'pnpm') return 'pnpm'\n if (pm === 'bun') return 'bun'\n if (pm === 'yarn') {\n const major = parseInt(version.split('.')[0] ?? '1', 10)\n return major < 2 ? 'yarn-classic' : 'yarn-berry'\n }\n return 'npm'\n}\n\nfunction installCmd(pm: PackageManager): [string, string[]] {\n switch (pm) {\n case 'pnpm': return ['pnpm', ['install']]\n case 'bun': return ['bun', ['install']]\n case 'yarn-classic':\n case 'yarn-berry': return ['yarn', ['install']]\n default: return ['npm', ['install']]\n }\n}\n\nfunction seedCmd(pm: PackageManager): [string, string[]] {\n switch (pm) {\n case 'pnpm': return ['pnpm', ['exec', 'nymbal', 'seed']]\n case 'bun': return ['bunx', ['nymbal', 'seed']]\n case 'yarn-classic': return ['yarn', ['run', 'nymbal', 'seed']]\n case 'yarn-berry': return ['yarn', ['exec', 'nymbal', 'seed']]\n default: return ['npx', ['nymbal', 'seed']]\n }\n}\n\nfunction devCmd(pm: PackageManager): string {\n switch (pm) {\n case 'pnpm': return 'pnpm dev'\n case 'bun': return 'bun dev'\n case 'yarn-classic':\n case 'yarn-berry': return 'yarn dev'\n default: return 'npm run dev'\n }\n}\n\nconst TEMPLATES = ['astro', 'nextjs'] as const\nconst CURRENCIES = ['GBP', 'USD', 'EUR'] as const\n\nexport interface CliFlags {\n name?: string\n template?: (typeof TEMPLATES)[number]\n currency?: (typeof CURRENCIES)[number]\n yes: boolean\n /** Present (false) only with `--no-install`. */\n install?: boolean\n}\n\n/** A command-line flag that is missing its value or names a value outside the allowed set. */\nexport class CliFlagError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'CliFlagError'\n }\n}\n\nfunction oneOf<T extends string>(flag: string, value: string, allowed: readonly T[]): T {\n if ((allowed as readonly string[]).includes(value)) return value as T\n throw new CliFlagError(`Unknown ${flag} \"${value}\". Allowed: ${allowed.join(', ')}`)\n}\n\n/** Parses process.argv (node and script first): --name, --template and --currency take `--flag v` or `--flag=v`. */\nexport function parseCliFlags(argv: string[]): CliFlags {\n const flags: CliFlags = { yes: false }\n const args = argv.slice(2)\n let positional: string | undefined\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!\n if (arg === '--yes' || arg === '-y') {\n flags.yes = true\n continue\n }\n if (arg === '--no-install') {\n flags.install = false\n continue\n }\n const match = /^--(name|template|currency)(?:=(.*))?$/s.exec(arg)\n if (match) {\n const flag = match[1]!\n let value = match[2]\n if (value === undefined) {\n const next = args[i + 1]\n if (next === undefined || next.startsWith('-')) throw new CliFlagError(`--${flag} needs a value`)\n value = next\n i++\n }\n if (flag === 'name') flags.name = value\n else if (flag === 'template') flags.template = oneOf('--template', value, TEMPLATES)\n else flags.currency = oneOf('--currency', value, CURRENCIES)\n } else if (!arg.startsWith('-') && positional === undefined) {\n positional = arg\n }\n }\n if (flags.name === undefined && positional !== undefined) flags.name = positional\n return flags\n}\n\nexport async function main(\n argv: string[] = process.argv,\n deps: MainDeps = {},\n): Promise<void> {\n const run = deps.run ?? runCmd\n let flags: CliFlags\n try {\n flags = parseCliFlags(argv)\n } catch (err) {\n if (!(err instanceof CliFlagError)) throw err\n process.stderr.write(`Error: ${err.message}\\n`)\n process.exit(1)\n }\n\n const pm = detectPackageManager()\n\n if (flags.yes) {\n if (!flags.name) {\n process.stderr.write('Error: --yes requires --name or a positional name argument\\n')\n process.exit(1)\n }\n const name = flags.name.trim()\n const answers: Answers = {\n name,\n directory: toDirectoryName(name),\n currency: flags.currency ?? 'GBP',\n template: flags.template ?? 'astro',\n seedDemo: true,\n }\n const targetDir = resolve(process.cwd(), answers.directory)\n if (existsSync(targetDir)) {\n const entries = await readdir(targetDir)\n if (entries.length > 0) {\n process.stderr.write(`Error: Directory ${answers.directory} already exists and is not empty.\\n`)\n process.exit(1)\n }\n } else {\n await mkdir(targetDir, { recursive: true })\n }\n await scaffold(targetDir, answers)\n const admin = newAdminCredentials()\n await writeAdminEnv(targetDir, admin)\n process.stdout.write(`Created ${answers.directory}\\n`)\n if (flags.install !== false) {\n await installAndSeed(targetDir, pm, answers.seedDemo, run, admin)\n } else {\n process.stdout.write(`Next: cd ${answers.directory} && ${installCmd(pm)[0]} install\\n`)\n }\n process.stdout.write(`${adminSignIn(admin).join('\\n')}\\n`)\n return\n }\n\n intro(pc.cyan(pc.bold('◆ create-nymbal-app')))\n\n const name = flags.name\n ? flags.name\n : ((await text({\n message: 'Store name',\n placeholder: 'My Store',\n validate: (v) => (!v || v.trim().length === 0 ? 'Required' : undefined),\n })) as string | symbol)\n\n if (isCancel(name)) return cancel('Cancelled')\n\n type Currency = 'GBP' | 'USD' | 'EUR'\n const currencyOptions: { value: Currency; label: string }[] = [\n { value: 'GBP', label: 'GBP — British Pound' },\n { value: 'USD', label: 'USD — US Dollar' },\n { value: 'EUR', label: 'EUR — Euro' },\n ]\n const currency = await select({\n message: 'Store currency',\n options: currencyOptions,\n initialValue: 'GBP' as Currency,\n })\n\n if (isCancel(currency)) return cancel('Cancelled')\n\n type Template = 'astro' | 'nextjs'\n const templateOptions: { value: Template; label: string }[] = [\n { value: 'astro', label: 'Astro (recommended — performance-first)' },\n { value: 'nextjs', label: 'Next.js (migration-friendly)' },\n ]\n const template = await select({\n message: 'Storefront template',\n options: templateOptions,\n initialValue: 'astro' as Template,\n })\n\n if (isCancel(template)) return cancel('Cancelled')\n\n const seedDemo = (await confirm({\n message: 'Generate demo content?',\n initialValue: true,\n })) as boolean | symbol\n\n if (isCancel(seedDemo)) return cancel('Cancelled')\n\n const shouldInstall =\n flags.install === false\n ? false\n : ((await confirm({\n message: 'Install dependencies now?',\n initialValue: true,\n })) as boolean | symbol)\n\n if (isCancel(shouldInstall)) return cancel('Cancelled')\n\n const answers: Answers = {\n name: String(name).trim(),\n directory: toDirectoryName(String(name).trim()),\n currency: currency as 'GBP' | 'USD' | 'EUR',\n template: template as 'astro' | 'nextjs',\n seedDemo: seedDemo as boolean,\n }\n\n const targetDir = resolve(process.cwd(), answers.directory)\n if (existsSync(targetDir)) {\n const entries = await readdir(targetDir)\n if (entries.length > 0) {\n cancel(`Directory ${answers.directory} already exists and is not empty.`)\n process.exit(1)\n }\n } else {\n await mkdir(targetDir, { recursive: true })\n }\n\n note(pc.dim(`Scaffolding into ${targetDir}`), 'Creating project')\n\n await scaffold(targetDir, answers)\n const admin = newAdminCredentials()\n await writeAdminEnv(targetDir, admin)\n\n if (shouldInstall) {\n await installAndSeed(targetDir, pm, answers.seedDemo, run, admin)\n }\n\n const cmd = devCmd(pm)\n const nextSteps = [\n ` ${pc.cyan('cd')} ${answers.directory}`,\n ]\n if (!shouldInstall) {\n const [ic] = installCmd(pm)\n nextSteps.push(` ${pc.cyan(ic + ' install')}`)\n }\n nextSteps.push(` ${pc.cyan(cmd)}`)\n\n outro(\n [pc.green(`✓ Created ${answers.directory}`), '', ...nextSteps, '', ...adminSignIn(admin)].join(\n '\\n',\n ),\n )\n}\n\n/** Install with the detected package manager, then seed through the project's own `nymbal` bin. */\nasync function installAndSeed(\n dir: string,\n pm: PackageManager,\n seedDemo: boolean,\n run: Runner,\n admin: AdminCredentials,\n): Promise<void> {\n const [installBin, installArgs] = installCmd(pm)\n process.stdout.write(`Running ${installBin} install (this may take a moment)...\\n`)\n await run(installBin, installArgs, dir)\n if (seedDemo) {\n process.stdout.write('Seeding demo content...\\n')\n const [seedBin, seedArgs] = seedCmd(pm)\n // Explicit, so a value exported in the shell cannot replace the one in .env and the outro.\n await run(seedBin, seedArgs, dir, {\n ...process.env,\n NYMBAL_ADMIN_EMAIL: admin.email,\n NYMBAL_ADMIN_PASSWORD: admin.password,\n })\n }\n}\n\nexport async function scaffold(dir: string, answers: Answers): Promise<void> {\n // Copy the chosen template directly into dir (flat — no storefront/ subdirectory)\n await copyScaffoldDir(resolve(SCAFFOLD_ROOT, 'templates', answers.template), dir)\n\n // Overlay the project layer (scaffold/project/<template>) on top of the template\n // defaults. Project files WIN on path collision — this is the override model:\n // anything in the project layer shadows the template default at the same path.\n // scaffold/project/ lives outside scaffold/templates/, so sync-scaffold's\n // rm -rf of scaffold/templates/* never deletes it.\n const projectLayer = resolve(SCAFFOLD_ROOT, 'project', answers.template)\n if (existsSync(projectLayer)) {\n await copyScaffoldDir(projectLayer, dir)\n }\n\n // Write nymbal.config.ts (answers-dependent, so generated at runtime)\n await writeFile(resolve(dir, 'nymbal.config.ts'), renderConfig(answers), 'utf8')\n\n // Patch the copied package.json: set name to the project directory name\n await patchPackageJson(dir, answers.directory)\n}\n\nasync function patchPackageJson(dir: string, name: string): Promise<void> {\n const pkgPath = resolve(dir, 'package.json')\n const pkg = JSON.parse(await readFile(pkgPath, 'utf8')) as Record<string, unknown>\n pkg['name'] = name\n pkg['private'] = true\n const packageManager = packageManagerField()\n if (packageManager) pkg['packageManager'] = packageManager\n await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\\n', 'utf8')\n}\n\nasync function copyScaffoldDir(src: string, dest: string): Promise<void> {\n if (!existsSync(src)) {\n throw new Error(`Scaffold directory not found: ${src}`)\n }\n await cp(src, dest, {\n recursive: true,\n filter: (source) => !source.endsWith('node_modules'),\n })\n await renameDotFiles(dest)\n}\n\nasync function renameDotFiles(dir: string): Promise<void> {\n // Non-recursive: only rename _dot.* files at the project root.\n // All shared dotfiles (_dot.gitignore, _dot.env.example) live at template root.\n const entries = await readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isDirectory() && entry.name.startsWith('_dot.')) {\n const full = resolve(dir, entry.name)\n const renamed = resolve(dir, '.' + entry.name.slice(5))\n await writeFile(renamed, await readFile(full))\n await rm(full)\n }\n }\n}\n\nfunction renderConfig(answers: Answers): string {\n return `import { defineConfig, env } from '@nymbalhq/config'\n\nexport default defineConfig({\n store: {\n name: '${escape(answers.name)}',\n currency: '${answers.currency}',\n locale: '${defaultLocale(answers.currency)}',\n timezone: '${defaultTimezone(answers.currency)}',\n },\n\n template: '${answers.template}',\n\n infrastructure: {\n cloud: 'local',\n commandStore: 'sqlite',\n documentStore: 'in-memory',\n eventBus: 'in-process',\n compute: 'in-process',\n },\n\n security: {\n adapter: 'middleware',\n rateLimit: {\n '/checkout': { requests: 10, window: '60s', action: 'block' },\n '/auth/*': { requests: 5, window: '60s', action: 'challenge' },\n '/api/*': { requests: 100, window: '60s', action: 'throttle' },\n '/webhooks/*': { requests: 200, window: '60s', action: 'throttle' },\n },\n botProtection: { mode: 'managed', allowList: ['googlebot', 'bingbot', 'stripe-webhooks'] },\n headers: {\n hsts: true,\n contentSecurityPolicy: 'strict',\n referrerPolicy: 'strict-origin-when-cross-origin',\n xFrameOptions: 'deny',\n },\n csrf: true,\n },\n\n commerce: {\n payments: { provider: 'native' },\n email: { provider: 'native' },\n reviews: { provider: 'native' },\n search: { provider: 'native' },\n analytics: { provider: 'native' },\n shipping: { provider: 'native' },\n tax: { provider: 'native' },\n ai: { provider: 'anthropic', config: { apiKey: env('ANTHROPIC_API_KEY', 'anthropic-placeholder') } },\n },\n\n http: {\n adapter: 'fastify',\n port: ${API_PORT},\n },\n\n deployment: {\n strategy: 'standard',\n },\n\n features: {\n staging: false,\n heartbeat: false,\n autoUpdates: false,\n },\n})\n`\n}\n\nfunction defaultLocale(currency: Answers['currency']): string {\n return currency === 'USD' ? 'en-US' : 'en-GB'\n}\n\nfunction defaultTimezone(currency: Answers['currency']): string {\n return currency === 'USD' ? 'America/New_York' : 'Europe/London'\n}\n\nfunction escape(v: string): string {\n return v.replace(/'/g, \"\\\\'\")\n}\n\nfunction toDirectoryName(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9-_ ]/g, '')\n .trim()\n .replace(/\\s+/g, '-')\n}\n\nconst runCmd: Runner = (cmd, args, cwd, env = process.env) => {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { cwd, env, stdio: 'inherit', shell: false })\n child.on('error', reject)\n child.on('exit', (code) => {\n if (code === 0) return resolve()\n reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`))\n })\n })\n}\n"],"mappings":"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","names":["name","answers","targetDir","admin","resolve"]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { cp, readFile, mkdir, readdir, rm, writeFile } from 'node:fs/promises'\nimport { existsSync } from 'node:fs'\nimport { dirname, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { spawn, spawnSync } from 'node:child_process'\nimport { randomBytes } from 'node:crypto'\nimport { cancel, confirm, intro, isCancel, note, outro, select, text } from '@clack/prompts'\nimport pc from 'picocolors'\n\nconst HERE = dirname(fileURLToPath(import.meta.url))\nconst SCAFFOLD_ROOT = resolve(HERE, '..', 'scaffold')\n\nexport type PackageManager = 'npm' | 'pnpm' | 'yarn-classic' | 'yarn-berry' | 'bun'\n\ninterface Answers {\n name: string\n directory: string\n currency: 'GBP' | 'USD' | 'EUR'\n template: 'astro' | 'nextjs'\n seedDemo: boolean\n}\n\n/**\n * Runs a command in `cwd` (with `env` when given, else the process environment) and rejects on a non-zero exit.\n * Injected into `main` so tests do not shell out.\n */\nexport type Runner = (\n cmd: string,\n args: string[],\n cwd: string,\n env?: NodeJS.ProcessEnv,\n) => Promise<void>\n\n/** The port `renderConfig` gives the API, which serves the admin at /admin/. */\nconst API_PORT = 3001\n\ninterface AdminCredentials {\n email: string\n password: string\n}\n\n/** A fresh admin password per project (128 bits, base64url), so no two stores share the seed's default. */\nfunction newAdminCredentials(): AdminCredentials {\n return { email: 'admin@nymbal.local', password: randomBytes(16).toString('base64url') }\n}\n\n/** `.env` holds the admin credentials the seed (and a first `nymbal dev`) creates the admin from. */\nasync function writeAdminEnv(dir: string, admin: AdminCredentials): Promise<void> {\n const lines = [\n '# The admin account the seed creates. Keep this file out of version control (.gitignore lists it).',\n `NYMBAL_ADMIN_EMAIL=${admin.email}`,\n `NYMBAL_ADMIN_PASSWORD=${admin.password}`,\n '',\n ]\n await writeFile(resolve(dir, '.env'), lines.join('\\n'), { encoding: 'utf8', mode: 0o600 })\n}\n\nfunction adminSignIn(admin: AdminCredentials): string[] {\n return [\n `Admin: http://localhost:${API_PORT}/admin/`,\n ` email ${admin.email}`,\n ` password ${admin.password} (saved in .env)`,\n ]\n}\n\nexport interface MainDeps {\n run?: Runner\n}\n\n/**\n * The `packageManager` field for the package manager that launched us (`npm_config_user_agent`), read when called.\n * Without a usable user agent it is the installed npm; undefined when npm cannot be run either.\n */\nfunction packageManagerField(): string | undefined {\n const token = (process.env['npm_config_user_agent'] ?? '').split(' ')[0] ?? ''\n const match = /^(npm|pnpm|yarn|bun)\\/(\\d+\\.\\d+\\.\\d+\\S*)$/.exec(token)\n if (match) return `${match[1]}@${match[2]}`\n const npm = spawnSync('npm', ['-v'], { encoding: 'utf8', shell: process.platform === 'win32' })\n const version = npm.status === 0 ? npm.stdout.trim() : ''\n return version ? `npm@${version}` : undefined\n}\n\nexport function detectPackageManager(): PackageManager {\n const ua = process.env['npm_config_user_agent']\n if (!ua) return 'npm'\n const token = ua.split(' ')[0] ?? ''\n const slash = token.indexOf('/')\n const pm = slash >= 0 ? token.slice(0, slash) : token\n const version = slash >= 0 ? token.slice(slash + 1) : ''\n if (pm === 'pnpm') return 'pnpm'\n if (pm === 'bun') return 'bun'\n if (pm === 'yarn') {\n const major = parseInt(version.split('.')[0] ?? '1', 10)\n return major < 2 ? 'yarn-classic' : 'yarn-berry'\n }\n return 'npm'\n}\n\nfunction installCmd(pm: PackageManager): [string, string[]] {\n switch (pm) {\n case 'pnpm':\n return ['pnpm', ['install']]\n case 'bun':\n return ['bun', ['install']]\n case 'yarn-classic':\n case 'yarn-berry':\n return ['yarn', ['install']]\n default:\n return ['npm', ['install']]\n }\n}\n\nfunction seedCmd(pm: PackageManager): [string, string[]] {\n switch (pm) {\n case 'pnpm':\n return ['pnpm', ['exec', 'nymbal', 'seed']]\n case 'bun':\n return ['bunx', ['nymbal', 'seed']]\n case 'yarn-classic':\n return ['yarn', ['run', 'nymbal', 'seed']]\n case 'yarn-berry':\n return ['yarn', ['exec', 'nymbal', 'seed']]\n default:\n return ['npx', ['nymbal', 'seed']]\n }\n}\n\nfunction devCmd(pm: PackageManager): string {\n switch (pm) {\n case 'pnpm':\n return 'pnpm dev'\n case 'bun':\n return 'bun dev'\n case 'yarn-classic':\n case 'yarn-berry':\n return 'yarn dev'\n default:\n return 'npm run dev'\n }\n}\n\nconst TEMPLATES = ['astro', 'nextjs'] as const\nconst CURRENCIES = ['GBP', 'USD', 'EUR'] as const\n\nexport interface CliFlags {\n name?: string\n template?: (typeof TEMPLATES)[number]\n currency?: (typeof CURRENCIES)[number]\n yes: boolean\n /** Present (false) only with `--no-install`. */\n install?: boolean\n}\n\n/** A command-line flag that is missing its value or names a value outside the allowed set. */\nexport class CliFlagError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'CliFlagError'\n }\n}\n\nfunction oneOf<T extends string>(flag: string, value: string, allowed: readonly T[]): T {\n if ((allowed as readonly string[]).includes(value)) return value as T\n throw new CliFlagError(`Unknown ${flag} \"${value}\". Allowed: ${allowed.join(', ')}`)\n}\n\n/** Parses process.argv (node and script first): --name, --template and --currency take `--flag v` or `--flag=v`. */\nexport function parseCliFlags(argv: string[]): CliFlags {\n const flags: CliFlags = { yes: false }\n const args = argv.slice(2)\n let positional: string | undefined\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]!\n if (arg === '--yes' || arg === '-y') {\n flags.yes = true\n continue\n }\n if (arg === '--no-install') {\n flags.install = false\n continue\n }\n const match = /^--(name|template|currency)(?:=(.*))?$/s.exec(arg)\n if (match) {\n const flag = match[1]!\n let value = match[2]\n if (value === undefined) {\n const next = args[i + 1]\n if (next === undefined || next.startsWith('-'))\n throw new CliFlagError(`--${flag} needs a value`)\n value = next\n i++\n }\n if (flag === 'name') flags.name = value\n else if (flag === 'template') flags.template = oneOf('--template', value, TEMPLATES)\n else flags.currency = oneOf('--currency', value, CURRENCIES)\n } else if (!arg.startsWith('-') && positional === undefined) {\n positional = arg\n }\n }\n if (flags.name === undefined && positional !== undefined) flags.name = positional\n return flags\n}\n\nexport async function main(argv: string[] = process.argv, deps: MainDeps = {}): Promise<void> {\n const run = deps.run ?? runCmd\n let flags: CliFlags\n try {\n flags = parseCliFlags(argv)\n } catch (err) {\n if (!(err instanceof CliFlagError)) throw err\n process.stderr.write(`Error: ${err.message}\\n`)\n process.exit(1)\n }\n\n const pm = detectPackageManager()\n\n if (flags.yes) {\n if (!flags.name) {\n process.stderr.write('Error: --yes requires --name or a positional name argument\\n')\n process.exit(1)\n }\n const name = flags.name.trim()\n const answers: Answers = {\n name,\n directory: toDirectoryName(name),\n currency: flags.currency ?? 'GBP',\n template: flags.template ?? 'astro',\n seedDemo: true,\n }\n const targetDir = resolve(process.cwd(), answers.directory)\n if (existsSync(targetDir)) {\n const entries = await readdir(targetDir)\n if (entries.length > 0) {\n process.stderr.write(\n `Error: Directory ${answers.directory} already exists and is not empty.\\n`,\n )\n process.exit(1)\n }\n } else {\n await mkdir(targetDir, { recursive: true })\n }\n await scaffold(targetDir, answers)\n const admin = newAdminCredentials()\n await writeAdminEnv(targetDir, admin)\n process.stdout.write(`Created ${answers.directory}\\n`)\n if (flags.install !== false) {\n await installAndSeed(targetDir, pm, answers.seedDemo, run, admin)\n } else {\n process.stdout.write(`Next: cd ${answers.directory} && ${installCmd(pm)[0]} install\\n`)\n }\n process.stdout.write(`${adminSignIn(admin).join('\\n')}\\n`)\n return\n }\n\n intro(pc.cyan(pc.bold('◆ create-nymbal-app')))\n\n const name = flags.name\n ? flags.name\n : ((await text({\n message: 'Store name',\n placeholder: 'My Store',\n validate: (v) => (!v || v.trim().length === 0 ? 'Required' : undefined),\n })) as string | symbol)\n\n if (isCancel(name)) return cancel('Cancelled')\n\n type Currency = 'GBP' | 'USD' | 'EUR'\n const currencyOptions: { value: Currency; label: string }[] = [\n { value: 'GBP', label: 'GBP — British Pound' },\n { value: 'USD', label: 'USD — US Dollar' },\n { value: 'EUR', label: 'EUR — Euro' },\n ]\n const currency = await select({\n message: 'Store currency',\n options: currencyOptions,\n initialValue: 'GBP' as Currency,\n })\n\n if (isCancel(currency)) return cancel('Cancelled')\n\n type Template = 'astro' | 'nextjs'\n const templateOptions: { value: Template; label: string }[] = [\n { value: 'astro', label: 'Astro (recommended — performance-first)' },\n { value: 'nextjs', label: 'Next.js (migration-friendly)' },\n ]\n const template = await select({\n message: 'Storefront template',\n options: templateOptions,\n initialValue: 'astro' as Template,\n })\n\n if (isCancel(template)) return cancel('Cancelled')\n\n const seedDemo = (await confirm({\n message: 'Generate demo content?',\n initialValue: true,\n })) as boolean | symbol\n\n if (isCancel(seedDemo)) return cancel('Cancelled')\n\n const shouldInstall =\n flags.install === false\n ? false\n : ((await confirm({\n message: 'Install dependencies now?',\n initialValue: true,\n })) as boolean | symbol)\n\n if (isCancel(shouldInstall)) return cancel('Cancelled')\n\n const answers: Answers = {\n name: String(name).trim(),\n directory: toDirectoryName(String(name).trim()),\n currency: currency as 'GBP' | 'USD' | 'EUR',\n template: template as 'astro' | 'nextjs',\n seedDemo: seedDemo as boolean,\n }\n\n const targetDir = resolve(process.cwd(), answers.directory)\n if (existsSync(targetDir)) {\n const entries = await readdir(targetDir)\n if (entries.length > 0) {\n cancel(`Directory ${answers.directory} already exists and is not empty.`)\n process.exit(1)\n }\n } else {\n await mkdir(targetDir, { recursive: true })\n }\n\n note(pc.dim(`Scaffolding into ${targetDir}`), 'Creating project')\n\n await scaffold(targetDir, answers)\n const admin = newAdminCredentials()\n await writeAdminEnv(targetDir, admin)\n\n if (shouldInstall) {\n await installAndSeed(targetDir, pm, answers.seedDemo, run, admin)\n }\n\n const cmd = devCmd(pm)\n const nextSteps = [` ${pc.cyan('cd')} ${answers.directory}`]\n if (!shouldInstall) {\n const [ic] = installCmd(pm)\n nextSteps.push(` ${pc.cyan(ic + ' install')}`)\n }\n nextSteps.push(` ${pc.cyan(cmd)}`)\n\n outro(\n [pc.green(`✓ Created ${answers.directory}`), '', ...nextSteps, '', ...adminSignIn(admin)].join(\n '\\n',\n ),\n )\n}\n\n/** Install with the detected package manager, then seed through the project's own `nymbal` bin. */\nasync function installAndSeed(\n dir: string,\n pm: PackageManager,\n seedDemo: boolean,\n run: Runner,\n admin: AdminCredentials,\n): Promise<void> {\n const [installBin, installArgs] = installCmd(pm)\n process.stdout.write(`Running ${installBin} install (this may take a moment)...\\n`)\n await run(installBin, installArgs, dir)\n if (seedDemo) {\n process.stdout.write('Seeding demo content...\\n')\n const [seedBin, seedArgs] = seedCmd(pm)\n // Explicit, so a value exported in the shell cannot replace the one in .env and the outro.\n await run(seedBin, seedArgs, dir, {\n ...process.env,\n NYMBAL_ADMIN_EMAIL: admin.email,\n NYMBAL_ADMIN_PASSWORD: admin.password,\n })\n }\n}\n\nexport async function scaffold(dir: string, answers: Answers): Promise<void> {\n // Copy the chosen template directly into dir (flat — no storefront/ subdirectory)\n await copyScaffoldDir(resolve(SCAFFOLD_ROOT, 'templates', answers.template), dir)\n\n // Overlay the project layer (scaffold/project/<template>) on top of the template\n // defaults. Project files WIN on path collision — this is the override model:\n // anything in the project layer shadows the template default at the same path.\n // scaffold/project/ lives outside scaffold/templates/, so sync-scaffold's\n // rm -rf of scaffold/templates/* never deletes it.\n const projectLayer = resolve(SCAFFOLD_ROOT, 'project', answers.template)\n if (existsSync(projectLayer)) {\n await copyScaffoldDir(projectLayer, dir)\n }\n\n // Write nymbal.config.ts (answers-dependent, so generated at runtime)\n await writeFile(resolve(dir, 'nymbal.config.ts'), renderConfig(answers), 'utf8')\n\n // Patch the copied package.json: set name to the project directory name\n await patchPackageJson(dir, answers.directory)\n}\n\nasync function patchPackageJson(dir: string, name: string): Promise<void> {\n const pkgPath = resolve(dir, 'package.json')\n const pkg = JSON.parse(await readFile(pkgPath, 'utf8')) as Record<string, unknown>\n pkg['name'] = name\n pkg['private'] = true\n const packageManager = packageManagerField()\n if (packageManager) pkg['packageManager'] = packageManager\n await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\\n', 'utf8')\n}\n\nasync function copyScaffoldDir(src: string, dest: string): Promise<void> {\n if (!existsSync(src)) {\n throw new Error(`Scaffold directory not found: ${src}`)\n }\n await cp(src, dest, {\n recursive: true,\n filter: (source) => !source.endsWith('node_modules'),\n })\n await renameDotFiles(dest)\n}\n\nasync function renameDotFiles(dir: string): Promise<void> {\n // Non-recursive: only rename _dot.* files at the project root.\n // All shared dotfiles (_dot.gitignore, _dot.env.example) live at template root.\n const entries = await readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isDirectory() && entry.name.startsWith('_dot.')) {\n const full = resolve(dir, entry.name)\n const renamed = resolve(dir, '.' + entry.name.slice(5))\n await writeFile(renamed, await readFile(full))\n await rm(full)\n }\n }\n}\n\nfunction renderConfig(answers: Answers): string {\n return `import { defineConfig } from '@nymbalhq/config'\n\nexport default defineConfig({\n store: {\n name: '${escape(answers.name)}',\n currency: '${answers.currency}',\n locale: '${defaultLocale(answers.currency)}',\n timezone: '${defaultTimezone(answers.currency)}',\n },\n\n template: '${answers.template}',\n\n infrastructure: {\n commandStore: 'sqlite',\n documentStore: 'in-memory',\n eventBus: 'in-process',\n },\n\n security: {\n adapter: 'middleware',\n rateLimit: {\n '/checkout': { requests: 10, window: '60s', action: 'block' },\n '/auth/*': { requests: 5, window: '60s', action: 'challenge' },\n '/api/*': { requests: 100, window: '60s', action: 'throttle' },\n '/webhooks/*': { requests: 200, window: '60s', action: 'throttle' },\n },\n botProtection: { mode: 'managed', allowList: ['googlebot', 'bingbot', 'stripe-webhooks'] },\n headers: {\n hsts: true,\n contentSecurityPolicy: 'strict',\n referrerPolicy: 'strict-origin-when-cross-origin',\n xFrameOptions: 'deny',\n },\n csrf: true,\n },\n\n commerce: {\n payments: { provider: 'native' },\n email: { provider: 'native' },\n reviews: { provider: 'native' },\n search: { provider: 'native' },\n analytics: { provider: 'native' },\n shipping: { provider: 'native' },\n tax: { provider: 'native' },\n ai: { provider: 'none' },\n },\n\n http: {\n adapter: 'fastify',\n port: ${API_PORT},\n },\n\n deployment: {\n strategy: 'standard',\n },\n\n features: {\n staging: false,\n heartbeat: false,\n autoUpdates: false,\n },\n})\n`\n}\n\nfunction defaultLocale(currency: Answers['currency']): string {\n return currency === 'USD' ? 'en-US' : 'en-GB'\n}\n\nfunction defaultTimezone(currency: Answers['currency']): string {\n return currency === 'USD' ? 'America/New_York' : 'Europe/London'\n}\n\nfunction escape(v: string): string {\n return v.replace(/'/g, \"\\\\'\")\n}\n\nfunction toDirectoryName(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9-_ ]/g, '')\n .trim()\n .replace(/\\s+/g, '-')\n}\n\nconst runCmd: Runner = (cmd, args, cwd, env = process.env) => {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { cwd, env, stdio: 'inherit', shell: false })\n child.on('error', reject)\n child.on('exit', (code) => {\n if (code === 0) return resolve()\n reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`))\n })\n })\n}\n"],"mappings":"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","names":["name","answers","targetDir","admin","resolve"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nymbal-app",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffold a new Nymbal commerce project",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,15 +17,14 @@
17
17
  "seed": "nymbal seed"
18
18
  },
19
19
  "dependencies": {
20
- "nymbal": "0.2.0",
21
- "@nymbalhq/config": "0.2.0",
22
- "@nymbalhq/platform": "0.2.0",
23
- "@nymbalhq/stack": "0.2.0",
20
+ "nymbal": "1.1.0",
21
+ "@nymbalhq/config": "1.1.0",
22
+ "@nymbalhq/platform": "1.1.0",
23
+ "@nymbalhq/stack": "1.1.0",
24
24
  "@astrojs/node": "^11.1.8",
25
- "@nymbalhq/sdk": "0.2.0",
26
- "@nymbalhq/types": "0.2.0",
27
- "@nymbalhq/web-components": "0.2.0",
28
- "@stripe/stripe-js": "^4.0.0",
25
+ "@nymbalhq/sdk": "1.1.0",
26
+ "@nymbalhq/types": "1.1.0",
27
+ "@nymbalhq/web-components": "1.1.0",
29
28
  "astro": "^7.4.0"
30
29
  },
31
30
  "devDependencies": {
@@ -34,26 +33,26 @@
34
33
  },
35
34
  "pnpm": {
36
35
  "overrides": {
37
- "nymbal": "0.2.0",
38
- "@nymbalhq/config": "0.2.0",
39
- "@nymbalhq/http": "0.2.0",
40
- "@nymbalhq/platform": "0.2.0",
41
- "@nymbalhq/react": "0.2.0",
42
- "@nymbalhq/sdk": "0.2.0",
43
- "@nymbalhq/stack": "0.2.0",
44
- "@nymbalhq/types": "0.2.0",
45
- "@nymbalhq/web-components": "0.2.0"
36
+ "nymbal": "1.1.0",
37
+ "@nymbalhq/config": "1.1.0",
38
+ "@nymbalhq/http": "1.1.0",
39
+ "@nymbalhq/platform": "1.1.0",
40
+ "@nymbalhq/react": "1.1.0",
41
+ "@nymbalhq/sdk": "1.1.0",
42
+ "@nymbalhq/stack": "1.1.0",
43
+ "@nymbalhq/types": "1.1.0",
44
+ "@nymbalhq/web-components": "1.1.0"
46
45
  }
47
46
  },
48
47
  "overrides": {
49
- "nymbal": "0.2.0",
50
- "@nymbalhq/config": "0.2.0",
51
- "@nymbalhq/http": "0.2.0",
52
- "@nymbalhq/platform": "0.2.0",
53
- "@nymbalhq/react": "0.2.0",
54
- "@nymbalhq/sdk": "0.2.0",
55
- "@nymbalhq/stack": "0.2.0",
56
- "@nymbalhq/types": "0.2.0",
57
- "@nymbalhq/web-components": "0.2.0"
48
+ "nymbal": "1.1.0",
49
+ "@nymbalhq/config": "1.1.0",
50
+ "@nymbalhq/http": "1.1.0",
51
+ "@nymbalhq/platform": "1.1.0",
52
+ "@nymbalhq/react": "1.1.0",
53
+ "@nymbalhq/sdk": "1.1.0",
54
+ "@nymbalhq/stack": "1.1.0",
55
+ "@nymbalhq/types": "1.1.0",
56
+ "@nymbalhq/web-components": "1.1.0"
58
57
  }
59
58
  }
@@ -1,4 +1,8 @@
1
1
  ---
2
+ import { fetchCapabilities } from '../lib/api'
3
+
4
+ const capabilities = await fetchCapabilities()
5
+ const canSearch = capabilities?.search.capabilities.includes('search') ?? false
2
6
  ---
3
7
  <header class="site-header" data-testid="site-header">
4
8
  <div class="header-container container">
@@ -21,7 +25,9 @@
21
25
  </nav>
22
26
 
23
27
  <div class="header-actions">
24
- <nymbal-search-bar placeholder="Search products..." debounce-ms="300"></nymbal-search-bar>
28
+ {canSearch && (
29
+ <nymbal-search-bar placeholder="Search products..." debounce-ms="300"></nymbal-search-bar>
30
+ )}
25
31
  <a href="/account" class="header-account-link" aria-label="Account">
26
32
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
27
33
  <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
@@ -61,33 +61,7 @@ const { productId } = Astro.props
61
61
  </form>
62
62
 
63
63
  <script>
64
- import { client } from '../lib/client.js'
65
-
66
- const form = document.getElementById('review-form') as HTMLFormElement | null
67
- const messageEl = document.getElementById('review-form-message')
68
-
69
- form?.addEventListener('submit', async (e) => {
70
- e.preventDefault()
71
- if (!form || !messageEl) return
72
-
73
- const productId = form.dataset.productId ?? ''
74
- const formData = new FormData(form)
75
- const rating = Number(formData.get('rating'))
76
- const title = String(formData.get('title'))
77
- const body = String(formData.get('body'))
78
-
79
- try {
80
- await client.adapter.reviews.submit({ productId, rating, title, body })
81
- messageEl.textContent = 'Thank you for your review!'
82
- messageEl.style.display = 'block'
83
- messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
84
- form.reset()
85
- } catch {
86
- messageEl.textContent = 'Something went wrong. Please try again.'
87
- messageEl.style.display = 'block'
88
- messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
89
- }
90
- })
64
+ import '../scripts/review-form.js'
91
65
  </script>
92
66
 
93
67
  <style>
@@ -1,32 +1,15 @@
1
1
  ---
2
+ import { STOCK_LABELS, stockLevel } from '@nymbalhq/types'
3
+
2
4
  interface Props {
3
5
  stock: number
4
6
  }
5
7
 
6
8
  const { stock } = Astro.props
7
-
8
- let statusClass: string
9
- let label: string
10
-
11
- if (stock <= 0) {
12
- statusClass = 'out-of-stock'
13
- label = 'Out of Stock'
14
- } else if (stock < 5) {
15
- statusClass = 'low-stock'
16
- label = 'Low Stock'
17
- } else {
18
- statusClass = 'in-stock'
19
- label = 'In Stock'
20
- }
9
+ const level = stockLevel(stock)
21
10
  ---
22
11
 
23
- <span class={`pdp-stock ${statusClass}`} data-testid="stock-badge">
12
+ <span class="pdp-stock" data-testid="stock-badge" data-level={level}>
24
13
  <span class="pdp-stock-dot" aria-hidden="true"></span>
25
- {label}
14
+ <span data-stock-label>{STOCK_LABELS[level]}</span>
26
15
  </span>
27
-
28
- <style>
29
- .low-stock {
30
- color: var(--nymbal-color-warning, #d97706);
31
- }
32
- </style>
@@ -102,6 +102,8 @@ const { title } = Astro.props
102
102
  }
103
103
 
104
104
  .account-sidebar {
105
+ /* Lets the one-column track shrink to the viewport, so the nav row scrolls instead of widening the page. */
106
+ min-width: 0;
105
107
  border-bottom: 1px solid var(--nymbal-color-border, #e4e4e7);
106
108
  padding-bottom: var(--nymbal-spacing-md, 1rem);
107
109
  }
@@ -1,6 +1,6 @@
1
1
  import type { DenormalisedProduct, ProductListResult, ReviewsListResult } from '@nymbalhq/sdk'
2
2
  import { DEFAULT_PRODUCT_SORT, PRODUCT_SORT_KEYS } from '@nymbalhq/types'
3
- import type { Category, Order, ProductSortKey } from '@nymbalhq/types'
3
+ import type { Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
4
4
 
5
5
  const API_URL = import.meta.env.NYMBAL_API_URL ?? 'http://localhost:3001'
6
6
 
@@ -27,10 +27,24 @@ export async function fetchProducts(params?: {
27
27
  }
28
28
  }
29
29
 
30
- /** The sort a listing URL asks for; a missing or unknown `?sort=` is the default. */
31
- export function sortFromUrl(url: URL): ProductSortKey {
30
+ /** What the store can do, which decides what the page renders; null when the API did not answer. */
31
+ export async function fetchCapabilities(): Promise<StoreCapabilities | null> {
32
+ try {
33
+ const res = await fetch(`${API_URL}/api/capabilities`)
34
+ if (!res.ok) return null
35
+ const envelope = (await res.json()) as { data: StoreCapabilities }
36
+ return envelope.data ?? null
37
+ } catch (err) {
38
+ console.error('[api] fetchCapabilities failed', err)
39
+ return null
40
+ }
41
+ }
42
+
43
+ /** The sort a listing URL asks for, among the store's keys; a missing or unknown `?sort=` is the store's default. */
44
+ export function sortFromUrl(url: URL, catalog?: StoreCapabilities['catalog']): ProductSortKey {
32
45
  const sort = url.searchParams.get('sort')
33
- return PRODUCT_SORT_KEYS.find((key) => key === sort) ?? DEFAULT_PRODUCT_SORT
46
+ const keys = catalog?.sortKeys ?? PRODUCT_SORT_KEYS
47
+ return keys.find((key) => key === sort) ?? catalog?.defaultSort ?? DEFAULT_PRODUCT_SORT
34
48
  }
35
49
 
36
50
  export async function fetchProduct(slug: string): Promise<DenormalisedProduct | null> {
@@ -7,6 +7,8 @@ const client = createNymbalClient({
7
7
  })
8
8
 
9
9
  registerClient(client)
10
+ // A payment driver of the project's own goes here, before the components: registerPaymentDriver(name, driver) from
11
+ // @nymbalhq/sdk, named by the driver of the payments adapter's client half.
10
12
  registerComponents()
11
13
  client.cart.load()
12
14
 
@@ -0,0 +1,44 @@
1
+ /** What a handler resolves when it has sent the browser elsewhere (`location.href = …`): the form stays locked. */
2
+ export type SubmitOutcome = void | 'leaving'
3
+
4
+ export type SubmitHandler = (event: SubmitEvent) => Promise<SubmitOutcome> | SubmitOutcome
5
+
6
+ function submitButtonOf(event: SubmitEvent, form: HTMLFormElement): HTMLButtonElement | null {
7
+ if (event.submitter instanceof HTMLButtonElement) return event.submitter
8
+ return form.querySelector<HTMLButtonElement>('button[type="submit"], button:not([type])')
9
+ }
10
+
11
+ /**
12
+ * Runs `handler` for a submit of `form`, ignoring every further submit until it settles. The flag, not the button, is
13
+ * the guard: `requestSubmit()` and Enter in a field still fire `submit` while the button is disabled.
14
+ */
15
+ export function submitOnce(form: HTMLFormElement | null, handler: SubmitHandler): void {
16
+ if (form === null) return
17
+ let inFlight = false
18
+
19
+ form.addEventListener('submit', (event) => {
20
+ event.preventDefault()
21
+ if (inFlight) return
22
+ inFlight = true
23
+ const button = submitButtonOf(event as SubmitEvent, form)
24
+ if (button) button.disabled = true
25
+
26
+ const release = () => {
27
+ inFlight = false
28
+ if (button) button.disabled = false
29
+ }
30
+
31
+ // Inside `then`, a synchronous throw from the handler becomes a rejection and releases the form like one.
32
+ Promise.resolve()
33
+ .then(() => handler(event as SubmitEvent))
34
+ .then(
35
+ (outcome) => {
36
+ if (outcome !== 'leaving') release()
37
+ },
38
+ (reason: unknown) => {
39
+ release()
40
+ console.error('A form submit handler failed', reason)
41
+ },
42
+ )
43
+ })
44
+ }
@@ -30,6 +30,7 @@ import AddressForm from '../../components/AddressForm.astro'
30
30
  import type { Address, CustomerAddress } from '@nymbalhq/types'
31
31
  import { client } from '../../lib/client.js'
32
32
  import { escapeHtml } from '../../lib/format.js'
33
+ import { submitOnce } from '../../lib/submit-once.js'
33
34
 
34
35
  const container = document.getElementById('addresses-container')
35
36
  const form = document.getElementById('add-address-form') as HTMLFormElement | null
@@ -75,8 +76,7 @@ import AddressForm from '../../components/AddressForm.astro'
75
76
  }
76
77
  }
77
78
 
78
- form?.addEventListener('submit', async (e) => {
79
- e.preventDefault()
79
+ submitOnce(form, async () => {
80
80
  if (!form || !messageEl) return
81
81
 
82
82
  const formData = new FormData(form)
@@ -65,67 +65,5 @@ import AccountLayout from '../../layouts/AccountLayout.astro'
65
65
  </AccountLayout>
66
66
 
67
67
  <script>
68
- import { client } from '../../lib/client.js'
69
-
70
- const form = document.getElementById('profile-form') as HTMLFormElement | null
71
- const messageEl = document.getElementById('profile-message')
72
-
73
- function populateForm() {
74
- const profile = client.auth.getState().customer
75
- if (!profile) return
76
-
77
- const firstNameEl = document.getElementById('profile-firstName') as HTMLInputElement
78
- const lastNameEl = document.getElementById('profile-lastName') as HTMLInputElement
79
- const emailEl = document.getElementById('profile-email') as HTMLInputElement
80
- const phoneEl = document.getElementById('profile-phone') as HTMLInputElement
81
-
82
- if (firstNameEl) firstNameEl.value = profile.firstName ?? ''
83
- if (lastNameEl) lastNameEl.value = profile.lastName ?? ''
84
- if (emailEl) emailEl.value = profile.email ?? ''
85
- if (phoneEl) phoneEl.value = profile.phone ?? ''
86
- }
87
-
88
- // Load profile data
89
- async function init() {
90
- try {
91
- await client.auth.loadProfile()
92
- } catch {
93
- // ignore
94
- }
95
- populateForm()
96
- }
97
-
98
- client.auth.subscribe(populateForm)
99
- init()
100
-
101
- form?.addEventListener('submit', async (e) => {
102
- e.preventDefault()
103
- if (!form || !messageEl) return
104
-
105
- const firstName = (document.getElementById('profile-firstName') as HTMLInputElement)?.value
106
- const lastName = (document.getElementById('profile-lastName') as HTMLInputElement)?.value
107
- const phone = (document.getElementById('profile-phone') as HTMLInputElement)?.value
108
-
109
- const submitBtn = form.querySelector('[data-testid="profile-save"]') as HTMLButtonElement
110
- if (submitBtn) {
111
- submitBtn.disabled = true
112
- submitBtn.textContent = 'Saving...'
113
- }
114
-
115
- try {
116
- await client.auth.updateProfile({ firstName, lastName, phone })
117
- messageEl.textContent = 'Profile updated successfully.'
118
- messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
119
- messageEl.style.display = 'block'
120
- } catch (err) {
121
- messageEl.textContent = err instanceof Error ? err.message : 'Failed to update profile.'
122
- messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
123
- messageEl.style.display = 'block'
124
- } finally {
125
- if (submitBtn) {
126
- submitBtn.disabled = false
127
- submitBtn.textContent = 'Save Changes'
128
- }
129
- }
130
- })
68
+ import '../../scripts/profile-form.js'
131
69
  </script>
@@ -4,12 +4,14 @@ import '../../styles/pages/home.css'
4
4
  import BaseLayout from '../../layouts/BaseLayout.astro'
5
5
  import Breadcrumb from '../../components/Breadcrumb.astro'
6
6
  import ProductCard from '../../components/ProductCard.astro'
7
- import SortDropdown from '../../components/SortDropdown.astro'
8
7
  import Pagination from '../../components/Pagination.astro'
9
- import { fetchProducts, fetchCategories, sortFromUrl } from '../../lib/api'
8
+ import { PRODUCT_SORT_LABELS } from '@nymbalhq/types'
9
+ import { fetchProducts, fetchCategories, fetchCapabilities, sortFromUrl } from '../../lib/api'
10
10
 
11
11
  const { slug } = Astro.params
12
- const sort = sortFromUrl(Astro.url)
12
+ const capabilities = await fetchCapabilities()
13
+ const sortKeys = capabilities?.catalog.sortKeys ?? []
14
+ const sort = sortFromUrl(Astro.url, capabilities?.catalog)
13
15
 
14
16
  const [productResult, categories] = await Promise.all([
15
17
  fetchProducts({ limit: 24, category: slug, sort }),
@@ -41,13 +43,30 @@ const facets = productResult.facets ?? []
41
43
 
42
44
  <div class="plp-layout">
43
45
  <aside class="plp-sidebar">
44
- <nymbal-product-filter
45
- facets={JSON.stringify(facets)}
46
- ></nymbal-product-filter>
46
+ {facets.length > 0 && (
47
+ <nymbal-product-filter
48
+ facets={JSON.stringify(facets)}
49
+ ></nymbal-product-filter>
50
+ )}
47
51
  </aside>
48
52
 
49
53
  <div class="plp-grid-area">
50
- <SortDropdown sort={sort} />
54
+ {sortKeys.length > 0 && (
55
+ <div class="plp-sort-bar">
56
+ <label for="sort-select">Sort by</label>
57
+ <nymbal-sort-dropdown
58
+ sort-keys={JSON.stringify(sortKeys)}
59
+ value={sort}
60
+ default-sort={capabilities?.catalog.defaultSort}
61
+ >
62
+ <select id="sort-select" data-testid="sort-dropdown">
63
+ {sortKeys.map((key) => (
64
+ <option value={key} selected={key === sort}>{PRODUCT_SORT_LABELS[key]}</option>
65
+ ))}
66
+ </select>
67
+ </nymbal-sort-dropdown>
68
+ </div>
69
+ )}
51
70
 
52
71
  <div class="product-grid" id="product-grid" data-category={slug}>
53
72
  {products.map((product) => (