appfunnel 0.5.0 → 0.7.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
@@ -175,28 +175,308 @@ var init_projects = __esm({
175
175
  }
176
176
  });
177
177
 
178
+ // src/lib/api.ts
179
+ async function apiFetch(path, options) {
180
+ const { token, apiBaseUrl, ...fetchOpts } = options;
181
+ const base = apiBaseUrl || DEFAULT_API_BASE2;
182
+ const url = `${base}${path}`;
183
+ const isFormData = fetchOpts.body instanceof FormData;
184
+ const headers = {
185
+ Authorization: token,
186
+ ...fetchOpts.headers || {}
187
+ };
188
+ if (!isFormData) {
189
+ headers["Content-Type"] = "application/json";
190
+ }
191
+ const response = await fetch(url, {
192
+ ...fetchOpts,
193
+ headers
194
+ });
195
+ if (!response.ok) {
196
+ const body = await response.text().catch(() => "");
197
+ let message = `API request failed: ${response.status} ${response.statusText}`;
198
+ try {
199
+ const parsed = JSON.parse(body);
200
+ if (parsed.error) message = parsed.error;
201
+ if (parsed.message) message = parsed.message;
202
+ } catch {
203
+ }
204
+ const error2 = new CLIError("API_ERROR", message);
205
+ error2.statusCode = response.status;
206
+ throw error2;
207
+ }
208
+ return response;
209
+ }
210
+ async function fetchPrices(projectId, storePriceIds, options) {
211
+ if (storePriceIds.length === 0) return /* @__PURE__ */ new Map();
212
+ const response = await apiFetch(`/project/${projectId}/headless/prices`, {
213
+ ...options,
214
+ method: "POST",
215
+ body: JSON.stringify({ storePriceIds })
216
+ });
217
+ const data = await response.json();
218
+ return new Map(Object.entries(data.prices || {}));
219
+ }
220
+ async function fetchStores(projectId, options) {
221
+ const response = await apiFetch(`/project/${projectId}/stores`, {
222
+ ...options,
223
+ method: "GET"
224
+ });
225
+ const data = await response.json();
226
+ return data.data || [];
227
+ }
228
+ async function fetchStorePrices(projectId, storeId, options) {
229
+ const response = await apiFetch(
230
+ `/project/${projectId}/stores/${storeId}/prices`,
231
+ { ...options, method: "GET" }
232
+ );
233
+ const data = await response.json();
234
+ return data.data || [];
235
+ }
236
+ async function publishBuild(projectId, funnelId, manifest, assets, options, promote) {
237
+ const formData = new FormData();
238
+ formData.set("manifest", JSON.stringify(manifest));
239
+ if (funnelId) {
240
+ formData.set("funnelId", funnelId);
241
+ }
242
+ if (promote) {
243
+ formData.set("promote", "true");
244
+ }
245
+ for (const asset of assets) {
246
+ formData.append(
247
+ "assets",
248
+ new Blob([new Uint8Array(asset.content)], { type: asset.contentType }),
249
+ asset.path
250
+ );
251
+ }
252
+ try {
253
+ const response = await apiFetch(`/project/${projectId}/headless/publish`, {
254
+ ...options,
255
+ method: "POST",
256
+ body: formData
257
+ });
258
+ return await response.json();
259
+ } catch (err) {
260
+ if (err instanceof CLIError && err.code === "API_ERROR") {
261
+ if (err.statusCode === 413) {
262
+ throw new CLIError(
263
+ "BUNDLE_TOO_LARGE",
264
+ err.message,
265
+ "Reduce page bundle sizes. Check for large dependencies."
266
+ );
267
+ }
268
+ if (err.statusCode === 409) {
269
+ throw new CLIError(
270
+ "FUNNEL_NOT_HEADLESS",
271
+ err.message,
272
+ "Remove funnelId from config to create a new headless funnel."
273
+ );
274
+ }
275
+ throw new CLIError("PUBLISH_FAILED", err.message);
276
+ }
277
+ throw err;
278
+ }
279
+ }
280
+ var DEFAULT_API_BASE2;
281
+ var init_api = __esm({
282
+ "src/lib/api.ts"() {
283
+ "use strict";
284
+ init_errors();
285
+ DEFAULT_API_BASE2 = "https://api.appfunnel.net";
286
+ }
287
+ });
288
+
178
289
  // src/commands/init.ts
179
290
  var init_exports = {};
180
291
  __export(init_exports, {
181
292
  initCommand: () => initCommand
182
293
  });
183
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, existsSync } from "fs";
184
- import { join as join2 } from "path";
294
+ import { cpSync, existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, readdirSync } from "fs";
295
+ import { join as join2, dirname } from "path";
296
+ import { fileURLToPath } from "url";
185
297
  import pc4 from "picocolors";
