appfunnel 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -21,9 +21,7 @@ function formatError(err) {
21
21
  return lines.join("\n");
22
22
  }
23
23
  function formatWarning(code, message, hint) {
24
- const lines = [
25
- `${pc.yellow("WARNING")} ${pc.dim(`[${code}]`)}: ${message}`
26
- ];
24
+ const lines = [`${pc.yellow("WARNING")} ${pc.dim(`[${code}]`)}: ${message}`];
27
25
  if (hint) {
28
26
  lines.push(` ${pc.dim("Hint:")} ${hint}`);
29
27
  }
@@ -175,28 +173,314 @@ var init_projects = __esm({
175
173
  }
176
174
  });
177
175
 
176
+ // src/lib/api.ts
177
+ async function apiFetch(path, options) {
178
+ const { token, apiBaseUrl, ...fetchOpts } = options;
179
+ const base = apiBaseUrl || DEFAULT_API_BASE2;
180
+ const url = `${base}${path}`;
181
+ const isFormData = fetchOpts.body instanceof FormData;
182
+ const headers = {
183
+ Authorization: token,
184
+ ...fetchOpts.headers || {}
185
+ };
186
+ if (!isFormData) {
187
+ headers["Content-Type"] = "application/json";
188
+ }
189
+ const response = await fetch(url, {
190
+ ...fetchOpts,
191
+ headers
192
+ });
193
+ if (!response.ok) {
194
+ const body = await response.text().catch(() => "");
195
+ let message = `API request failed: ${response.status} ${response.statusText}`;
196
+ try {
197
+ const parsed = JSON.parse(body);
198
+ if (parsed.error) message = parsed.error;
199
+ if (parsed.message) message = parsed.message;
200
+ } catch {
201
+ }
202
+ const error2 = new CLIError("API_ERROR", message);
203
+ error2.statusCode = response.status;
204
+ throw error2;
205
+ }
206
+ return response;
207
+ }
208
+ async function fetchPrices(projectId, storePriceIds, options) {
209
+ if (storePriceIds.length === 0) return /* @__PURE__ */ new Map();
210
+ const response = await apiFetch(`/project/${projectId}/headless/prices`, {
211
+ ...options,
212
+ method: "POST",
213
+ body: JSON.stringify({ storePriceIds })
214
+ });
215
+ const data = await response.json();
216
+ return new Map(Object.entries(data.prices || {}));
217
+ }
218
+ async function fetchStores(projectId, options) {
219
+ const response = await apiFetch(`/project/${projectId}/stores`, {
220
+ ...options,
221
+ method: "GET"
222
+ });
223
+ const data = await response.json();
224
+ return data.data || [];
225
+ }
226
+ async function fetchStorePrices(projectId, storeId, options) {
227
+ const response = await apiFetch(
228
+ `/project/${projectId}/stores/${storeId}/prices`,
229
+ { ...options, method: "GET" }
230
+ );
231
+ const data = await response.json();
232
+ return data.data || [];
233
+ }
234
+ async function publishBuild(projectId, funnelId, manifest, assets, options, promote) {
235
+ const formData = new FormData();
236
+ formData.set("manifest", JSON.stringify(manifest));
237
+ if (funnelId) {
238
+ formData.set("funnelId", funnelId);
239
+ }
240
+ if (promote) {
241
+ formData.set("promote", "true");
242
+ }
243
+ for (const asset of assets) {
244
+ formData.append(
245
+ "assets",
246
+ new Blob([new Uint8Array(asset.content)], { type: asset.contentType }),
247
+ asset.path
248
+ );
249
+ }
250
+ try {
251
+ const response = await apiFetch(`/project/${projectId}/headless/publish`, {
252
+ ...options,
253
+ method: "POST",
254
+ body: formData
255
+ });
256
+ return await response.json();
257
+ } catch (err) {
258
+ if (err instanceof CLIError && err.code === "API_ERROR") {
259
+ if (err.statusCode === 413) {
260
+ throw new CLIError(
261
+ "BUNDLE_TOO_LARGE",
262
+ err.message,
263
+ "Reduce page bundle sizes. Check for large dependencies."
264
+ );
265
+ }
266
+ if (err.statusCode === 409) {
267
+ throw new CLIError(
268
+ "FUNNEL_NOT_HEADLESS",
269
+ err.message,
270
+ "Remove funnelId from config to create a new headless funnel."
271
+ );
272
+ }
273
+ throw new CLIError("PUBLISH_FAILED", err.message);
274
+ }
275
+ throw err;
276
+ }
277
+ }
278
+ var DEFAULT_API_BASE2;
279
+ var init_api = __esm({
280
+ "src/lib/api.ts"() {
281
+ "use strict";
282
+ init_errors();
283
+ DEFAULT_API_BASE2 = "https://api.appfunnel.net";
284
+ }
285
+ });
286
+
178
287
  // src/commands/init.ts
179
288
  var init_exports = {};
180
289
  __export(init_exports, {
181
290
  initCommand: () => initCommand
182
291
  });
183
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, existsSync } from "fs";
184
- import { join as join2 } from "path";
292
+ import { cpSync, existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, readdirSync } from "fs";
293
+ import { join as join2, dirname } from "path";
294
+ import { fileURLToPath } from "url";
185
295
  import pc4 from "picocolors";