186
- async function initCommand(name) {
298
+ import select2 from "@inquirer/select";
299
+ import input from "@inquirer/input";
300
+ function getTemplatesDir() {
301
+ const dir = join2(__dirname, "..", "templates");
302
+ if (!existsSync(dir)) {
303
+ throw new Error(`Templates directory not found at ${dir}`);
304
+ }
305
+ return dir;
306
+ }
307
+ function listTemplates() {
308
+ const root = getTemplatesDir();
309
+ return readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
310
+ const configPath = join2(root, d.name, "template.json");
311
+ const config = existsSync(configPath) ? JSON.parse(readFileSync2(configPath, "utf-8")) : { name: d.name, description: "", products: [] };
312
+ return { dir: d.name, config };
313
+ });
314
+ }
315
+ function formatInterval(interval, count) {
316
+ if (!interval) return " one-time";
317
+ const label = INTERVAL_LABELS[interval]?.[count];
318
+ if (label) return `/${label}`;
319
+ return `/${count} ${interval}${count > 1 ? "s" : ""}`;
320
+ }
321
+ function formatPrice(price) {
322
+ const amount = (price.amount / 100).toFixed(2);
323
+ const currency = price.currency.toUpperCase();
324
+ const interval = formatInterval(price.interval, price.intervalCount);
325
+ const productName = price.name || "Unnamed product";
326
+ const priceName = price.priceName ? ` \u2014 ${price.priceName}` : "";
327
+ return `${currency} ${amount}${interval} ${pc4.dim(`${productName}${priceName}`)} ${pc4.dim(`(${price.id})`)}`;
328
+ }
329
+ function formatStoreName(store) {
330
+ const test = store.isTestMode ? pc4.yellow(" (test)") : "";
331
+ return `${store.name || store.type}${test}`;
332
+ }
333
+ async function initCommand() {
187
334
  const creds = requireAuth();
188
- const dir = join2(process.cwd(), name);
189
- if (existsSync(dir)) {
190
- error(`Directory '${name}' already exists.`);
191
- process.exit(1);
192
- }
335
+ const apiOpts = { token: creds.token };
336
+ const name = await input({
337
+ message: "Funnel name",
338
+ validate: (value) => {
339
+ if (!value.trim()) return "Name is required";
340
+ if (!/^[a-z0-9-]+$/.test(value.trim()))
341
+ return "Use lowercase letters, numbers, and hyphens only";
342
+ if (existsSync(join2(process.cwd(), value.trim())))
343
+ return `Directory '${value.trim()}' already exists`;
344
+ return true;
345
+ }
346
+ });
347
+ const dir = join2(process.cwd(), name.trim());
193
348
  const projectId = await promptForProject(creds.token);
194
349
  const projects = await fetchProjects(creds.token);
195
350
  const project = projects.find((p) => p.id === projectId);
351
+ const templates = listTemplates();
352
+ const selectedDir = await select2({
353
+ message: "Choose a template",
354
+ choices: templates.map((t) => ({
355
+ name: t.config.description ? `${t.config.name} \u2014 ${pc4.dim(t.config.description)}` : t.config.name,
356
+ value: t.dir
357
+ }))
358
+ });
359
+ const chosen = templates.find((t) => t.dir === selectedDir);
360
+ const templateConfig = chosen.config;
361
+ const templateDir = join2(getTemplatesDir(), chosen.dir);
362
+ const productBindings = [];
363
+ if (templateConfig.products.length > 0) {
364
+ const storesSpinner = spinner("Fetching stores...");
365
+ const stores = await fetchStores(projectId, apiOpts);
366
+ storesSpinner.stop();
367
+ if (stores.length === 0) {
368
+ warn("No stores found for this project.");
369
+ info("Products will use empty IDs \u2014 create a store in the dashboard and update appfunnel.config.ts.");
370
+ } else {
371
+ let store;
372
+ if (stores.length === 1) {
373
+ store = stores[0];
374
+ info(`Using store: ${formatStoreName(store)}`);
375
+ } else {
376
+ const storeId = await select2({
377
+ message: "Choose a store",
378
+ choices: stores.map((s2) => ({
379
+ name: formatStoreName(s2),
380
+ value: s2.id
381
+ }))
382
+ });
383
+ store = stores.find((s2) => s2.id === storeId);
384
+ }
385
+ const pricesSpinner = spinner("Fetching prices...");
386
+ const prices = await fetchStorePrices(projectId, store.id, apiOpts);
387
+ pricesSpinner.stop();
388
+ if (prices.length === 0) {
389
+ warn("No prices found in this store.");
390
+ info("Import prices in the dashboard, then update appfunnel.config.ts.");
391
+ } else {
392
+ console.log();
393
+ info(`Configuring ${templateConfig.products.length} product(s)...`);
394
+ console.log();
395
+ for (const product of templateConfig.products) {
396
+ if (product.description) {
397
+ console.log(` ${pc4.dim(product.description)}`);
398
+ }
399
+ const storePriceId = await select2({
400
+ message: `${product.label} \u2014 select a price`,
401
+ choices: prices.map((p) => ({
402
+ name: formatPrice(p),
403
+ value: p.id
404
+ }))
405
+ });
406
+ const binding = {
407
+ id: product.id,
408
+ name: product.id,
409
+ storePriceId
410
+ };
411
+ if (product.paidTrial) {
412
+ const trialStorePriceId = await select2({
413
+ message: `${product.label} \u2014 select a trial price`,
414
+ choices: [
415
+ ...prices.map((p) => ({
416
+ name: formatPrice(p),
417
+ value: p.id
418
+ })),
419
+ { name: pc4.dim("Skip (no trial)"), value: "" }
420
+ ]
421
+ });
422
+ if (trialStorePriceId) {
423
+ binding.trialStorePriceId = trialStorePriceId;
424
+ const trialDaysStr = await select2({
425
+ message: `${product.label} \u2014 trial duration`,
426
+ choices: [
427
+ { name: "3 days", value: "3" },
428
+ { name: "7 days", value: "7" },
429
+ { name: "14 days", value: "14" },
430
+ { name: "30 days", value: "30" },
431
+ { name: "90 days", value: "90" }
432
+ ]
433
+ });
434
+ binding.trialDays = parseInt(trialDaysStr, 10);
435
+ }
436
+ }
437
+ productBindings.push(binding);
438
+ }
439
+ }
440
+ }
441
+ }
196
442
  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 });
443
+ cpSync(templateDir, dir, { recursive: true });
444
+ const templateJsonPath = join2(dir, "template.json");
445
+ if (existsSync(templateJsonPath)) {
446
+ const { unlinkSync } = await import("fs");
447
+ unlinkSync(templateJsonPath);
448
+ }
449
+ const configPath = join2(dir, "appfunnel.config.ts");
450
+ if (existsSync(configPath)) {
451
+ let config = readFileSync2(configPath, "utf-8");
452
+ config = config.replace("__PROJECT_ID__", projectId);
453
+ config = config.replace("__NAME__", name);
454
+ if (productBindings.length > 0) {
455
+ const itemsStr = productBindings.map((b) => {
456
+ const fields = [
457
+ ` { id: '${b.id}'`,
458
+ `name: '${b.name}'`,
459
+ `storePriceId: '${b.storePriceId}'`
460
+ ];
461
+ if (b.trialDays) fields.push(`trialDays: ${b.trialDays}`);
462
+ if (b.trialStorePriceId)
463
+ fields.push(`trialStorePriceId: '${b.trialStorePriceId}'`);
464
+ return fields.join(", ") + " }";
465
+ }).join(",\n");
466
+ const defaultId = productBindings[0].id;
467
+ config = config.replace(
468
+ /products:\s*\{[^}]*items:\s*\[[^\]]*\][^}]*\}/s,
469
+ `products: {
470
+ items: [
471
+ ${itemsStr},
472
+ ],
473
+ defaultId: '${defaultId}',
474
+ }`
475
+ );
476
+ }
477
+ writeFileSync2(configPath, config);
478
+ }
479
+ const sdkVersion = `^${"0.7.0"}`;
200
480
  writeFileSync2(
201
481
  join2(dir, "package.json"),
202
482
  JSON.stringify(
@@ -211,12 +491,12 @@ async function initCommand(name) {
211
491
  publish: "appfunnel publish"
212
492
  },
213
493
  dependencies: {
214
- "@appfunnel-dev/sdk": "^0.5.0",
494
+ "@appfunnel-dev/sdk": sdkVersion,
215
495
  react: "^18.3.0",
216
496
  "react-dom": "^18.3.0"
217
497
  },
218
498
  devDependencies: {
219
- appfunnel: "^0.5.0",
499
+ appfunnel: sdkVersion,
220
500
  typescript: "^5.4.0",
221
501
  "@types/react": "^18.2.0",
222
502
  "@types/react-dom": "^18.2.0",
@@ -230,112 +510,6 @@ async function initCommand(name) {
230
510
  2
231
511
  ) + "\n"
232
512
  );
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
513
  s.stop();
340
514
  console.log();
341
515
  success(`Created ${pc4.bold(name)} for project ${pc4.bold(project.name)}`);
@@ -345,12 +519,21 @@ dist
345
519
  console.log(` ${pc4.dim("appfunnel dev")}`);
346
520
  console.log();
347
521
  }
522
+ var __dirname, INTERVAL_LABELS;
348
523
  var init_init = __esm({
349
524
  "src/commands/init.ts"() {
350
525
  "use strict";
351
526
  init_logger();
352
527
  init_auth();
353
528
  init_projects();
529
+ init_api();
530
+ __dirname = dirname(fileURLToPath(import.meta.url));
531
+ INTERVAL_LABELS = {
532
+ day: { 1: "day", 7: "week", 14: "2 weeks", 30: "month" },
533
+ week: { 1: "week", 2: "2 weeks", 4: "month", 12: "quarter", 52: "year" },
534
+ month: { 1: "month", 3: "quarter", 6: "6 months", 12: "year" },
535
+ year: { 1: "year" }
536
+ };
354
537
  }
355
538
  });
356
539
 
@@ -364,7 +547,7 @@ import { randomUUID } from "crypto";
364
547
  import open from "open";
365
548
  async function loginCommand() {
366
549
  const state = randomUUID();
367
- return new Promise((resolve6, reject) => {
550
+ return new Promise((resolve5, reject) => {
368
551
  const server = createServer((req, res) => {
369
552
  const url = new URL(req.url || "/", `http://localhost`);
370
553
  if (url.pathname !== "/callback") {
@@ -403,7 +586,7 @@ async function loginCommand() {
403
586
  spinner2.stop();
404
587
  success(`Logged in as ${email || userId}`);
405
588
  server.close();
406
- resolve6();
589
+ resolve5();
407
590
  });
408
591
  server.listen(0, "127.0.0.1", () => {
409
592
  const addr = server.address();
@@ -449,7 +632,7 @@ async function whoamiCommand() {
449
632
  const creds = requireAuth();
450
633
  const spin = spinner("Verifying credentials\u2026");
451
634
  try {
452
- const response = await fetch(`${DEFAULT_API_BASE2}/user`, {
635
+ const response = await fetch(`${DEFAULT_API_BASE3}/user`, {
453
636
  headers: {
454
637
  Authorization: creds.token,
455
638
  "Content-Type": "application/json"
@@ -480,19 +663,19 @@ async function whoamiCommand() {
480
663
  );
481
664
  }
482
665
  }
483
- var DEFAULT_API_BASE2;
666
+ var DEFAULT_API_BASE3;
484
667
  var init_whoami = __esm({
485
668
  "src/commands/whoami.ts"() {
486
669
  "use strict";
487
670
  init_auth();
488
671
  init_errors();
489
672
  init_logger();
490
- DEFAULT_API_BASE2 = "https://api.appfunnel.net";
673
+ DEFAULT_API_BASE3 = "https://api.appfunnel.net";
491
674
  }
492
675
  });
493
676
 
494
677
  // src/lib/config.ts
495
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
678
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
496
679
  import { join as join3, resolve } from "path";
497
680
  async function loadConfig(cwd) {
498
681
  const configPath = join3(cwd, CONFIG_FILE);
@@ -504,7 +687,7 @@ async function loadConfig(cwd) {
504
687
  );
505
688
  }
506
689
  const { transform } = await import("esbuild");
507
- const raw = readFileSync2(configPath, "utf-8");
690
+ const raw = readFileSync3(configPath, "utf-8");
508
691
  const result = await transform(raw, {
509
692
  loader: "ts",
510
693
  format: "esm",
@@ -536,10 +719,10 @@ var init_config = __esm({
536
719
  });
537
720
 
538
721
  // src/lib/version.ts
539
- import { readFileSync as readFileSync3 } from "fs";
722
+ import { readFileSync as readFileSync4 } from "fs";
540
723
  import { join as join4 } from "path";
541
724
  function checkVersionCompatibility(cwd) {
542
- const cliVersion = "0.5.0";
725
+ const cliVersion = "0.7.0";
543
726
  const sdkVersion = getSdkVersion(cwd);
544
727
  const [cliMajor, cliMinor] = cliVersion.split(".").map(Number);
545
728
  const [sdkMajor, sdkMinor] = sdkVersion.split(".").map(Number);
@@ -560,7 +743,7 @@ function getSdkVersion(cwd) {
560
743
  "sdk",
561
744
  "package.json"
562
745
  );
563
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
746
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
564
747
  return pkg.version;
565
748
  } catch {
566
749
  throw new CLIError(
@@ -578,7 +761,7 @@ var init_version = __esm({
578
761
  });
579
762
 
580
763
  // src/extract/pages.ts
581
- import { readdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
764
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
582
765
  import { join as join5, basename } from "path";
583
766
  function scanPages(cwd) {
584
767
  const pagesDir = resolvePagesDir(cwd);
@@ -589,7 +772,7 @@ function scanPages(cwd) {
589
772
  "Create src/pages/ and add at least one .tsx page file."
590
773
  );
591
774
  }
592
- const files = readdirSync(pagesDir).filter((f) => f.endsWith(".tsx") && !f.startsWith("_")).map((f) => basename(f, ".tsx")).sort();
775
+ const files = readdirSync2(pagesDir).filter((f) => f.endsWith(".tsx") && !f.startsWith("_")).map((f) => basename(f, ".tsx")).sort();
593
776
  if (files.length === 0) {
594
777
  throw new CLIError(
595
778
  "NO_PAGES",
@@ -605,7 +788,7 @@ async function extractPageDefinitions(cwd, pageKeys) {
605
788
  const result = {};
606
789
  for (const key of pageKeys) {
607
790
  const filePath = join5(pagesDir, `${key}.tsx`);
608
- const source = readFileSync4(filePath, "utf-8");
791
+ const source = readFileSync5(filePath, "utf-8");
609
792
  const definition = extractDefinePage(ts, source, filePath);
610
793
  if (definition) {
611
794
  result[key] = definition;
@@ -694,6 +877,7 @@ var init_pages = __esm({
694
877
 
695
878
  // src/vite/entry.ts
696
879
  import { join as join6 } from "path";
880
+ import { existsSync as existsSync4 } from "fs";
697
881
  function generateEntrySource(options) {
698
882
  const { config, pages, pagesDir, funnelTsxPath, isDev } = options;
699
883
  const pageKeys = Object.keys(pages);
@@ -721,76 +905,197 @@ function generateEntrySource(options) {
721
905
  for (const [key, def] of Object.entries(pages)) {
722
906
  slugMap[def.slug || key] = key;
723
907
  }
908
+ const priceDataCode = isDev ? options.priceData && options.priceData.size > 0 ? `const priceData = new Map(${JSON.stringify([...options.priceData.entries()])});` : `const priceData = undefined;` : `const priceData = (() => {
909
+ const rd = typeof window !== 'undefined' && window.__APPFUNNEL_DATA__;
910
+ if (!rd || !rd.products?.items) return undefined;
911
+ // Build price map from server-injected product data
912
+ const map = new Map();
913
+ for (const item of rd.products.items) {
914
+ if (item.priceData && item.storePriceId) map.set(item.storePriceId, item.priceData);
915
+ if (item.trialPriceData && item.trialStorePriceId) map.set(item.trialStorePriceId, item.trialPriceData);
916
+ }
917
+ return map.size > 0 ? map : undefined;
918
+ })();`;
724
919
  const trackingCode = isDev ? `
725
920
  // Dev mode: mock tracking \u2014 log events to console
726
- const originalFetch = globalThis.fetch;
727
921
  globalThis.__APPFUNNEL_DEV__ = true;
728
922
  ` : "";
923
+ const appCssPath = join6(pagesDir, "..", "app.css").replace(/\\/g, "/");
924
+ const hasAppCss = existsSync4(join6(pagesDir, "..", "app.css"));
729
925
  return `
730
- import { StrictMode, lazy, Suspense, useState, useEffect, useCallback } from 'react'
926
+ import { StrictMode, Component, lazy, Suspense, useState, useCallback, useEffect, useTransition, useDeferredValue, useSyncExternalStore } from 'react'
731
927
  import { createRoot } from 'react-dom/client'
732
- import { FunnelProvider } from '@appfunnel-dev/sdk/internal'
928
+ import { FunnelProvider, useNavigation } from '@appfunnel-dev/sdk'
929
+ ${hasAppCss ? `import '${appCssPath}'` : ""}
733
930
  import FunnelWrapper from '${funnelTsxPath.replace(/\\/g, "/")}'
734
931
 
735
932
  ${trackingCode}
736
933
 
737
- const pages = {
934
+ const pageComponents = {
738
935
  ${pageImports}
739
936
  }
740
937
 
938
+ ${priceDataCode}
939
+
741
940
  const config = ${JSON.stringify(fullConfig, null, 2)}
742
941
 
743
- const slugToKey = ${JSON.stringify(slugMap)}
744
942
  const keyToSlug = ${JSON.stringify(
745
943
  Object.fromEntries(Object.entries(slugMap).map(([s, k]) => [k, s]))
746
944
  )}
945
+ const slugToKey = ${JSON.stringify(slugMap)}
946
+
947
+ const DEV_CAMPAIGN_SLUG = 'campaign'
948
+ const DEFAULT_INITIAL = '${config.initialPageKey || pageKeys[0] || "index"}'
747
949
 
748
- function getInitialPage() {
749
- const path = window.location.pathname.split('/').filter(Boolean).pop() || ''
750
- return slugToKey[path] || '${config.initialPage || pageKeys[0] || "index"}'
950
+ /**
951
+ * Parse the URL to extract basePath, campaignSlug, and initial page.
952
+ *
953
+ * URL pattern: /f/<campaignSlug>[/<pageSlug>]
954
+ *
955
+ * In dev, redirects bare / to /f/<projectId> so the URL matches production.
956
+ */
957
+ function parseUrl() {
958
+ const parts = window.location.pathname.split('/').filter(Boolean)
959
+
960
+ // /f/<campaignSlug>[/<pageSlug>]
961
+ if (parts[0] === 'f' && parts.length >= 2) {
962
+ const campaignSlug = parts[1]
963
+ const pageSlug = parts[2] || ''
964
+ const pageKey = pageSlug ? (slugToKey[pageSlug] || '') : ''
965
+ return {
966
+ basePath: '/f/' + campaignSlug,
967
+ campaignSlug,
968
+ initialPage: pageKey || DEFAULT_INITIAL,
969
+ }
970
+ }
971
+
972
+ // Bare URL \u2192 redirect to /f/<slug> in dev
973
+ ${isDev ? `
974
+ window.history.replaceState(null, '', '/f/' + DEV_CAMPAIGN_SLUG)
975
+ return {
976
+ basePath: '/f/' + DEV_CAMPAIGN_SLUG,
977
+ campaignSlug: DEV_CAMPAIGN_SLUG,
978
+ initialPage: DEFAULT_INITIAL,
979
+ }` : `
980
+ return {
981
+ basePath: '',
982
+ campaignSlug: '',
983
+ initialPage: DEFAULT_INITIAL,
984
+ }`}
751
985
  }
752
986
 
753
- function App() {
754
- const [currentPage, setCurrentPage] = useState(getInitialPage)
987
+ const { basePath, campaignSlug, initialPage } = parseUrl()
988
+
989
+ ${isDev ? `
990
+ class ErrorBoundary extends Component {
991
+ constructor(props) {
992
+ super(props)
993
+ this.state = { error: null }
994
+ }
995
+ static getDerivedStateFromError(error) {
996
+ return { error }
997
+ }
998
+ componentDidCatch(error, info) {
999
+ console.error('[AppFunnel] Render error:', error, info)
1000
+ }
1001
+ render() {
1002
+ if (this.state.error) {
1003
+ return (
1004
+ <div style={{ padding: '2rem', fontFamily: 'monospace' }}>
1005
+ <h2 style={{ color: 'red' }}>AppFunnel Error</h2>
1006
+ <pre style={{ whiteSpace: 'pre-wrap', color: '#333' }}>{this.state.error.message}</pre>
1007
+ <pre style={{ whiteSpace: 'pre-wrap', color: '#666', fontSize: '12px' }}>{this.state.error.stack}</pre>
1008
+ </div>
1009
+ )
1010
+ }
1011
+ return this.props.children
1012
+ }
1013
+ }
1014
+ ` : ""}
1015
+
1016
+ /**
1017
+ * PageRenderer lives inside FunnelProvider so it can use SDK hooks.
1018
+ * Subscribes to the router \u2014 re-renders when the page changes.
1019
+ * Uses useTransition to keep showing the current page while the next one loads.
1020
+ */
1021
+ function PageRenderer() {
1022
+ const { currentPage, goToPage } = useNavigation()
1023
+ const routerPageKey = currentPage?.key || ''
1024
+
1025
+ // Track the displayed page separately so we can transition smoothly
1026
+ const [displayedKey, setDisplayedKey] = useState(routerPageKey)
1027
+ const [isPending, startTransition] = useTransition()
1028
+
1029
+ // When the router's page changes, transition to the new page
1030
+ useEffect(() => {
1031
+ if (routerPageKey && routerPageKey !== displayedKey) {
1032
+ startTransition(() => {
1033
+ setDisplayedKey(routerPageKey)
1034
+ })
1035
+ }
1036
+ }, [routerPageKey, displayedKey])
1037
+
1038
+ // Sync URL with current page
1039
+ const slug = currentPage?.slug || routerPageKey
1040
+ useEffect(() => {
1041
+ const expectedPath = basePath ? basePath + '/' + slug : '/' + slug
1042
+ if (slug && window.location.pathname !== expectedPath) {
1043
+ window.history.pushState(null, '', expectedPath)
1044
+ }
1045
+ }, [slug])
755
1046
 
1047
+ // Handle browser back/forward
756
1048
  useEffect(() => {
757
1049
  const handlePopState = () => {
758
1050
  const path = window.location.pathname.split('/').filter(Boolean).pop() || ''
759
- const pageKey = slugToKey[path]
760
- if (pageKey && pageKey !== currentPage) {
761
- setCurrentPage(pageKey)
1051
+ const key = slugToKey[path]
1052
+ if (key && key !== routerPageKey) {
1053
+ goToPage(key)
762
1054
  }
763
1055
  }
764
1056
  window.addEventListener('popstate', handlePopState)
765
1057
  return () => window.removeEventListener('popstate', handlePopState)
766
- }, [currentPage])
1058
+ }, [routerPageKey, goToPage])
767
1059
 
768
- // Expose navigation to FunnelProvider's router
769
- useEffect(() => {
770
- window.__APPFUNNEL_NAVIGATE__ = (pageKey) => {
771
- setCurrentPage(pageKey)
772
- const slug = keyToSlug[pageKey] || pageKey
773
- window.history.pushState(null, '', '/' + slug)
774
- }
775
- return () => { delete window.__APPFUNNEL_NAVIGATE__ }
776
- }, [])
777
-
778
- const PageComponent = pages[currentPage]
1060
+ const PageComponent = pageComponents[displayedKey]
779
1061
 
780
1062
  if (!PageComponent) {
781
- return <div style={{ padding: '2rem', color: 'red' }}>Page not found: {currentPage}</div>
1063
+ return <div style={{ padding: '2rem', color: 'red' }}>Page not found: {displayedKey}</div>
782
1064
  }
783
1065
 
1066
+ return (
1067
+ <Suspense fallback={null}>
1068
+ <PageComponent />
1069
+ </Suspense>
1070
+ )
1071
+ }
1072
+
1073
+ // Runtime data injected by the server (production only)
1074
+ const __rd = typeof window !== 'undefined' && window.__APPFUNNEL_DATA__
1075
+
1076
+ function App() {
1077
+ // In production, merge server-injected integrations into config
1078
+ const runtimeConfig = __rd && __rd.integrations
1079
+ ? { ...config, integrations: { ...config.integrations, ...__rd.integrations } }
1080
+ : config
1081
+
1082
+ const sessionData = __rd ? {
1083
+ campaignId: __rd.campaignId || '',
1084
+ funnelId: __rd.funnelId || config.funnelId || '',
1085
+ experimentId: __rd.experimentId || null,
1086
+ } : undefined
1087
+
784
1088
  return (
785
1089
  <FunnelProvider
786
- config={config}
787
- initialPage={currentPage}
788
- apiBaseUrl={${isDev ? "''" : "config.settings?.apiBaseUrl || ''"}}
1090
+ config={runtimeConfig}
1091
+ initialPage={initialPage}
1092
+ basePath={basePath}
1093
+ campaignSlug={campaignSlug}
1094
+ priceData={priceData}
1095
+ sessionData={sessionData}
789
1096
  >
790
1097
  <FunnelWrapper>
791
- <Suspense fallback={null}>
792
- <PageComponent />
793
- </Suspense>
1098
+ <PageRenderer />
794
1099
  </FunnelWrapper>
795
1100
  </FunnelProvider>
796
1101
  )
@@ -798,9 +1103,14 @@ function App() {
798
1103
 
799
1104
  createRoot(document.getElementById('root')).render(
800
1105
  <StrictMode>
801
- <App />
1106
+ ${isDev ? "<ErrorBoundary>" : ""}
1107
+ <App />
1108
+ ${isDev ? "</ErrorBoundary>" : ""}
802
1109
  </StrictMode>
803
1110
  )
1111
+
1112
+ // Reveal body (the host page may set opacity:0 for a loading transition)
1113
+ document.body.style.opacity = '1'
804
1114
  `;
805
1115
  }
806
1116
  var init_entry = __esm({
@@ -832,54 +1142,66 @@ var init_html = __esm({
832
1142
 
833
1143
  // src/vite/plugin.ts
834
1144
  import { resolve as resolve2, join as join7 } from "path";
835
- import { existsSync as existsSync4 } from "fs";
1145
+ import { existsSync as existsSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync6 } from "fs";
836
1146
  function appfunnelPlugin(options) {
837
1147
  const { cwd, config, isDev } = options;
838
1148
  let pages = options.pages;
839
1149
  const pagesDir = resolve2(cwd, "src", "pages");
840
1150
  const funnelTsxPath = resolve2(cwd, "src", "funnel.tsx");
841
- let server;
1151
+ const appfunnelDir = join7(cwd, APPFUNNEL_DIR);
1152
+ const htmlPath = join7(appfunnelDir, "index.html");
842
1153
  function getEntrySource() {
843
1154
  return generateEntrySource({
844
1155
  config,
845
1156
  pages,
846
1157
  pagesDir,
847
1158
  funnelTsxPath,
848
- isDev
1159
+ isDev,
1160
+ priceData: options.priceData
849
1161
  });
850
1162
  }
851
1163
  return {
852
1164
  name: "appfunnel",
853
1165
  config() {
1166
+ mkdirSync2(appfunnelDir, { recursive: true });
1167
+ writeFileSync3(htmlPath, generateHtml(config.name || "AppFunnel"));
854
1168
  return {
1169
+ // Don't let Vite auto-serve index.html — we handle it ourselves
1170
+ appType: "custom",
855
1171
  resolve: {
856
1172
  alias: {
857
1173
  "@": resolve2(cwd, "src")
858
1174
  }
859
1175
  },
860
- // Ensure we can import .tsx files
861
1176
  esbuild: {
862
1177
  jsx: "automatic"
863
1178
  },
864
1179
  optimizeDeps: {
865
- include: ["react", "react-dom", "react/jsx-runtime"]
1180
+ include: ["react", "react-dom", "react/jsx-runtime"],
1181
+ force: true
866
1182
  }
867
1183
  };
868
1184
  },
869
1185
  resolveId(id) {
870
- if (id === VIRTUAL_ENTRY_ID) {
1186
+ if (id === VIRTUAL_ENTRY_ID || id === "/" + VIRTUAL_ENTRY_ID) {
871
1187
  return RESOLVED_VIRTUAL_ENTRY_ID;
872
1188
  }
873
1189
  return null;
874
1190
  },
875
- load(id) {
1191
+ async load(id) {
876
1192
  if (id === RESOLVED_VIRTUAL_ENTRY_ID) {
877
- return getEntrySource();
1193
+ const { transform } = await import("esbuild");
1194
+ const source = getEntrySource();
1195
+ const result = await transform(source, {
1196
+ loader: "tsx",
1197
+ jsx: "automatic",
1198
+ sourcefile: "appfunnel-entry.tsx"
1199
+ });
1200
+ return result.code;
878
1201
  }
879
1202
  return null;
880
1203
  },
881
1204
  configureServer(devServer) {
882
- server = devServer;
883
1205
  const watcher = devServer.watcher;
884
1206
  watcher.add(pagesDir);
885
1207
  const handlePagesChange = async () => {
@@ -903,7 +1225,7 @@ function appfunnelPlugin(options) {
903
1225
  }
904
1226
  });
905
1227
  const configPath = join7(cwd, "appfunnel.config.ts");
906
- if (existsSync4(configPath)) {
1228
+ if (existsSync5(configPath)) {
907
1229
  watcher.add(configPath);
908
1230
  watcher.on("change", (file) => {
909
1231
  if (file === configPath) {
@@ -912,33 +1234,37 @@ function appfunnelPlugin(options) {
912
1234
  });
913
1235
  }
914
1236
  return () => {
915
- devServer.middlewares.use((req, res, next) => {
916
- if (req.url?.startsWith("/@") || req.url?.startsWith("/node_modules") || req.url?.startsWith("/src") || req.url?.includes(".")) {
1237
+ devServer.middlewares.use(async (req, res, next) => {
1238
+ const url = req.url?.split("?")[0] || "";
1239
+ if (url.includes(".") || url.startsWith("/@") || url.startsWith("/node_modules")) {
917
1240
  return next();
918
1241
  }
919
- const html = generateHtml(config.name || "AppFunnel");
920
- devServer.transformIndexHtml(req.url || "/", html).then((transformed) => {
1242
+ try {
1243
+ const rawHtml = readFileSync6(htmlPath, "utf-8");
1244
+ const html = await devServer.transformIndexHtml(req.url || "/", rawHtml);
921
1245
  res.statusCode = 200;
922
1246
  res.setHeader("Content-Type", "text/html");
923
- res.end(transformed);
924
- }).catch(next);
1247
+ res.end(html);
1248
+ } catch (err) {
1249
+ next(err);
1250
+ }
925
1251
  });
926
1252
  };
927
1253
  },
928
- // For production build: inject the HTML as the input
929
1254
  transformIndexHtml(html) {
930
1255
  return html;
931
1256
  }
932
1257
  };
933
1258
  }
934
- var VIRTUAL_ENTRY_ID, RESOLVED_VIRTUAL_ENTRY_ID;
1259
+ var VIRTUAL_ENTRY_ID, RESOLVED_VIRTUAL_ENTRY_ID, APPFUNNEL_DIR;
935
1260
  var init_plugin = __esm({
936
1261
  "src/vite/plugin.ts"() {
937
1262
  "use strict";
938
1263
  init_entry();
939
1264
  init_html();
940
1265
  VIRTUAL_ENTRY_ID = "@appfunnel/entry";
941
- RESOLVED_VIRTUAL_ENTRY_ID = "\0" + VIRTUAL_ENTRY_ID;
1266
+ RESOLVED_VIRTUAL_ENTRY_ID = "\0" + VIRTUAL_ENTRY_ID + ".tsx";
1267
+ APPFUNNEL_DIR = ".appfunnel";
942
1268
  }
943
1269
  });
944
1270
 
@@ -947,7 +1273,7 @@ var dev_exports = {};
947
1273
  __export(dev_exports, {
948
1274
  devCommand: () => devCommand
949
1275
  });
950
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1276
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
951
1277
  import { join as join8 } from "path";
952
1278
  import pc6 from "picocolors";
953
1279
  async function devCommand(options) {
@@ -963,13 +1289,22 @@ async function devCommand(options) {
963
1289
  const projectId = await promptForProject(creds.token);
964
1290
  config.projectId = projectId;
965
1291
  const configPath = join8(cwd, "appfunnel.config.ts");
966
- const configSource = readFileSync5(configPath, "utf-8");
967
- const updated = configSource.replace(
968
- /projectId:\s*['"].*?['"]/,
969
- `projectId: '${projectId}'`
970
- );
1292
+ const configSource = readFileSync7(configPath, "utf-8");
1293
+ let updated;
1294
+ if (/projectId:\s*['"]/.test(configSource)) {
1295
+ updated = configSource.replace(
1296
+ /projectId:\s*['"].*?['"]/,
1297
+ `projectId: '${projectId}'`
1298
+ );
1299
+ } else {
1300
+ updated = configSource.replace(
1301
+ /(defineConfig\(\{[\t ]*\n)/,
1302
+ `$1 projectId: '${projectId}',
1303
+ `
1304
+ );
1305
+ }
971
1306
  if (updated !== configSource) {
972
- writeFileSync3(configPath, updated);
1307
+ writeFileSync4(configPath, updated);
973
1308
  success(`Updated projectId in appfunnel.config.ts`);
974
1309
  } else {
975
1310
  warn(`Could not auto-update appfunnel.config.ts \u2014 add projectId: '${projectId}' manually.`);
@@ -980,8 +1315,43 @@ async function devCommand(options) {
980
1315
  let pages = await extractPageDefinitions(cwd, pageKeys);
981
1316
  s2.stop();
982
1317
  info(`Found ${pageKeys.length} pages: ${pageKeys.join(", ")}`);
1318
+ let priceData = /* @__PURE__ */ new Map();
1319
+ if (config.projectId && config.products?.items?.length) {
1320
+ try {
1321
+ const storePriceIds = [
1322
+ ...new Set(
1323
+ config.products.items.flatMap(
1324
+ (item) => [item.storePriceId, item.trialStorePriceId].filter(Boolean)
1325
+ )
1326
+ )
1327
+ ];
1328
+ info(`Fetching ${storePriceIds.length} store prices: ${storePriceIds.join(", ")}`);
1329
+ const s3 = spinner("Fetching store prices...");
1330
+ priceData = await fetchPrices(config.projectId, storePriceIds, { token: creds.token });
1331
+ s3.stop();
1332
+ const missingIds = storePriceIds.filter((id) => !priceData.has(id));
1333
+ if (missingIds.length > 0) {
1334
+ error(`Missing store prices: ${missingIds.join(", ")}`);
1335
+ error("Make sure these storePriceId values in your config match prices in your project.");
1336
+ process.exit(1);
1337
+ }
1338
+ success(`Fetched ${priceData.size}/${storePriceIds.length} store prices`);
1339
+ } catch (err) {
1340
+ error(`Failed to fetch store prices: ${err.message}`);
1341
+ process.exit(1);
1342
+ }
1343
+ }
983
1344
  const { createServer: createServer2 } = await import("vite");
984
1345
  const react = await import("@vitejs/plugin-react");
1346
+ let tailwindPlugin = null;
1347
+ try {
1348
+ const { createRequire } = await import("module");
1349
+ const require2 = createRequire(join8(cwd, "package.json"));
1350
+ const tailwindPath = require2.resolve("@tailwindcss/vite");
1351
+ const tailwindVite = await import(tailwindPath);
1352
+ tailwindPlugin = tailwindVite.default;
1353
+ } catch {
1354
+ }
985
1355
  const server = await createServer2({
986
1356
  root: cwd,
987
1357
  server: {
@@ -990,11 +1360,13 @@ async function devCommand(options) {
990
1360
  },
991
1361
  plugins: [
992
1362
  react.default(),
1363
+ ...tailwindPlugin ? [tailwindPlugin()] : [],
993
1364
  appfunnelPlugin({
994
1365
  cwd,
995
1366
  config,
996
1367
  pages,
997
1368
  isDev: true,
1369
+ priceData,
998
1370
  async onPagesChange() {
999
1371
  pageKeys = scanPages(cwd);
1000
1372
  pages = await extractPageDefinitions(cwd, pageKeys);
@@ -1028,6 +1400,7 @@ var init_dev = __esm({
1028
1400
  init_pages();
1029
1401
  init_plugin();
1030
1402
  init_projects();
1403
+ init_api();
1031
1404
  }
1032
1405
  });
1033
1406
 
@@ -1036,8 +1409,9 @@ var build_exports = {};
1036
1409
  __export(build_exports, {
1037
1410
  buildCommand: () => buildCommand
1038
1411
  });
1039
- import { resolve as resolve4, join as join9 } from "path";
1040
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync4, statSync, readdirSync as readdirSync2 } from "fs";
1412
+ import { resolve as resolve3, join as join9 } from "path";
1413
+ import { randomUUID as randomUUID2 } from "crypto";
1414
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync5, statSync, readdirSync as readdirSync3 } from "fs";
1041
1415
  import pc7 from "picocolors";
1042
1416
  async function buildCommand() {
1043
1417
  const cwd = process.cwd();
@@ -1058,15 +1432,25 @@ async function buildCommand() {
1058
1432
  s.stop();
1059
1433
  validateRoutes(config, pages, pageKeys);
1060
1434
  info(`Building ${pageKeys.length} pages...`);
1061
- const outDir = resolve4(cwd, ".appfunnel");
1435
+ const outDir = resolve3(cwd, "dist");
1062
1436
  const { build } = await import("vite");
1063
1437
  const react = await import("@vitejs/plugin-react");
1064
- const htmlPath = resolve4(cwd, "index.html");
1438
+ let tailwindPlugin = null;
1439
+ try {
1440
+ const { createRequire } = await import("module");
1441
+ const require2 = createRequire(join9(cwd, "package.json"));
1442
+ const tailwindPath = require2.resolve("@tailwindcss/vite");
1443
+ const tailwindVite = await import(tailwindPath);
1444
+ tailwindPlugin = tailwindVite.default;
1445
+ } catch {
1446
+ }
1447
+ const htmlPath = resolve3(cwd, "index.html");
1065
1448
  const htmlContent = generateHtml(config.name || "AppFunnel");
1066
- writeFileSync4(htmlPath, htmlContent);
1449
+ writeFileSync5(htmlPath, htmlContent);
1067
1450
  try {
1068
1451
  await build({
1069
1452
  root: cwd,
1453
+ base: "./",
1070
1454
  build: {
1071
1455
  outDir,
1072
1456
  emptyOutDir: true,
@@ -1087,6 +1471,7 @@ async function buildCommand() {
1087
1471
  },
1088
1472
  plugins: [
1089
1473
  react.default(),
1474
+ ...tailwindPlugin ? [tailwindPlugin()] : [],
1090
1475
  appfunnelPlugin({
1091
1476
  cwd,
1092
1477
  config,
@@ -1122,9 +1507,12 @@ async function buildCommand() {
1122
1507
  const totalSize = assets.reduce((sum, a) => sum + a.size, 0);
1123
1508
  const manifest = {
1124
1509
  version: 1,
1510
+ buildHash: randomUUID2(),
1125
1511
  sdkVersion: getSdkVersion2(cwd),
1126
1512
  projectId: config.projectId,
1127
1513
  funnelId: config.funnelId,
1514
+ name: config.name,
1515
+ initialPageKey: config.initialPageKey,
1128
1516
  pages: { ...config.pages, ...mergedPages },
1129
1517
  routes: { ...config.routes, ...mergedRoutes },
1130
1518
  responses: config.responses || {},
@@ -1134,15 +1522,15 @@ async function buildCommand() {
1134
1522
  assets,
1135
1523
  totalSize
1136
1524
  };
1137
- writeFileSync4(join9(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
1525
+ writeFileSync5(join9(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
1138
1526
  console.log();
1139
1527
  success("Build complete");
1140
1528
  console.log();
1141
- console.log(` ${pc7.dim("Output:")} .appfunnel/`);
1529
+ console.log(` ${pc7.dim("Output:")} dist/`);
1142
1530
  console.log(` ${pc7.dim("Pages:")} ${pageKeys.length}`);
1143
1531
  console.log(` ${pc7.dim("Size:")} ${formatSize(totalSize)}`);
1144
1532
  console.log();
1145
- for (const asset of assets.filter((a) => a.path.endsWith(".js"))) {
1533
+ for (const asset of assets) {
1146
1534
  const sizeStr = formatSize(asset.size);
1147
1535
  const isOver = asset.size > MAX_PAGE_SIZE;
1148
1536
  console.log(` ${isOver ? pc7.yellow("!") : pc7.dim("\xB7")} ${pc7.dim(asset.path)} ${isOver ? pc7.yellow(sizeStr) : pc7.dim(sizeStr)}`);
@@ -1234,7 +1622,7 @@ function validateConditionVariables(condition, pageKey, allVariables) {
1234
1622
  function collectAssets(outDir) {
1235
1623
  const assets = [];
1236
1624
  function walk(dir, prefix = "") {
1237
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
1625
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
1238
1626
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
1239
1627
  const fullPath = join9(dir, entry.name);
1240
1628
  if (entry.isDirectory()) {
@@ -1254,7 +1642,7 @@ function formatSize(bytes) {
1254
1642
  }
1255
1643
  function getSdkVersion2(cwd) {
1256
1644
  try {
1257
- const pkg = JSON.parse(readFileSync6(join9(cwd, "node_modules", "@appfunnel", "sdk", "package.json"), "utf-8"));
1645
+ const pkg = JSON.parse(readFileSync8(join9(cwd, "node_modules", "@appfunnel", "sdk", "package.json"), "utf-8"));
1258
1646
  return pkg.version;
1259
1647
  } catch {
1260
1648
  return "0.0.0";
@@ -1285,83 +1673,25 @@ var init_build = __esm({
1285
1673
  }
1286
1674
  });
1287
1675
 
1288
- // src/lib/api.ts
1289
- async function apiFetch(path, options) {
1290
- const { token, apiBaseUrl, ...fetchOpts } = options;
1291
- const base = apiBaseUrl || DEFAULT_API_BASE3;
1292
- const url = `${base}${path}`;
1293
- const isFormData = fetchOpts.body instanceof FormData;
1294
- const headers = {
1295
- Authorization: token,
1296
- ...fetchOpts.headers || {}
1297
- };
1298
- if (!isFormData) {
1299
- headers["Content-Type"] = "application/json";
1300
- }
1301
- const response = await fetch(url, {
1302
- ...fetchOpts,
1303
- headers
1304
- });
1305
- if (!response.ok) {
1306
- const body = await response.text().catch(() => "");
1307
- let message = `API request failed: ${response.status} ${response.statusText}`;
1308
- try {
1309
- const parsed = JSON.parse(body);
1310
- if (parsed.error) message = parsed.error;
1311
- if (parsed.message) message = parsed.message;
1312
- } catch {
1313
- }
1314
- const error2 = new CLIError("API_ERROR", message);
1315
- error2.statusCode = response.status;
1316
- throw error2;
1317
- }
1318
- return response;
1319
- }
1320
- async function publishBuild(projectId, funnelId, manifest, assets, options) {
1321
- const formData = new FormData();
1322
- formData.set("manifest", JSON.stringify(manifest));
1323
- formData.set("funnelId", funnelId);
1324
- for (const asset of assets) {
1325
- formData.append(
1326
- "assets",
1327
- new Blob([new Uint8Array(asset.content)], { type: asset.contentType }),
1328
- asset.path
1329
- );
1330
- }
1331
- try {
1332
- const response = await apiFetch(`/project/${projectId}/headless/publish`, {
1333
- ...options,
1334
- method: "POST",
1335
- body: formData
1336
- });
1337
- return await response.json();
1338
- } catch (err) {
1339
- if (err instanceof CLIError && err.code === "API_ERROR") {
1340
- if (err.statusCode === 413) {
1341
- throw new CLIError(
1342
- "BUNDLE_TOO_LARGE",
1343
- err.message,
1344
- "Reduce page bundle sizes. Check for large dependencies."
1345
- );
1346
- }
1347
- if (err.statusCode === 409) {
1348
- throw new CLIError(
1349
- "FUNNEL_NOT_HEADLESS",
1350
- err.message,
1351
- "Create a new headless funnel from the dashboard, or remove funnelId from config."
1352
- );
1353
- }
1354
- throw new CLIError("PUBLISH_FAILED", err.message);
1355
- }
1356
- throw err;
1676
+ // src/lib/config-patch.ts
1677
+ import { join as join10 } from "path";
1678
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
1679
+ function patchConfigFunnelId(cwd, funnelId) {
1680
+ const configPath = join10(cwd, "appfunnel.config.ts");
1681
+ let content = readFileSync9(configPath, "utf-8");
1682
+ if (content.includes("funnelId")) return;
1683
+ const patched = content.replace(
1684
+ /(projectId:\s*['"][^'"]+['"],?\s*\n)/,
1685
+ `$1 funnelId: '${funnelId}',
1686
+ `
1687
+ );
1688
+ if (patched !== content) {
1689
+ writeFileSync6(configPath, patched, "utf-8");
1357
1690
  }
1358
1691
  }
1359
- var DEFAULT_API_BASE3;
1360
- var init_api = __esm({
1361
- "src/lib/api.ts"() {
1692
+ var init_config_patch = __esm({
1693
+ "src/lib/config-patch.ts"() {
1362
1694
  "use strict";
1363
- init_errors();
1364
- DEFAULT_API_BASE3 = "https://api.appfunnel.net";
1365
1695
  }
1366
1696
  });
1367
1697
 
@@ -1370,34 +1700,49 @@ var publish_exports = {};
1370
1700
  __export(publish_exports, {
1371
1701
  publishCommand: () => publishCommand
1372
1702
  });
1373
- import { resolve as resolve5, join as join10 } from "path";
1374
- import { readFileSync as readFileSync7, existsSync as existsSync5 } from "fs";
1703
+ import { resolve as resolve4, join as join11 } from "path";
1704
+ import { readFileSync as readFileSync10, existsSync as existsSync6 } from "fs";
1375
1705
  import pc8 from "picocolors";
1376
1706
  function getMimeType(path) {
1377
1707
  const ext = path.substring(path.lastIndexOf("."));
1378
1708
  return MIME_TYPES[ext] || "application/octet-stream";
1379
1709
  }
1380
- async function publishCommand() {
1710
+ function formatSize2(bytes) {
1711
+ if (bytes < 1024) return `${bytes}B`;
1712
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
1713
+ return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
1714
+ }
1715
+ async function publishCommand(options) {
1381
1716
  const cwd = process.cwd();
1382
1717
  const creds = requireAuth();
1383
1718
  checkVersionCompatibility(cwd);
1384
1719
  const config = await loadConfig(cwd);
1385
- const outDir = resolve5(cwd, ".appfunnel");
1386
- const manifestPath = join10(outDir, "manifest.json");
1387
- if (!existsSync5(manifestPath)) {
1720
+ const projectId = config.projectId;
1721
+ if (!projectId) {
1722
+ throw new CLIError(
1723
+ "CONFIG_NOT_FOUND",
1724
+ "No projectId in appfunnel.config.ts.",
1725
+ "Add projectId to your config. You can find it in the dashboard."
1726
+ );
1727
+ }
1728
+ const outDir = resolve4(cwd, "dist");
1729
+ const manifestPath = join11(outDir, "manifest.json");
1730
+ if (!existsSync6(manifestPath)) {
1388
1731
  throw new CLIError(
1389
1732
  "BUILD_NOT_FOUND",
1390
1733
  "No build output found.",
1391
1734
  "Run 'appfunnel build' first."
1392
1735
  );
1393
1736
  }
1394
- const manifest = JSON.parse(readFileSync7(manifestPath, "utf-8"));
1737
+ const manifest = JSON.parse(readFileSync10(manifestPath, "utf-8"));
1395
1738
  const assets = manifest.assets || [];
1396
- const s = spinner("Uploading build...");
1739
+ const s = spinner("Preparing assets...");
1397
1740
  const assetPayloads = [];
1398
- for (const asset of assets) {
1399
- const fullPath = join10(outDir, asset.path);
1400
- if (!existsSync5(fullPath)) {
1741
+ let totalBytes = 0;
1742
+ for (let i = 0; i < assets.length; i++) {
1743
+ const asset = assets[i];
1744
+ const fullPath = join11(outDir, asset.path);
1745
+ if (!existsSync6(fullPath)) {
1401
1746
  s.stop();
1402
1747
  throw new CLIError(
1403
1748
  "BUILD_NOT_FOUND",
@@ -1405,44 +1750,42 @@ async function publishCommand() {
1405
1750
  "Run 'appfunnel build' to regenerate."
1406
1751
  );
1407
1752
  }
1753
+ const content = readFileSync10(fullPath);
1754
+ totalBytes += content.length;
1408
1755
  assetPayloads.push({
1409
1756
  path: asset.path,
1410
- content: readFileSync7(fullPath),
1757
+ content,
1411
1758
  contentType: getMimeType(asset.path)
1412
1759
  });
1760
+ s.text = `Preparing assets... ${i + 1}/${assets.length} ${pc8.dim(`(${formatSize2(totalBytes)})`)}`;
1413
1761
  }
1414
- const projectId = config.projectId;
1415
- const funnelId = config.funnelId;
1416
- if (!projectId) {
1417
- s.stop();
1418
- throw new CLIError(
1419
- "CONFIG_NOT_FOUND",
1420
- "No projectId in appfunnel.config.ts.",
1421
- "Add projectId to your config. You can find it in the dashboard."
1422
- );
1423
- }
1424
- if (!funnelId) {
1425
- s.stop();
1426
- throw new CLIError(
1427
- "CONFIG_NOT_FOUND",
1428
- "No funnelId in appfunnel.config.ts.",
1429
- "Add funnelId to your config, or create a new funnel from the dashboard."
1430
- );
1431
- }
1762
+ s.text = `Uploading ${assets.length} assets ${pc8.dim(`(${formatSize2(totalBytes)})`)}`;
1432
1763
  const result = await publishBuild(
1433
1764
  projectId,
1434
- funnelId,
1765
+ config.funnelId || "",
1435
1766
  manifest,
1436
1767
  assetPayloads,
1437
- { token: creds.token }
1768
+ { token: creds.token },
1769
+ options?.promote
1438
1770
  );
1439
1771
  s.stop();
1772
+ if (result.created && result.funnelId) {
1773
+ patchConfigFunnelId(cwd, result.funnelId);
1774
+ info(`Funnel created \u2014 funnelId added to appfunnel.config.ts`);
1775
+ }
1440
1776
  console.log();
1441
- success("Published successfully");
1777
+ success(result.activated ? "Published and activated" : "Published successfully");
1442
1778
  console.log();
1443
- console.log(` ${pc8.dim("Build ID:")} ${result.buildId}`);
1444
- console.log(` ${pc8.dim("URL:")} ${pc8.cyan(result.url)}`);
1445
- console.log(` ${pc8.dim("Assets:")} ${assets.length} files`);
1779
+ console.log(` ${pc8.dim("Build ID:")} ${result.buildId}`);
1780
+ if (result.funnelId && !config.funnelId) {
1781
+ console.log(` ${pc8.dim("Funnel:")} ${result.funnelId}`);
1782
+ }
1783
+ console.log(` ${pc8.dim("Dashboard:")} ${pc8.cyan(result.dashboardUrl)}`);
1784
+ console.log(` ${pc8.dim("Assets:")} ${assets.length} files ${pc8.dim(`(${formatSize2(totalBytes)})`)}`);
1785
+ if (!result.activated) {
1786
+ console.log();
1787
+ console.log(` ${pc8.dim("Tip:")} Use ${pc8.cyan("--promote")} to activate immediately, or promote from the dashboard.`);
1788
+ }
1446
1789
  console.log();
1447
1790
  }
1448
1791
  var MIME_TYPES;
@@ -1455,6 +1798,7 @@ var init_publish = __esm({
1455
1798
  init_version();
1456
1799
  init_api();
1457
1800
  init_errors();
1801
+ init_config_patch();
1458
1802
  MIME_TYPES = {
1459
1803
  ".js": "application/javascript",
1460
1804
  ".css": "text/css",
@@ -1474,10 +1818,10 @@ init_errors();
1474
1818
  import { Command } from "commander";
1475
1819
  import pc9 from "picocolors";
1476
1820
  var program = new Command();
1477
- program.name("appfunnel").description("Build and publish headless AppFunnel projects").version("0.5.0");
1478
- program.command("init <name>").description("Create a new AppFunnel project").action(async (name) => {
1821
+ program.name("appfunnel").description("Build and publish headless AppFunnel projects").version("0.7.0");
1822
+ program.command("init").description("Create a new AppFunnel project").action(async () => {
1479
1823
  const { initCommand: initCommand2 } = await Promise.resolve().then(() => (init_init(), init_exports));
1480
- await initCommand2(name);
1824
+ await initCommand2();
1481
1825
  });
1482
1826
  program.command("login").description("Authenticate with AppFunnel").action(async () => {
1483
1827
  const { loginCommand: loginCommand2 } = await Promise.resolve().then(() => (init_login(), login_exports));
@@ -1495,9 +1839,9 @@ program.command("build").description("Build the funnel for production").action(a
1495
1839
  const { buildCommand: buildCommand2 } = await Promise.resolve().then(() => (init_build(), build_exports));
1496
1840
  await buildCommand2();
1497
1841
  });
1498
- program.command("publish").description("Publish the build to AppFunnel").action(async () => {
1842
+ program.command("publish").description("Publish the build to AppFunnel").option("--promote", "Activate the build immediately after publishing").action(async (options) => {
1499
1843
  const { publishCommand: publishCommand2 } = await Promise.resolve().then(() => (init_publish(), publish_exports));
1500
- await publishCommand2();
1844
+ await publishCommand2({ promote: options.promote });
1501
1845
  });
1502
1846
  program.hook("postAction", () => {
1503
1847
  });