186
- async function initCommand(name) {
296
+ import select2 from "@inquirer/select";
297
+ import input from "@inquirer/input";
298
+ function getTemplatesDir() {
299
+ const dir = join2(__dirname, "..", "templates");
300
+ if (!existsSync(dir)) {
301
+ throw new Error(`Templates directory not found at ${dir}`);
302
+ }
303
+ return dir;
304
+ }
305
+ function listTemplates() {
306
+ const root = getTemplatesDir();
307
+ return readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
308
+ const configPath = join2(root, d.name, "template.json");
309
+ const config = existsSync(configPath) ? JSON.parse(readFileSync2(configPath, "utf-8")) : { name: d.name, description: "", products: [] };
310
+ return { dir: d.name, config };
311
+ });
312
+ }
313
+ function formatInterval(interval, count) {
314
+ if (!interval) return " one-time";
315
+ const label = INTERVAL_LABELS[interval]?.[count];
316
+ if (label) return `/${label}`;
317
+ return `/${count} ${interval}${count > 1 ? "s" : ""}`;
318
+ }
319
+ function formatPrice(price) {
320
+ const amount = (price.amount / 100).toFixed(2);
321
+ const currency = price.currency.toUpperCase();
322
+ const interval = formatInterval(price.interval, price.intervalCount);
323
+ const productName = price.name || "Unnamed product";
324
+ const priceName = price.priceName ? ` \u2014 ${price.priceName}` : "";
325
+ return `${currency} ${amount}${interval} ${pc4.dim(`${productName}${priceName}`)} ${pc4.dim(`(${price.id})`)}`;
326
+ }
327
+ function formatStoreName(store) {
328
+ const test = store.isTestMode ? pc4.yellow(" (test)") : "";
329
+ return `${store.name || store.type}${test}`;
330
+ }
331
+ async function initCommand(nameArg) {
187
332
  const creds = requireAuth();
188
- const dir = join2(process.cwd(), name);
189
- if (existsSync(dir)) {
190
- error(`Directory '${name}' already exists.`);
191
- process.exit(1);
333
+ const apiOpts = { token: creds.token };
334
+ const name = nameArg?.trim() || await input({
335
+ message: "Funnel name",
336
+ validate: (value) => {
337
+ if (!value.trim()) return "Name is required";
338
+ if (!/^[a-z0-9-]+$/.test(value.trim()))
339
+ return "Use lowercase letters, numbers, and hyphens only";
340
+ if (existsSync(join2(process.cwd(), value.trim())))
341
+ return `Directory '${value.trim()}' already exists`;
342
+ return true;
343
+ }
344
+ });
345
+ if (nameArg) {
346
+ if (!/^[a-z0-9-]+$/.test(name))
347
+ throw new Error("Name must be lowercase letters, numbers, and hyphens only");
348
+ if (existsSync(join2(process.cwd(), name)))
349
+ throw new Error(`Directory '${name}' already exists`);
192
350
  }
351
+ const dir = join2(process.cwd(), name.trim());
193
352
  const projectId = await promptForProject(creds.token);
194
353
  const projects = await fetchProjects(creds.token);
195
354
  const project = projects.find((p) => p.id === projectId);
355
+ const templates = listTemplates();
356
+ const selectedDir = await select2({
357
+ message: "Choose a template",
358
+ choices: templates.map((t) => ({
359
+ name: t.config.description ? `${t.config.name} \u2014 ${pc4.dim(t.config.description)}` : t.config.name,
360
+ value: t.dir
361
+ }))
362
+ });
363
+ const chosen = templates.find((t) => t.dir === selectedDir);
364
+ const templateConfig = chosen.config;
365
+ const templateDir = join2(getTemplatesDir(), chosen.dir);
366
+ const productBindings = [];
367
+ if (templateConfig.products.length > 0) {
368
+ const storesSpinner = spinner("Fetching stores...");
369
+ const stores = await fetchStores(projectId, apiOpts);
370
+ storesSpinner.stop();
371
+ if (stores.length === 0) {
372
+ warn("No stores found for this project.");
373
+ info("Products will use empty IDs \u2014 create a store in the dashboard and update appfunnel.config.ts.");
374
+ } else {
375
+ let store;
376
+ if (stores.length === 1) {
377
+ store = stores[0];
378
+ info(`Using store: ${formatStoreName(store)}`);
379
+ } else {
380
+ const storeId = await select2({
381
+ message: "Choose a store",
382
+ choices: stores.map((s2) => ({
383
+ name: formatStoreName(s2),
384
+ value: s2.id
385
+ }))
386
+ });
387
+ store = stores.find((s2) => s2.id === storeId);
388
+ }
389
+ const pricesSpinner = spinner("Fetching prices...");
390
+ const prices = await fetchStorePrices(projectId, store.id, apiOpts);
391
+ pricesSpinner.stop();
392
+ if (prices.length === 0) {
393
+ warn("No prices found in this store.");
394
+ info("Import prices in the dashboard, then update appfunnel.config.ts.");
395
+ } else {
396
+ console.log();
397
+ info(`Configuring ${templateConfig.products.length} product(s)...`);
398
+ console.log();
399
+ for (const product of templateConfig.products) {
400
+ if (product.description) {
401
+ console.log(` ${pc4.dim(product.description)}`);
402
+ }
403
+ const storePriceId = await select2({
404
+ message: `${product.label} \u2014 select a price`,
405
+ choices: prices.map((p) => ({
406
+ name: formatPrice(p),
407
+ value: p.id
408
+ }))
409
+ });
410
+ const binding = {
411
+ id: product.id,
412
+ name: product.id,
413
+ storePriceId
414
+ };
415
+ if (product.paidTrial) {
416
+ const trialStorePriceId = await select2({
417
+ message: `${product.label} \u2014 select a trial price`,
418
+ choices: [
419
+ ...prices.map((p) => ({
420
+ name: formatPrice(p),
421
+ value: p.id
422
+ })),
423
+ { name: pc4.dim("Skip (no trial)"), value: "" }
424
+ ]
425
+ });
426
+ if (trialStorePriceId) {
427
+ binding.trialStorePriceId = trialStorePriceId;
428
+ const trialDaysStr = await select2({
429
+ message: `${product.label} \u2014 trial duration`,
430
+ choices: [
431
+ { name: "3 days", value: "3" },
432
+ { name: "7 days", value: "7" },
433
+ { name: "14 days", value: "14" },
434
+ { name: "30 days", value: "30" },
435
+ { name: "90 days", value: "90" }
436
+ ]
437
+ });
438
+ binding.trialDays = parseInt(trialDaysStr, 10);
439
+ }
440
+ }
441
+ productBindings.push(binding);
442
+ }
443
+ }
444
+ }
445
+ }
196
446
  const s = spinner(`Creating ${name}...`);
197
- mkdirSync2(join2(dir, "src", "pages"), { recursive: true });
198
- mkdirSync2(join2(dir, "src", "components"), { recursive: true });
199
- mkdirSync2(join2(dir, "locales"), { recursive: true });
447
+ cpSync(templateDir, dir, { recursive: true });
448
+ const templateJsonPath = join2(dir, "template.json");
449
+ if (existsSync(templateJsonPath)) {
450
+ const { unlinkSync } = await import("fs");
451
+ unlinkSync(templateJsonPath);
452
+ }
453
+ const configPath = join2(dir, "appfunnel.config.ts");
454
+ if (existsSync(configPath)) {
455
+ let config = readFileSync2(configPath, "utf-8");
456
+ config = config.replace("__PROJECT_ID__", projectId);
457
+ config = config.replace("__NAME__", name);
458
+ if (productBindings.length > 0) {
459
+ const itemsStr = productBindings.map((b) => {
460
+ const fields = [
461
+ ` { id: '${b.id}'`,
462
+ `name: '${b.name}'`,
463
+ `storePriceId: '${b.storePriceId}'`
464
+ ];
465
+ if (b.trialDays) fields.push(`trialDays: ${b.trialDays}`);
466
+ if (b.trialStorePriceId)
467
+ fields.push(`trialStorePriceId: '${b.trialStorePriceId}'`);
468
+ return fields.join(", ") + " }";
469
+ }).join(",\n");
470
+ const defaultId = productBindings[0].id;
471
+ config = config.replace(
472
+ /products:\s*\{[^}]*items:\s*\[[^\]]*\][^}]*\}/s,
473
+ `products: {
474
+ items: [
475
+ ${itemsStr},
476
+ ],
477
+ defaultId: '${defaultId}',
478
+ }`
479
+ );
480
+ }
481
+ writeFileSync2(configPath, config);
482
+ }
483
+ const sdkVersion = `^${"0.8.0"}`;
200
484
  writeFileSync2(
201
485
  join2(dir, "package.json"),
202
486
  JSON.stringify(
@@ -211,12 +495,12 @@ async function initCommand(name) {
211
495
  publish: "appfunnel publish"
212
496
  },
213
497
  dependencies: {
214
- "@appfunnel-dev/sdk": "^0.6.0",
498
+ "@appfunnel-dev/sdk": sdkVersion,
215
499
  react: "^18.3.0",
216
500
  "react-dom": "^18.3.0"
217
501
  },
218
502
  devDependencies: {
219
- appfunnel: "^0.6.0",
503
+ appfunnel: sdkVersion,
220
504
  typescript: "^5.4.0",
221
505
  "@types/react": "^18.2.0",
222
506
  "@types/react-dom": "^18.2.0",
@@ -230,112 +514,6 @@ async function initCommand(name) {
230
514
  2
231
515
  ) + "\n"
232
516
  );
233
- writeFileSync2(
234
- join2(dir, "tsconfig.json"),
235
- JSON.stringify(
236
- {
237
- compilerOptions: {
238
- target: "ES2020",
239
- module: "ESNext",
240
- moduleResolution: "bundler",
241
- jsx: "react-jsx",
242
- strict: true,
243
- esModuleInterop: true,
244
- skipLibCheck: true,
245
- paths: {
246
- "@/*": ["./src/*"]
247
- },
248
- baseUrl: "."
249
- },
250
- include: ["src"]
251
- },
252
- null,
253
- 2
254
- ) + "\n"
255
- );
256
- writeFileSync2(join2(dir, "src", "app.css"), `@import "tailwindcss";
257
- `);
258
- writeFileSync2(
259
- join2(dir, "appfunnel.config.ts"),
260
- `import { defineConfig } from '@appfunnel-dev/sdk'
261
-
262
- export default defineConfig({
263
- projectId: '${projectId}',
264
- name: '${name}',
265
- defaultLocale: 'en',
266
-
267
- responses: {
268
- goal: { type: 'string' },
269
- },
270
-
271
- products: {
272
- items: [],
273
- },
274
- })
275
- `
276
- );
277
- writeFileSync2(
278
- join2(dir, "src", "funnel.tsx"),
279
- `import './app.css'
280
-
281
- export default function Funnel({ children }: { children: React.ReactNode }) {
282
- return (
283
- <div className="min-h-screen">
284
- {children}
285
- </div>
286
- )
287
- }
288
- `
289
- );
290
- writeFileSync2(
291
- join2(dir, "src", "pages", "index.tsx"),
292
- `import { definePage, useResponse, useNavigation } from '@appfunnel-dev/sdk'
293
-
294
- export const page = definePage({
295
- name: 'Landing',
296
- type: 'default',
297
- routes: [],
298
- })
299
-
300
- export default function Landing() {
301
- const [goal, setGoal] = useResponse<string>('goal')
302
- const { goToNextPage } = useNavigation()
303
-
304
- return (
305
- <div className="flex min-h-screen items-center justify-center p-4">
306
- <div className="w-full max-w-md space-y-6">
307
- <h1 className="text-3xl font-bold text-center">Welcome</h1>
308
- <input
309
- type="text"
310
- value={goal}
311
- onChange={(e) => setGoal(e.target.value)}
312
- placeholder="What's your goal?"
313
- className="w-full rounded-xl border p-4 text-lg"
314
- />
315
- <button
316
- onClick={goToNextPage}
317
- disabled={!goal.trim()}
318
- className="w-full rounded-xl bg-blue-600 py-4 text-lg font-bold text-white disabled:opacity-50"
319
- >
320
- Continue
321
- </button>
322
- </div>
323
- </div>
324
- )
325
- }
326
- `
327
- );
328
- writeFileSync2(
329
- join2(dir, "locales", "en.json"),
330
- JSON.stringify({ welcome: "Welcome" }, null, 2) + "\n"
331
- );
332
- writeFileSync2(
333
- join2(dir, ".gitignore"),
334
- `node_modules
335
- dist
336
- .appfunnel
337
- `
338
- );
339
517
  s.stop();
340
518
  console.log();
341
519
  success(`Created ${pc4.bold(name)} for project ${pc4.bold(project.name)}`);
@@ -345,12 +523,21 @@ dist
345
523
  console.log(` ${pc4.dim("appfunnel dev")}`);
346
524
  console.log();
347
525
  }
526
+ var __dirname, INTERVAL_LABELS;
348
527
  var init_init = __esm({
349
528
  "src/commands/init.ts"() {
350
529
  "use strict";
351
530
  init_logger();
352
531
  init_auth();
353
532
  init_projects();
533
+ init_api();
534
+ __dirname = dirname(fileURLToPath(import.meta.url));
535
+ INTERVAL_LABELS = {
536
+ day: { 1: "day", 7: "week", 14: "2 weeks", 30: "month" },
537
+ week: { 1: "week", 2: "2 weeks", 4: "month", 12: "quarter", 52: "year" },
538
+ month: { 1: "month", 3: "quarter", 6: "6 months", 12: "year" },
539
+ year: { 1: "year" }
540
+ };
354
541
  }
355
542
  });
356
543
 
@@ -449,7 +636,7 @@ async function whoamiCommand() {
449
636
  const creds = requireAuth();
450
637
  const spin = spinner("Verifying credentials\u2026");
451
638
  try {
452
- const response = await fetch(`${DEFAULT_API_BASE2}/user`, {
639
+ const response = await fetch(`${DEFAULT_API_BASE3}/user`, {
453
640
  headers: {
454
641
  Authorization: creds.token,
455
642
  "Content-Type": "application/json"
@@ -480,19 +667,19 @@ async function whoamiCommand() {
480
667
  );
481
668
  }
482
669
  }
483
- var DEFAULT_API_BASE2;
670
+ var DEFAULT_API_BASE3;
484
671
  var init_whoami = __esm({
485
672
  "src/commands/whoami.ts"() {
486
673
  "use strict";
487
674
  init_auth();
488
675
  init_errors();
489
676
  init_logger();
490
- DEFAULT_API_BASE2 = "https://api.appfunnel.net";
677
+ DEFAULT_API_BASE3 = "https://api.appfunnel.net";
491
678
  }
492
679
  });
493
680
 
494
681
  // src/lib/config.ts
495
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
682
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
496
683
  import { join as join3, resolve } from "path";
497
684
  async function loadConfig(cwd) {
498
685
  const configPath = join3(cwd, CONFIG_FILE);
@@ -504,7 +691,7 @@ async function loadConfig(cwd) {
504
691
  );
505
692
  }
506
693
  const { transform } = await import("esbuild");
507
- const raw = readFileSync2(configPath, "utf-8");
694
+ const raw = readFileSync3(configPath, "utf-8");
508
695
  const result = await transform(raw, {
509
696
  loader: "ts",
510
697
  format: "esm",
@@ -536,10 +723,10 @@ var init_config = __esm({
536
723
  });
537
724
 
538
725
  // src/lib/version.ts
539
- import { readFileSync as readFileSync3 } from "fs";
726
+ import { readFileSync as readFileSync4 } from "fs";
540
727
  import { join as join4 } from "path";
541
728
  function checkVersionCompatibility(cwd) {
542
- const cliVersion = "0.6.0";
729
+ const cliVersion = "0.8.0";
543
730
  const sdkVersion = getSdkVersion(cwd);
544
731
  const [cliMajor, cliMinor] = cliVersion.split(".").map(Number);
545
732
  const [sdkMajor, sdkMinor] = sdkVersion.split(".").map(Number);
@@ -560,7 +747,7 @@ function getSdkVersion(cwd) {
560
747
  "sdk",
561
748
  "package.json"
562
749
  );
563
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
750
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
564
751
  return pkg.version;
565
752
  } catch {
566
753
  throw new CLIError(
@@ -578,7 +765,7 @@ var init_version = __esm({
578
765
  });
579
766
 
580
767
  // src/extract/pages.ts
581
- import { readdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
768
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
582
769
  import { join as join5, basename } from "path";
583
770
  function scanPages(cwd) {
584
771
  const pagesDir = resolvePagesDir(cwd);
@@ -589,7 +776,7 @@ function scanPages(cwd) {
589
776
  "Create src/pages/ and add at least one .tsx page file."
590
777
  );
591
778
  }
592
- const files = readdirSync(pagesDir).filter((f) => f.endsWith(".tsx") && !f.startsWith("_")).map((f) => basename(f, ".tsx")).sort();
779
+ const files = readdirSync2(pagesDir).filter((f) => f.endsWith(".tsx") && !f.startsWith("_")).map((f) => basename(f, ".tsx")).sort();
593
780
  if (files.length === 0) {
594
781
  throw new CLIError(
595
782
  "NO_PAGES",
@@ -605,7 +792,7 @@ async function extractPageDefinitions(cwd, pageKeys) {
605
792
  const result = {};
606
793
  for (const key of pageKeys) {
607
794
  const filePath = join5(pagesDir, `${key}.tsx`);
608
- const source = readFileSync4(filePath, "utf-8");
795
+ const source = readFileSync5(filePath, "utf-8");
609
796
  const definition = extractDefinePage(ts, source, filePath);
610
797
  if (definition) {
611
798
  result[key] = definition;
@@ -696,7 +883,7 @@ var init_pages = __esm({
696
883
  import { join as join6 } from "path";
697
884
  import { existsSync as existsSync4 } from "fs";
698
885
  function generateEntrySource(options) {
699
- const { config, pages, pagesDir, funnelTsxPath, isDev } = options;
886
+ const { config, pages, pagesDir, funnelTsxPath, isDev, translations } = options;
700
887
  const pageKeys = Object.keys(pages);
701
888
  const mergedPages = {};
702
889
  const mergedRoutes = {};
@@ -754,6 +941,8 @@ ${pageImports}
754
941
 
755
942
  ${priceDataCode}
756
943
 
944
+ const translations = ${translations ? JSON.stringify(translations) : "undefined"}
945
+
757
946
  const config = ${JSON.stringify(fullConfig, null, 2)}
758
947
 
759
948
  const keyToSlug = ${JSON.stringify(
@@ -762,7 +951,7 @@ const keyToSlug = ${JSON.stringify(
762
951
  const slugToKey = ${JSON.stringify(slugMap)}
763
952
 
764
953
  const DEV_CAMPAIGN_SLUG = 'campaign'
765
- const DEFAULT_INITIAL = '${config.initialPageKey || pageKeys[0] || "index"}'
954
+ const DEFAULT_INITIAL = '${config.initialPageKey}'
766
955
 
767
956
  /**
768
957
  * Parse the URL to extract basePath, campaignSlug, and initial page.
@@ -910,6 +1099,7 @@ function App() {
910
1099
  campaignSlug={campaignSlug}
911
1100
  priceData={priceData}
912
1101
  sessionData={sessionData}
1102
+ translations={translations}
913
1103
  >
914
1104
  <FunnelWrapper>
915
1105
  <PageRenderer />
@@ -959,7 +1149,24 @@ var init_html = __esm({
959
1149
 
960
1150
  // src/vite/plugin.ts
961
1151
  import { resolve as resolve2, join as join7 } from "path";
962
- import { existsSync as existsSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5 } from "fs";
1152
+ import { existsSync as existsSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync6, readdirSync as readdirSync3 } from "fs";
1153
+ function loadTranslations(cwd) {
1154
+ const localesDir = join7(cwd, "locales");
1155
+ if (!existsSync5(localesDir)) return void 0;
1156
+ const translations = {};
1157
+ let hasAny = false;
1158
+ for (const file of readdirSync3(localesDir)) {
1159
+ if (!file.endsWith(".json")) continue;
1160
+ const locale = file.replace(/\.json$/, "");
1161
+ try {
1162
+ const content = readFileSync6(join7(localesDir, file), "utf-8");
1163
+ translations[locale] = JSON.parse(content);
1164
+ hasAny = true;
1165
+ } catch {
1166
+ }
1167
+ }
1168
+ return hasAny ? translations : void 0;
1169
+ }
963
1170
  function appfunnelPlugin(options) {
964
1171
  const { cwd, config, isDev } = options;
965
1172
  let pages = options.pages;
@@ -967,6 +1174,7 @@ function appfunnelPlugin(options) {
967
1174
  const funnelTsxPath = resolve2(cwd, "src", "funnel.tsx");
968
1175
  const appfunnelDir = join7(cwd, APPFUNNEL_DIR);
969
1176
  const htmlPath = join7(appfunnelDir, "index.html");
1177
+ const localesDir = join7(cwd, "locales");
970
1178
  function getEntrySource() {
971
1179
  return generateEntrySource({
972
1180
  config,
@@ -974,13 +1182,14 @@ function appfunnelPlugin(options) {
974
1182
  pagesDir,
975
1183
  funnelTsxPath,
976
1184
  isDev,
977
- priceData: options.priceData
1185
+ priceData: options.priceData,
1186
+ translations: loadTranslations(cwd)
978
1187
  });
979
1188
  }
980
1189
  return {
981
1190
  name: "appfunnel",
982
1191
  config() {
983
- mkdirSync3(appfunnelDir, { recursive: true });
1192
+ mkdirSync2(appfunnelDir, { recursive: true });
984
1193
  writeFileSync3(htmlPath, generateHtml(config.name || "AppFunnel"));
985
1194
  return {
986
1195
  // Don't let Vite auto-serve index.html — we handle it ourselves
@@ -988,7 +1197,8 @@ function appfunnelPlugin(options) {
988
1197
  resolve: {
989
1198
  alias: {
990
1199
  "@": resolve2(cwd, "src")
991
- }
1200
+ },
1201
+ dedupe: ["react", "react-dom"]
992
1202
  },
993
1203
  esbuild: {
994
1204
  jsx: "automatic"
@@ -1050,6 +1260,18 @@ function appfunnelPlugin(options) {
1050
1260
  }
1051
1261
  });
1052
1262
  }
1263
+ if (existsSync5(localesDir)) {
1264
+ watcher.add(localesDir);
1265
+ watcher.on("change", (file) => {
1266
+ if (file.startsWith(localesDir) && file.endsWith(".json")) {
1267
+ const mod = devServer.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ENTRY_ID);
1268
+ if (mod) {
1269
+ devServer.moduleGraph.invalidateModule(mod);
1270
+ }
1271
+ devServer.ws.send({ type: "full-reload" });
1272
+ }
1273
+ });
1274
+ }
1053
1275
  return () => {
1054
1276
  devServer.middlewares.use(async (req, res, next) => {
1055
1277
  const url = req.url?.split("?")[0] || "";
@@ -1057,7 +1279,7 @@ function appfunnelPlugin(options) {
1057
1279
  return next();
1058
1280
  }
1059
1281
  try {
1060
- const rawHtml = readFileSync5(htmlPath, "utf-8");
1282
+ const rawHtml = readFileSync6(htmlPath, "utf-8");
1061
1283
  const html = await devServer.transformIndexHtml(req.url || "/", rawHtml);
1062
1284
  res.statusCode = 200;
1063
1285
  res.setHeader("Content-Type", "text/html");
@@ -1085,104 +1307,12 @@ var init_plugin = __esm({
1085
1307
  }
1086
1308
  });
1087
1309
 
1088
- // src/lib/api.ts
1089
- async function apiFetch(path, options) {
1090
- const { token, apiBaseUrl, ...fetchOpts } = options;
1091
- const base = apiBaseUrl || DEFAULT_API_BASE3;
1092
- const url = `${base}${path}`;
1093
- const isFormData = fetchOpts.body instanceof FormData;
1094
- const headers = {
1095
- Authorization: token,
1096
- ...fetchOpts.headers || {}
1097
- };
1098
- if (!isFormData) {
1099
- headers["Content-Type"] = "application/json";
1100
- }
1101
- const response = await fetch(url, {
1102
- ...fetchOpts,
1103
- headers
1104
- });
1105
- if (!response.ok) {
1106
- const body = await response.text().catch(() => "");
1107
- let message = `API request failed: ${response.status} ${response.statusText}`;
1108
- try {
1109
- const parsed = JSON.parse(body);
1110
- if (parsed.error) message = parsed.error;
1111
- if (parsed.message) message = parsed.message;
1112
- } catch {
1113
- }
1114
- const error2 = new CLIError("API_ERROR", message);
1115
- error2.statusCode = response.status;
1116
- throw error2;
1117
- }
1118
- return response;
1119
- }
1120
- async function fetchPrices(projectId, storePriceIds, options) {
1121
- if (storePriceIds.length === 0) return /* @__PURE__ */ new Map();
1122
- const response = await apiFetch(`/project/${projectId}/headless/prices`, {
1123
- ...options,
1124
- method: "POST",
1125
- body: JSON.stringify({ storePriceIds })
1126
- });
1127
- const data = await response.json();
1128
- return new Map(Object.entries(data.prices || {}));
1129
- }
1130
- async function publishBuild(projectId, funnelId, manifest, assets, options) {
1131
- const formData = new FormData();
1132
- formData.set("manifest", JSON.stringify(manifest));
1133
- if (funnelId) {
1134
- formData.set("funnelId", funnelId);
1135
- }
1136
- for (const asset of assets) {
1137
- formData.append(
1138
- "assets",
1139
- new Blob([new Uint8Array(asset.content)], { type: asset.contentType }),
1140
- asset.path
1141
- );
1142
- }
1143
- try {
1144
- const response = await apiFetch(`/project/${projectId}/headless/publish`, {
1145
- ...options,
1146
- method: "POST",
1147
- body: formData
1148
- });
1149
- return await response.json();
1150
- } catch (err) {
1151
- if (err instanceof CLIError && err.code === "API_ERROR") {
1152
- if (err.statusCode === 413) {
1153
- throw new CLIError(
1154
- "BUNDLE_TOO_LARGE",
1155
- err.message,
1156
- "Reduce page bundle sizes. Check for large dependencies."
1157
- );
1158
- }
1159
- if (err.statusCode === 409) {
1160
- throw new CLIError(
1161
- "FUNNEL_NOT_HEADLESS",
1162
- err.message,
1163
- "Remove funnelId from config to create a new headless funnel."
1164
- );
1165
- }
1166
- throw new CLIError("PUBLISH_FAILED", err.message);
1167
- }
1168
- throw err;
1169
- }
1170
- }
1171
- var DEFAULT_API_BASE3;
1172
- var init_api = __esm({
1173
- "src/lib/api.ts"() {
1174
- "use strict";
1175
- init_errors();
1176
- DEFAULT_API_BASE3 = "https://api.appfunnel.net";
1177
- }
1178
- });
1179
-
1180
1310
  // src/commands/dev.ts
1181
1311
  var dev_exports = {};
1182
1312
  __export(dev_exports, {
1183
1313
  devCommand: () => devCommand
1184
1314
  });
1185
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1315
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
1186
1316
  import { join as join8 } from "path";
1187
1317
  import pc6 from "picocolors";
1188
1318
  async function devCommand(options) {
@@ -1198,7 +1328,7 @@ async function devCommand(options) {
1198
1328
  const projectId = await promptForProject(creds.token);
1199
1329
  config.projectId = projectId;
1200
1330
  const configPath = join8(cwd, "appfunnel.config.ts");
1201
- const configSource = readFileSync6(configPath, "utf-8");
1331
+ const configSource = readFileSync7(configPath, "utf-8");
1202
1332
  let updated;
1203
1333
  if (/projectId:\s*['"]/.test(configSource)) {
1204
1334
  updated = configSource.replace(
@@ -1320,7 +1450,7 @@ __export(build_exports, {
1320
1450
  });
1321
1451
  import { resolve as resolve3, join as join9 } from "path";
1322
1452
  import { randomUUID as randomUUID2 } from "crypto";
1323
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, statSync, readdirSync as readdirSync2 } from "fs";
1453
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync5, statSync, readdirSync as readdirSync4 } from "fs";
1324
1454
  import pc7 from "picocolors";
1325
1455
  async function buildCommand() {
1326
1456
  const cwd = process.cwd();
@@ -1336,6 +1466,14 @@ async function buildCommand() {
1336
1466
  "Run 'appfunnel dev' first to select a project, or add projectId manually."
1337
1467
  );
1338
1468
  }
1469
+ if (!config.initialPageKey) {
1470
+ s.stop();
1471
+ throw new CLIError(
1472
+ "MISSING_INITIAL_PAGE_KEY",
1473
+ "Missing initialPageKey in appfunnel.config.ts.",
1474
+ "Set initialPageKey to the page key (filename without .tsx) of your first page."
1475
+ );
1476
+ }
1339
1477
  const pageKeys = scanPages(cwd);
1340
1478
  const pages = await extractPageDefinitions(cwd, pageKeys);
1341
1479
  s.stop();
@@ -1439,7 +1577,7 @@ async function buildCommand() {
1439
1577
  console.log(` ${pc7.dim("Pages:")} ${pageKeys.length}`);
1440
1578
  console.log(` ${pc7.dim("Size:")} ${formatSize(totalSize)}`);
1441
1579
  console.log();
1442
- for (const asset of assets.filter((a) => a.path.endsWith(".js"))) {
1580
+ for (const asset of assets) {
1443
1581
  const sizeStr = formatSize(asset.size);
1444
1582
  const isOver = asset.size > MAX_PAGE_SIZE;
1445
1583
  console.log(` ${isOver ? pc7.yellow("!") : pc7.dim("\xB7")} ${pc7.dim(asset.path)} ${isOver ? pc7.yellow(sizeStr) : pc7.dim(sizeStr)}`);
@@ -1531,7 +1669,7 @@ function validateConditionVariables(condition, pageKey, allVariables) {
1531
1669
  function collectAssets(outDir) {
1532
1670
  const assets = [];
1533
1671
  function walk(dir, prefix = "") {
1534
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
1672
+ for (const entry of readdirSync4(dir, { withFileTypes: true })) {
1535
1673
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
1536
1674
  const fullPath = join9(dir, entry.name);
1537
1675
  if (entry.isDirectory()) {
@@ -1551,7 +1689,7 @@ function formatSize(bytes) {
1551
1689
  }
1552
1690
  function getSdkVersion2(cwd) {
1553
1691
  try {
1554
- const pkg = JSON.parse(readFileSync7(join9(cwd, "node_modules", "@appfunnel", "sdk", "package.json"), "utf-8"));
1692
+ const pkg = JSON.parse(readFileSync8(join9(cwd, "node_modules", "@appfunnel-dev", "sdk", "package.json"), "utf-8"));
1555
1693
  return pkg.version;
1556
1694
  } catch {
1557
1695
  return "0.0.0";
@@ -1584,10 +1722,10 @@ var init_build = __esm({
1584
1722
 
1585
1723
  // src/lib/config-patch.ts
1586
1724
  import { join as join10 } from "path";
1587
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
1725
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
1588
1726
  function patchConfigFunnelId(cwd, funnelId) {
1589
1727
  const configPath = join10(cwd, "appfunnel.config.ts");
1590
- let content = readFileSync8(configPath, "utf-8");
1728
+ let content = readFileSync9(configPath, "utf-8");
1591
1729
  if (content.includes("funnelId")) return;
1592
1730
  const patched = content.replace(
1593
1731
  /(projectId:\s*['"][^'"]+['"],?\s*\n)/,
@@ -1610,7 +1748,7 @@ __export(publish_exports, {
1610
1748
  publishCommand: () => publishCommand
1611
1749
  });
1612
1750
  import { resolve as resolve4, join as join11 } from "path";
1613
- import { readFileSync as readFileSync9, existsSync as existsSync6 } from "fs";
1751
+ import { readFileSync as readFileSync10, existsSync as existsSync6 } from "fs";
1614
1752
  import pc8 from "picocolors";
1615
1753
  function getMimeType(path) {
1616
1754
  const ext = path.substring(path.lastIndexOf("."));
@@ -1621,7 +1759,7 @@ function formatSize2(bytes) {
1621
1759
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
1622
1760
  return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
1623
1761
  }
1624
- async function publishCommand() {
1762
+ async function publishCommand(options) {
1625
1763
  const cwd = process.cwd();
1626
1764
  const creds = requireAuth();
1627
1765
  checkVersionCompatibility(cwd);
@@ -1643,7 +1781,7 @@ async function publishCommand() {
1643
1781
  "Run 'appfunnel build' first."
1644
1782
  );
1645
1783
  }
1646
- const manifest = JSON.parse(readFileSync9(manifestPath, "utf-8"));
1784
+ const manifest = JSON.parse(readFileSync10(manifestPath, "utf-8"));
1647
1785
  const assets = manifest.assets || [];
1648
1786
  const s = spinner("Preparing assets...");
1649
1787
  const assetPayloads = [];
@@ -1659,7 +1797,7 @@ async function publishCommand() {
1659
1797
  "Run 'appfunnel build' to regenerate."
1660
1798
  );
1661
1799
  }
1662
- const content = readFileSync9(fullPath);
1800
+ const content = readFileSync10(fullPath);
1663
1801
  totalBytes += content.length;
1664
1802
  assetPayloads.push({
1665
1803
  path: asset.path,
@@ -1674,7 +1812,8 @@ async function publishCommand() {
1674
1812
  config.funnelId || "",
1675
1813
  manifest,
1676
1814
  assetPayloads,
1677
- { token: creds.token }
1815
+ { token: creds.token },
1816
+ options?.promote
1678
1817
  );
1679
1818
  s.stop();
1680
1819
  if (result.created && result.funnelId) {
@@ -1682,14 +1821,18 @@ async function publishCommand() {
1682
1821
  info(`Funnel created \u2014 funnelId added to appfunnel.config.ts`);
1683
1822
  }
1684
1823
  console.log();
1685
- success("Published successfully");
1824
+ success(result.activated ? "Published and activated" : "Published successfully");
1686
1825
  console.log();
1687
- console.log(` ${pc8.dim("Build ID:")} ${result.buildId}`);
1826
+ console.log(` ${pc8.dim("Build ID:")} ${result.buildId}`);
1688
1827
  if (result.funnelId && !config.funnelId) {
1689
- console.log(` ${pc8.dim("Funnel:")} ${result.funnelId}`);
1828
+ console.log(` ${pc8.dim("Funnel:")} ${result.funnelId}`);
1829
+ }
1830
+ console.log(` ${pc8.dim("Dashboard:")} ${pc8.cyan(result.dashboardUrl)}`);
1831
+ console.log(` ${pc8.dim("Assets:")} ${assets.length} files ${pc8.dim(`(${formatSize2(totalBytes)})`)}`);
1832
+ if (!result.activated) {
1833
+ console.log();
1834
+ console.log(` ${pc8.dim("Tip:")} Use ${pc8.cyan("--promote")} to activate immediately, or promote from the dashboard.`);
1690
1835
  }
1691
- console.log(` ${pc8.dim("URL:")} ${pc8.cyan(result.url)}`);
1692
- console.log(` ${pc8.dim("Assets:")} ${assets.length} files ${pc8.dim(`(${formatSize2(totalBytes)})`)}`);
1693
1836
  console.log();
1694
1837
  }
1695
1838
  var MIME_TYPES;
@@ -1722,8 +1865,8 @@ init_errors();
1722
1865
  import { Command } from "commander";
1723
1866
  import pc9 from "picocolors";
1724
1867
  var program = new Command();
1725
- program.name("appfunnel").description("Build and publish headless AppFunnel projects").version("0.6.0");
1726
- program.command("init <name>").description("Create a new AppFunnel project").action(async (name) => {
1868
+ program.name("appfunnel").description("Build and publish headless AppFunnel projects").version("0.8.0");
1869
+ program.command("init").argument("[name]", "Project directory name").description("Create a new AppFunnel project").action(async (name) => {
1727
1870
  const { initCommand: initCommand2 } = await Promise.resolve().then(() => (init_init(), init_exports));
1728
1871
  await initCommand2(name);
1729
1872
  });
@@ -1743,9 +1886,9 @@ program.command("build").description("Build the funnel for production").action(a
1743
1886
  const { buildCommand: buildCommand2 } = await Promise.resolve().then(() => (init_build(), build_exports));
1744
1887
  await buildCommand2();
1745
1888
  });
1746
- program.command("publish").description("Publish the build to AppFunnel").action(async () => {
1889
+ program.command("publish").description("Publish the build to AppFunnel").option("--promote", "Activate the build immediately after publishing").action(async (options) => {
1747
1890
  const { publishCommand: publishCommand2 } = await Promise.resolve().then(() => (init_publish(), publish_exports));
1748
- await publishCommand2();
1891
+ await publishCommand2({ promote: options.promote });
1749
1892
  });
1750
1893
  program.hook("postAction", () => {
1751
1894
  });