appfunnel 0.4.0 → 0.6.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
@@ -48,7 +48,6 @@ var init_errors = __esm({
48
48
  });
49
49
 
50
50
  // src/lib/logger.ts
51
- import { readFileSync } from "fs";
52
51
  import pc2 from "picocolors";
53
52
  import ora from "ora";
54
53
  function success(msg) {
@@ -73,12 +72,12 @@ var init_logger = __esm({
73
72
  });
74
73
 
75
74
  // src/lib/auth.ts
76
- import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "fs";
75
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
77
76
  import { join } from "path";
78
77
  import { homedir } from "os";
79
78
  function readCredentials() {
80
79
  try {
81
- const raw = readFileSync2(CREDENTIALS_PATH, "utf-8");
80
+ const raw = readFileSync(CREDENTIALS_PATH, "utf-8");
82
81
  const data = JSON.parse(raw);
83
82
  if (!data.token) return null;
84
83
  return data;
@@ -212,12 +211,12 @@ async function initCommand(name) {
212
211
  publish: "appfunnel publish"
213
212
  },
214
213
  dependencies: {
215
- "@appfunnel-dev/sdk": "^0.3.0",
214
+ "@appfunnel-dev/sdk": "^0.6.0",
216
215
  react: "^18.3.0",
217
216
  "react-dom": "^18.3.0"
218
217
  },
219
218
  devDependencies: {
220
- appfunnel: "^0.3.0",
219
+ appfunnel: "^0.6.0",
221
220
  typescript: "^5.4.0",
222
221
  "@types/react": "^18.2.0",
223
222
  "@types/react-dom": "^18.2.0",
@@ -365,7 +364,7 @@ import { randomUUID } from "crypto";
365
364
  import open from "open";
366
365
  async function loginCommand() {
367
366
  const state = randomUUID();
368
- return new Promise((resolve6, reject) => {
367
+ return new Promise((resolve5, reject) => {
369
368
  const server = createServer((req, res) => {
370
369
  const url = new URL(req.url || "/", `http://localhost`);
371
370
  if (url.pathname !== "/callback") {
@@ -404,7 +403,7 @@ async function loginCommand() {
404
403
  spinner2.stop();
405
404
  success(`Logged in as ${email || userId}`);
406
405
  server.close();
407
- resolve6();
406
+ resolve5();
408
407
  });
409
408
  server.listen(0, "127.0.0.1", () => {
410
409
  const addr = server.address();
@@ -493,7 +492,7 @@ var init_whoami = __esm({
493
492
  });
494
493
 
495
494
  // src/lib/config.ts
496
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
495
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
497
496
  import { join as join3, resolve } from "path";
498
497
  async function loadConfig(cwd) {
499
498
  const configPath = join3(cwd, CONFIG_FILE);
@@ -505,7 +504,7 @@ async function loadConfig(cwd) {
505
504
  );
506
505
  }
507
506
  const { transform } = await import("esbuild");
508
- const raw = readFileSync3(configPath, "utf-8");
507
+ const raw = readFileSync2(configPath, "utf-8");
509
508
  const result = await transform(raw, {
510
509
  loader: "ts",
511
510
  format: "esm",
@@ -537,10 +536,10 @@ var init_config = __esm({
537
536
  });
538
537
 
539
538
  // src/lib/version.ts
540
- import { readFileSync as readFileSync4 } from "fs";
539
+ import { readFileSync as readFileSync3 } from "fs";
541
540
  import { join as join4 } from "path";
542
541
  function checkVersionCompatibility(cwd) {
543
- const cliVersion = getCliVersion();
542
+ const cliVersion = "0.6.0";
544
543
  const sdkVersion = getSdkVersion(cwd);
545
544
  const [cliMajor, cliMinor] = cliVersion.split(".").map(Number);
546
545
  const [sdkMajor, sdkMinor] = sdkVersion.split(".").map(Number);
@@ -552,19 +551,6 @@ function checkVersionCompatibility(cwd) {
552
551
  );
553
552
  }
554
553
  }
555
- function getCliVersion() {
556
- try {
557
- const pkg = JSON.parse(
558
- readFileSync4(
559
- new URL("../../package.json", import.meta.url),
560
- "utf-8"
561
- )
562
- );
563
- return pkg.version;
564
- } catch {
565
- return "0.0.0";
566
- }
567
- }
568
554
  function getSdkVersion(cwd) {
569
555
  try {
570
556
  const pkgPath = join4(
@@ -574,7 +560,7 @@ function getSdkVersion(cwd) {
574
560
  "sdk",
575
561
  "package.json"
576
562
  );
577
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
563
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
578
564
  return pkg.version;
579
565
  } catch {
580
566
  throw new CLIError(
@@ -592,7 +578,7 @@ var init_version = __esm({
592
578
  });
593
579
 
594
580
  // src/extract/pages.ts
595
- import { readdirSync, readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
581
+ import { readdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
596
582
  import { join as join5, basename } from "path";
597
583
  function scanPages(cwd) {
598
584
  const pagesDir = resolvePagesDir(cwd);
@@ -619,7 +605,7 @@ async function extractPageDefinitions(cwd, pageKeys) {
619
605
  const result = {};
620
606
  for (const key of pageKeys) {
621
607
  const filePath = join5(pagesDir, `${key}.tsx`);
622
- const source = readFileSync5(filePath, "utf-8");
608
+ const source = readFileSync4(filePath, "utf-8");
623
609
  const definition = extractDefinePage(ts, source, filePath);
624
610
  if (definition) {
625
611
  result[key] = definition;
@@ -708,6 +694,7 @@ var init_pages = __esm({
708
694
 
709
695
  // src/vite/entry.ts
710
696
  import { join as join6 } from "path";
697
+ import { existsSync as existsSync4 } from "fs";
711
698
  function generateEntrySource(options) {
712
699
  const { config, pages, pagesDir, funnelTsxPath, isDev } = options;
713
700
  const pageKeys = Object.keys(pages);
@@ -735,76 +722,197 @@ function generateEntrySource(options) {
735
722
  for (const [key, def] of Object.entries(pages)) {
736
723
  slugMap[def.slug || key] = key;
737
724
  }
725
+ const priceDataCode = isDev ? options.priceData && options.priceData.size > 0 ? `const priceData = new Map(${JSON.stringify([...options.priceData.entries()])});` : `const priceData = undefined;` : `const priceData = (() => {
726
+ const rd = typeof window !== 'undefined' && window.__APPFUNNEL_DATA__;
727
+ if (!rd || !rd.products?.items) return undefined;
728
+ // Build price map from server-injected product data
729
+ const map = new Map();
730
+ for (const item of rd.products.items) {
731
+ if (item.priceData && item.storePriceId) map.set(item.storePriceId, item.priceData);
732
+ if (item.trialPriceData && item.trialStorePriceId) map.set(item.trialStorePriceId, item.trialPriceData);
733
+ }
734
+ return map.size > 0 ? map : undefined;
735
+ })();`;
738
736
  const trackingCode = isDev ? `
739
737
  // Dev mode: mock tracking \u2014 log events to console
740
- const originalFetch = globalThis.fetch;
741
738
  globalThis.__APPFUNNEL_DEV__ = true;
742
739
  ` : "";
740
+ const appCssPath = join6(pagesDir, "..", "app.css").replace(/\\/g, "/");
741
+ const hasAppCss = existsSync4(join6(pagesDir, "..", "app.css"));
743
742
  return `
744
- import { StrictMode, lazy, Suspense, useState, useEffect, useCallback } from 'react'
743
+ import { StrictMode, Component, lazy, Suspense, useState, useCallback, useEffect, useTransition, useDeferredValue, useSyncExternalStore } from 'react'
745
744
  import { createRoot } from 'react-dom/client'
746
- import { FunnelProvider } from '@appfunnel-dev/sdk/internal'
745
+ import { FunnelProvider, useNavigation } from '@appfunnel-dev/sdk'
746
+ ${hasAppCss ? `import '${appCssPath}'` : ""}
747
747
  import FunnelWrapper from '${funnelTsxPath.replace(/\\/g, "/")}'
748
748
 
749
749
  ${trackingCode}
750
750
 
751
- const pages = {
751
+ const pageComponents = {
752
752
  ${pageImports}
753
753
  }
754
754
 
755
+ ${priceDataCode}
756
+
755
757
  const config = ${JSON.stringify(fullConfig, null, 2)}
756
758
 
757
- const slugToKey = ${JSON.stringify(slugMap)}
758
759
  const keyToSlug = ${JSON.stringify(
759
760
  Object.fromEntries(Object.entries(slugMap).map(([s, k]) => [k, s]))
760
761
  )}
762
+ const slugToKey = ${JSON.stringify(slugMap)}
763
+
764
+ const DEV_CAMPAIGN_SLUG = 'campaign'
765
+ const DEFAULT_INITIAL = '${config.initialPageKey || pageKeys[0] || "index"}'
766
+
767
+ /**
768
+ * Parse the URL to extract basePath, campaignSlug, and initial page.
769
+ *
770
+ * URL pattern: /f/<campaignSlug>[/<pageSlug>]
771
+ *
772
+ * In dev, redirects bare / to /f/<projectId> so the URL matches production.
773
+ */
774
+ function parseUrl() {
775
+ const parts = window.location.pathname.split('/').filter(Boolean)
776
+
777
+ // /f/<campaignSlug>[/<pageSlug>]
778
+ if (parts[0] === 'f' && parts.length >= 2) {
779
+ const campaignSlug = parts[1]
780
+ const pageSlug = parts[2] || ''
781
+ const pageKey = pageSlug ? (slugToKey[pageSlug] || '') : ''
782
+ return {
783
+ basePath: '/f/' + campaignSlug,
784
+ campaignSlug,
785
+ initialPage: pageKey || DEFAULT_INITIAL,
786
+ }
787
+ }
761
788
 
762
- function getInitialPage() {
763
- const path = window.location.pathname.split('/').filter(Boolean).pop() || ''
764
- return slugToKey[path] || '${config.initialPage || pageKeys[0] || "index"}'
789
+ // Bare URL \u2192 redirect to /f/<slug> in dev
790
+ ${isDev ? `
791
+ window.history.replaceState(null, '', '/f/' + DEV_CAMPAIGN_SLUG)
792
+ return {
793
+ basePath: '/f/' + DEV_CAMPAIGN_SLUG,
794
+ campaignSlug: DEV_CAMPAIGN_SLUG,
795
+ initialPage: DEFAULT_INITIAL,
796
+ }` : `
797
+ return {
798
+ basePath: '',
799
+ campaignSlug: '',
800
+ initialPage: DEFAULT_INITIAL,
801
+ }`}
765
802
  }
766
803
 
767
- function App() {
768
- const [currentPage, setCurrentPage] = useState(getInitialPage)
804
+ const { basePath, campaignSlug, initialPage } = parseUrl()
805
+
806
+ ${isDev ? `
807
+ class ErrorBoundary extends Component {
808
+ constructor(props) {
809
+ super(props)
810
+ this.state = { error: null }
811
+ }
812
+ static getDerivedStateFromError(error) {
813
+ return { error }
814
+ }
815
+ componentDidCatch(error, info) {
816
+ console.error('[AppFunnel] Render error:', error, info)
817
+ }
818
+ render() {
819
+ if (this.state.error) {
820
+ return (
821
+ <div style={{ padding: '2rem', fontFamily: 'monospace' }}>
822
+ <h2 style={{ color: 'red' }}>AppFunnel Error</h2>
823
+ <pre style={{ whiteSpace: 'pre-wrap', color: '#333' }}>{this.state.error.message}</pre>
824
+ <pre style={{ whiteSpace: 'pre-wrap', color: '#666', fontSize: '12px' }}>{this.state.error.stack}</pre>
825
+ </div>
826
+ )
827
+ }
828
+ return this.props.children
829
+ }
830
+ }
831
+ ` : ""}
769
832
 
833
+ /**
834
+ * PageRenderer lives inside FunnelProvider so it can use SDK hooks.
835
+ * Subscribes to the router \u2014 re-renders when the page changes.
836
+ * Uses useTransition to keep showing the current page while the next one loads.
837
+ */
838
+ function PageRenderer() {
839
+ const { currentPage, goToPage } = useNavigation()
840
+ const routerPageKey = currentPage?.key || ''
841
+
842
+ // Track the displayed page separately so we can transition smoothly
843
+ const [displayedKey, setDisplayedKey] = useState(routerPageKey)
844
+ const [isPending, startTransition] = useTransition()
845
+
846
+ // When the router's page changes, transition to the new page
847
+ useEffect(() => {
848
+ if (routerPageKey && routerPageKey !== displayedKey) {
849
+ startTransition(() => {
850
+ setDisplayedKey(routerPageKey)
851
+ })
852
+ }
853
+ }, [routerPageKey, displayedKey])
854
+
855
+ // Sync URL with current page
856
+ const slug = currentPage?.slug || routerPageKey
857
+ useEffect(() => {
858
+ const expectedPath = basePath ? basePath + '/' + slug : '/' + slug
859
+ if (slug && window.location.pathname !== expectedPath) {
860
+ window.history.pushState(null, '', expectedPath)
861
+ }
862
+ }, [slug])
863
+
864
+ // Handle browser back/forward
770
865
  useEffect(() => {
771
866
  const handlePopState = () => {
772
867
  const path = window.location.pathname.split('/').filter(Boolean).pop() || ''
773
- const pageKey = slugToKey[path]
774
- if (pageKey && pageKey !== currentPage) {
775
- setCurrentPage(pageKey)
868
+ const key = slugToKey[path]
869
+ if (key && key !== routerPageKey) {
870
+ goToPage(key)
776
871
  }
777
872
  }
778
873
  window.addEventListener('popstate', handlePopState)
779
874
  return () => window.removeEventListener('popstate', handlePopState)
780
- }, [currentPage])
781
-
782
- // Expose navigation to FunnelProvider's router
783
- useEffect(() => {
784
- window.__APPFUNNEL_NAVIGATE__ = (pageKey) => {
785
- setCurrentPage(pageKey)
786
- const slug = keyToSlug[pageKey] || pageKey
787
- window.history.pushState(null, '', '/' + slug)
788
- }
789
- return () => { delete window.__APPFUNNEL_NAVIGATE__ }
790
- }, [])
875
+ }, [routerPageKey, goToPage])
791
876
 
792
- const PageComponent = pages[currentPage]
877
+ const PageComponent = pageComponents[displayedKey]
793
878
 
794
879
  if (!PageComponent) {
795
- return <div style={{ padding: '2rem', color: 'red' }}>Page not found: {currentPage}</div>
880
+ return <div style={{ padding: '2rem', color: 'red' }}>Page not found: {displayedKey}</div>
796
881
  }
797
882
 
883
+ return (
884
+ <Suspense fallback={null}>
885
+ <PageComponent />
886
+ </Suspense>
887
+ )
888
+ }
889
+
890
+ // Runtime data injected by the server (production only)
891
+ const __rd = typeof window !== 'undefined' && window.__APPFUNNEL_DATA__
892
+
893
+ function App() {
894
+ // In production, merge server-injected integrations into config
895
+ const runtimeConfig = __rd && __rd.integrations
896
+ ? { ...config, integrations: { ...config.integrations, ...__rd.integrations } }
897
+ : config
898
+
899
+ const sessionData = __rd ? {
900
+ campaignId: __rd.campaignId || '',
901
+ funnelId: __rd.funnelId || config.funnelId || '',
902
+ experimentId: __rd.experimentId || null,
903
+ } : undefined
904
+
798
905
  return (
799
906
  <FunnelProvider
800
- config={config}
801
- initialPage={currentPage}
802
- apiBaseUrl={${isDev ? "''" : "config.settings?.apiBaseUrl || ''"}}
907
+ config={runtimeConfig}
908
+ initialPage={initialPage}
909
+ basePath={basePath}
910
+ campaignSlug={campaignSlug}
911
+ priceData={priceData}
912
+ sessionData={sessionData}
803
913
  >
804
914
  <FunnelWrapper>
805
- <Suspense fallback={null}>
806
- <PageComponent />
807
- </Suspense>
915
+ <PageRenderer />
808
916
  </FunnelWrapper>
809
917
  </FunnelProvider>
810
918
  )
@@ -812,9 +920,14 @@ function App() {
812
920
 
813
921
  createRoot(document.getElementById('root')).render(
814
922
  <StrictMode>
815
- <App />
923
+ ${isDev ? "<ErrorBoundary>" : ""}
924
+ <App />
925
+ ${isDev ? "</ErrorBoundary>" : ""}
816
926
  </StrictMode>
817
927
  )
928
+
929
+ // Reveal body (the host page may set opacity:0 for a loading transition)
930
+ document.body.style.opacity = '1'
818
931
  `;
819
932
  }
820
933
  var init_entry = __esm({
@@ -846,54 +959,66 @@ var init_html = __esm({
846
959
 
847
960
  // src/vite/plugin.ts
848
961
  import { resolve as resolve2, join as join7 } from "path";
849
- import { existsSync as existsSync4 } from "fs";
962
+ import { existsSync as existsSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5 } from "fs";
850
963
  function appfunnelPlugin(options) {
851
964
  const { cwd, config, isDev } = options;
852
965
  let pages = options.pages;
853
966
  const pagesDir = resolve2(cwd, "src", "pages");
854
967
  const funnelTsxPath = resolve2(cwd, "src", "funnel.tsx");
855
- let server;
968
+ const appfunnelDir = join7(cwd, APPFUNNEL_DIR);
969
+ const htmlPath = join7(appfunnelDir, "index.html");
856
970
  function getEntrySource() {
857
971
  return generateEntrySource({
858
972
  config,
859
973
  pages,
860
974
  pagesDir,
861
975
  funnelTsxPath,
862
- isDev
976
+ isDev,
977
+ priceData: options.priceData
863
978
  });
864
979
  }
865
980
  return {
866
981
  name: "appfunnel",
867
982
  config() {
983
+ mkdirSync3(appfunnelDir, { recursive: true });
984
+ writeFileSync3(htmlPath, generateHtml(config.name || "AppFunnel"));
868
985
  return {
986
+ // Don't let Vite auto-serve index.html — we handle it ourselves
987
+ appType: "custom",
869
988
  resolve: {
870
989
  alias: {
871
990
  "@": resolve2(cwd, "src")
872
991
  }
873
992
  },
874
- // Ensure we can import .tsx files
875
993
  esbuild: {
876
994
  jsx: "automatic"
877
995
  },
878
996
  optimizeDeps: {
879
- include: ["react", "react-dom", "react/jsx-runtime"]
997
+ include: ["react", "react-dom", "react/jsx-runtime"],
998
+ force: true
880
999
  }
881
1000
  };
882
1001
  },
883
1002
  resolveId(id) {
884
- if (id === VIRTUAL_ENTRY_ID) {
1003
+ if (id === VIRTUAL_ENTRY_ID || id === "/" + VIRTUAL_ENTRY_ID) {
885
1004
  return RESOLVED_VIRTUAL_ENTRY_ID;
886
1005
  }
887
1006
  return null;
888
1007
  },
889
- load(id) {
1008
+ async load(id) {
890
1009
  if (id === RESOLVED_VIRTUAL_ENTRY_ID) {
891
- return getEntrySource();
1010
+ const { transform } = await import("esbuild");
1011
+ const source = getEntrySource();
1012
+ const result = await transform(source, {
1013
+ loader: "tsx",
1014
+ jsx: "automatic",
1015
+ sourcefile: "appfunnel-entry.tsx"
1016
+ });
1017
+ return result.code;
892
1018
  }
893
1019
  return null;
894
1020
  },
895
1021
  configureServer(devServer) {
896
- server = devServer;
897
1022
  const watcher = devServer.watcher;
898
1023
  watcher.add(pagesDir);
899
1024
  const handlePagesChange = async () => {
@@ -917,7 +1042,7 @@ function appfunnelPlugin(options) {
917
1042
  }
918
1043
  });
919
1044
  const configPath = join7(cwd, "appfunnel.config.ts");
920
- if (existsSync4(configPath)) {
1045
+ if (existsSync5(configPath)) {
921
1046
  watcher.add(configPath);
922
1047
  watcher.on("change", (file) => {
923
1048
  if (file === configPath) {
@@ -926,33 +1051,129 @@ function appfunnelPlugin(options) {
926
1051
  });
927
1052
  }
928
1053
  return () => {
929
- devServer.middlewares.use((req, res, next) => {
930
- if (req.url?.startsWith("/@") || req.url?.startsWith("/node_modules") || req.url?.startsWith("/src") || req.url?.includes(".")) {
1054
+ devServer.middlewares.use(async (req, res, next) => {
1055
+ const url = req.url?.split("?")[0] || "";
1056
+ if (url.includes(".") || url.startsWith("/@") || url.startsWith("/node_modules")) {
931
1057
  return next();
932
1058
  }
933
- const html = generateHtml(config.name || "AppFunnel");
934
- devServer.transformIndexHtml(req.url || "/", html).then((transformed) => {
1059
+ try {
1060
+ const rawHtml = readFileSync5(htmlPath, "utf-8");
1061
+ const html = await devServer.transformIndexHtml(req.url || "/", rawHtml);
935
1062
  res.statusCode = 200;
936
1063
  res.setHeader("Content-Type", "text/html");
937
- res.end(transformed);
938
- }).catch(next);
1064
+ res.end(html);
1065
+ } catch (err) {
1066
+ next(err);
1067
+ }
939
1068
  });
940
1069
  };
941
1070
  },
942
- // For production build: inject the HTML as the input
943
1071
  transformIndexHtml(html) {
944
1072
  return html;
945
1073
  }
946
1074
  };
947
1075
  }
948
- var VIRTUAL_ENTRY_ID, RESOLVED_VIRTUAL_ENTRY_ID;
1076
+ var VIRTUAL_ENTRY_ID, RESOLVED_VIRTUAL_ENTRY_ID, APPFUNNEL_DIR;
949
1077
  var init_plugin = __esm({
950
1078
  "src/vite/plugin.ts"() {
951
1079
  "use strict";
952
1080
  init_entry();
953
1081
  init_html();
954
1082
  VIRTUAL_ENTRY_ID = "@appfunnel/entry";
955
- RESOLVED_VIRTUAL_ENTRY_ID = "\0" + VIRTUAL_ENTRY_ID;
1083
+ RESOLVED_VIRTUAL_ENTRY_ID = "\0" + VIRTUAL_ENTRY_ID + ".tsx";
1084
+ APPFUNNEL_DIR = ".appfunnel";
1085
+ }
1086
+ });
1087
+
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";
956
1177
  }
957
1178
  });
958
1179
 
@@ -961,7 +1182,7 @@ var dev_exports = {};
961
1182
  __export(dev_exports, {
962
1183
  devCommand: () => devCommand
963
1184
  });
964
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
1185
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
965
1186
  import { join as join8 } from "path";
966
1187
  import pc6 from "picocolors";
967
1188
  async function devCommand(options) {
@@ -978,12 +1199,21 @@ async function devCommand(options) {
978
1199
  config.projectId = projectId;
979
1200
  const configPath = join8(cwd, "appfunnel.config.ts");
980
1201
  const configSource = readFileSync6(configPath, "utf-8");
981
- const updated = configSource.replace(
982
- /projectId:\s*['"].*?['"]/,
983
- `projectId: '${projectId}'`
984
- );
1202
+ let updated;
1203
+ if (/projectId:\s*['"]/.test(configSource)) {
1204
+ updated = configSource.replace(
1205
+ /projectId:\s*['"].*?['"]/,
1206
+ `projectId: '${projectId}'`
1207
+ );
1208
+ } else {
1209
+ updated = configSource.replace(
1210
+ /(defineConfig\(\{[\t ]*\n)/,
1211
+ `$1 projectId: '${projectId}',
1212
+ `
1213
+ );
1214
+ }
985
1215
  if (updated !== configSource) {
986
- writeFileSync3(configPath, updated);
1216
+ writeFileSync4(configPath, updated);
987
1217
  success(`Updated projectId in appfunnel.config.ts`);
988
1218
  } else {
989
1219
  warn(`Could not auto-update appfunnel.config.ts \u2014 add projectId: '${projectId}' manually.`);
@@ -994,8 +1224,43 @@ async function devCommand(options) {
994
1224
  let pages = await extractPageDefinitions(cwd, pageKeys);
995
1225
  s2.stop();
996
1226
  info(`Found ${pageKeys.length} pages: ${pageKeys.join(", ")}`);
1227
+ let priceData = /* @__PURE__ */ new Map();
1228
+ if (config.projectId && config.products?.items?.length) {
1229
+ try {
1230
+ const storePriceIds = [
1231
+ ...new Set(
1232
+ config.products.items.flatMap(
1233
+ (item) => [item.storePriceId, item.trialStorePriceId].filter(Boolean)
1234
+ )
1235
+ )
1236
+ ];
1237
+ info(`Fetching ${storePriceIds.length} store prices: ${storePriceIds.join(", ")}`);
1238
+ const s3 = spinner("Fetching store prices...");
1239
+ priceData = await fetchPrices(config.projectId, storePriceIds, { token: creds.token });
1240
+ s3.stop();
1241
+ const missingIds = storePriceIds.filter((id) => !priceData.has(id));
1242
+ if (missingIds.length > 0) {
1243
+ error(`Missing store prices: ${missingIds.join(", ")}`);
1244
+ error("Make sure these storePriceId values in your config match prices in your project.");
1245
+ process.exit(1);
1246
+ }
1247
+ success(`Fetched ${priceData.size}/${storePriceIds.length} store prices`);
1248
+ } catch (err) {
1249
+ error(`Failed to fetch store prices: ${err.message}`);
1250
+ process.exit(1);
1251
+ }
1252
+ }
997
1253
  const { createServer: createServer2 } = await import("vite");
998
1254
  const react = await import("@vitejs/plugin-react");
1255
+ let tailwindPlugin = null;
1256
+ try {
1257
+ const { createRequire } = await import("module");
1258
+ const require2 = createRequire(join8(cwd, "package.json"));
1259
+ const tailwindPath = require2.resolve("@tailwindcss/vite");
1260
+ const tailwindVite = await import(tailwindPath);
1261
+ tailwindPlugin = tailwindVite.default;
1262
+ } catch {
1263
+ }
999
1264
  const server = await createServer2({
1000
1265
  root: cwd,
1001
1266
  server: {
@@ -1004,11 +1269,13 @@ async function devCommand(options) {
1004
1269
  },
1005
1270
  plugins: [
1006
1271
  react.default(),
1272
+ ...tailwindPlugin ? [tailwindPlugin()] : [],
1007
1273
  appfunnelPlugin({
1008
1274
  cwd,
1009
1275
  config,
1010
1276
  pages,
1011
1277
  isDev: true,
1278
+ priceData,
1012
1279
  async onPagesChange() {
1013
1280
  pageKeys = scanPages(cwd);
1014
1281
  pages = await extractPageDefinitions(cwd, pageKeys);
@@ -1042,6 +1309,7 @@ var init_dev = __esm({
1042
1309
  init_pages();
1043
1310
  init_plugin();
1044
1311
  init_projects();
1312
+ init_api();
1045
1313
  }
1046
1314
  });
1047
1315
 
@@ -1050,8 +1318,9 @@ var build_exports = {};
1050
1318
  __export(build_exports, {
1051
1319
  buildCommand: () => buildCommand
1052
1320
  });
1053
- import { resolve as resolve4, join as join9 } from "path";
1054
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, statSync, readdirSync as readdirSync2 } from "fs";
1321
+ import { resolve as resolve3, join as join9 } from "path";
1322
+ import { randomUUID as randomUUID2 } from "crypto";
1323
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, statSync, readdirSync as readdirSync2 } from "fs";
1055
1324
  import pc7 from "picocolors";
1056
1325
  async function buildCommand() {
1057
1326
  const cwd = process.cwd();
@@ -1072,15 +1341,25 @@ async function buildCommand() {
1072
1341
  s.stop();
1073
1342
  validateRoutes(config, pages, pageKeys);
1074
1343
  info(`Building ${pageKeys.length} pages...`);
1075
- const outDir = resolve4(cwd, ".appfunnel");
1344
+ const outDir = resolve3(cwd, "dist");
1076
1345
  const { build } = await import("vite");
1077
1346
  const react = await import("@vitejs/plugin-react");
1078
- const htmlPath = resolve4(cwd, "index.html");
1347
+ let tailwindPlugin = null;
1348
+ try {
1349
+ const { createRequire } = await import("module");
1350
+ const require2 = createRequire(join9(cwd, "package.json"));
1351
+ const tailwindPath = require2.resolve("@tailwindcss/vite");
1352
+ const tailwindVite = await import(tailwindPath);
1353
+ tailwindPlugin = tailwindVite.default;
1354
+ } catch {
1355
+ }
1356
+ const htmlPath = resolve3(cwd, "index.html");
1079
1357
  const htmlContent = generateHtml(config.name || "AppFunnel");
1080
- writeFileSync4(htmlPath, htmlContent);
1358
+ writeFileSync5(htmlPath, htmlContent);
1081
1359
  try {
1082
1360
  await build({
1083
1361
  root: cwd,
1362
+ base: "./",
1084
1363
  build: {
1085
1364
  outDir,
1086
1365
  emptyOutDir: true,
@@ -1101,6 +1380,7 @@ async function buildCommand() {
1101
1380
  },
1102
1381
  plugins: [
1103
1382
  react.default(),
1383
+ ...tailwindPlugin ? [tailwindPlugin()] : [],
1104
1384
  appfunnelPlugin({
1105
1385
  cwd,
1106
1386
  config,
@@ -1136,9 +1416,12 @@ async function buildCommand() {
1136
1416
  const totalSize = assets.reduce((sum, a) => sum + a.size, 0);
1137
1417
  const manifest = {
1138
1418
  version: 1,
1419
+ buildHash: randomUUID2(),
1139
1420
  sdkVersion: getSdkVersion2(cwd),
1140
1421
  projectId: config.projectId,
1141
1422
  funnelId: config.funnelId,
1423
+ name: config.name,
1424
+ initialPageKey: config.initialPageKey,
1142
1425
  pages: { ...config.pages, ...mergedPages },
1143
1426
  routes: { ...config.routes, ...mergedRoutes },
1144
1427
  responses: config.responses || {},
@@ -1148,11 +1431,11 @@ async function buildCommand() {
1148
1431
  assets,
1149
1432
  totalSize
1150
1433
  };
1151
- writeFileSync4(join9(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
1434
+ writeFileSync5(join9(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
1152
1435
  console.log();
1153
1436
  success("Build complete");
1154
1437
  console.log();
1155
- console.log(` ${pc7.dim("Output:")} .appfunnel/`);
1438
+ console.log(` ${pc7.dim("Output:")} dist/`);
1156
1439
  console.log(` ${pc7.dim("Pages:")} ${pageKeys.length}`);
1157
1440
  console.log(` ${pc7.dim("Size:")} ${formatSize(totalSize)}`);
1158
1441
  console.log();
@@ -1299,83 +1582,25 @@ var init_build = __esm({
1299
1582
  }
1300
1583
  });
1301
1584
 
1302
- // src/lib/api.ts
1303
- async function apiFetch(path, options) {
1304
- const { token, apiBaseUrl, ...fetchOpts } = options;
1305
- const base = apiBaseUrl || DEFAULT_API_BASE3;
1306
- const url = `${base}${path}`;
1307
- const isFormData = fetchOpts.body instanceof FormData;
1308
- const headers = {
1309
- Authorization: token,
1310
- ...fetchOpts.headers || {}
1311
- };
1312
- if (!isFormData) {
1313
- headers["Content-Type"] = "application/json";
1314
- }
1315
- const response = await fetch(url, {
1316
- ...fetchOpts,
1317
- headers
1318
- });
1319
- if (!response.ok) {
1320
- const body = await response.text().catch(() => "");
1321
- let message = `API request failed: ${response.status} ${response.statusText}`;
1322
- try {
1323
- const parsed = JSON.parse(body);
1324
- if (parsed.error) message = parsed.error;
1325
- if (parsed.message) message = parsed.message;
1326
- } catch {
1327
- }
1328
- const error2 = new CLIError("API_ERROR", message);
1329
- error2.statusCode = response.status;
1330
- throw error2;
1331
- }
1332
- return response;
1333
- }
1334
- async function publishBuild(projectId, funnelId, manifest, assets, options) {
1335
- const formData = new FormData();
1336
- formData.set("manifest", JSON.stringify(manifest));
1337
- formData.set("funnelId", funnelId);
1338
- for (const asset of assets) {
1339
- formData.append(
1340
- "assets",
1341
- new Blob([new Uint8Array(asset.content)], { type: asset.contentType }),
1342
- asset.path
1343
- );
1344
- }
1345
- try {
1346
- const response = await apiFetch(`/project/${projectId}/headless/publish`, {
1347
- ...options,
1348
- method: "POST",
1349
- body: formData
1350
- });
1351
- return await response.json();
1352
- } catch (err) {
1353
- if (err instanceof CLIError && err.code === "API_ERROR") {
1354
- if (err.statusCode === 413) {
1355
- throw new CLIError(
1356
- "BUNDLE_TOO_LARGE",
1357
- err.message,
1358
- "Reduce page bundle sizes. Check for large dependencies."
1359
- );
1360
- }
1361
- if (err.statusCode === 409) {
1362
- throw new CLIError(
1363
- "FUNNEL_NOT_HEADLESS",
1364
- err.message,
1365
- "Create a new headless funnel from the dashboard, or remove funnelId from config."
1366
- );
1367
- }
1368
- throw new CLIError("PUBLISH_FAILED", err.message);
1369
- }
1370
- throw err;
1585
+ // src/lib/config-patch.ts
1586
+ import { join as join10 } from "path";
1587
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
1588
+ function patchConfigFunnelId(cwd, funnelId) {
1589
+ const configPath = join10(cwd, "appfunnel.config.ts");
1590
+ let content = readFileSync8(configPath, "utf-8");
1591
+ if (content.includes("funnelId")) return;
1592
+ const patched = content.replace(
1593
+ /(projectId:\s*['"][^'"]+['"],?\s*\n)/,
1594
+ `$1 funnelId: '${funnelId}',
1595
+ `
1596
+ );
1597
+ if (patched !== content) {
1598
+ writeFileSync6(configPath, patched, "utf-8");
1371
1599
  }
1372
1600
  }
1373
- var DEFAULT_API_BASE3;
1374
- var init_api = __esm({
1375
- "src/lib/api.ts"() {
1601
+ var init_config_patch = __esm({
1602
+ "src/lib/config-patch.ts"() {
1376
1603
  "use strict";
1377
- init_errors();
1378
- DEFAULT_API_BASE3 = "https://api.appfunnel.net";
1379
1604
  }
1380
1605
  });
1381
1606
 
@@ -1384,34 +1609,49 @@ var publish_exports = {};
1384
1609
  __export(publish_exports, {
1385
1610
  publishCommand: () => publishCommand
1386
1611
  });
1387
- import { resolve as resolve5, join as join10 } from "path";
1388
- import { readFileSync as readFileSync8, existsSync as existsSync5 } from "fs";
1612
+ import { resolve as resolve4, join as join11 } from "path";
1613
+ import { readFileSync as readFileSync9, existsSync as existsSync6 } from "fs";
1389
1614
  import pc8 from "picocolors";
1390
1615
  function getMimeType(path) {
1391
1616
  const ext = path.substring(path.lastIndexOf("."));
1392
1617
  return MIME_TYPES[ext] || "application/octet-stream";
1393
1618
  }
1619
+ function formatSize2(bytes) {
1620
+ if (bytes < 1024) return `${bytes}B`;
1621
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
1622
+ return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
1623
+ }
1394
1624
  async function publishCommand() {
1395
1625
  const cwd = process.cwd();
1396
1626
  const creds = requireAuth();
1397
1627
  checkVersionCompatibility(cwd);
1398
1628
  const config = await loadConfig(cwd);
1399
- const outDir = resolve5(cwd, ".appfunnel");
1400
- const manifestPath = join10(outDir, "manifest.json");
1401
- if (!existsSync5(manifestPath)) {
1629
+ const projectId = config.projectId;
1630
+ if (!projectId) {
1631
+ throw new CLIError(
1632
+ "CONFIG_NOT_FOUND",
1633
+ "No projectId in appfunnel.config.ts.",
1634
+ "Add projectId to your config. You can find it in the dashboard."
1635
+ );
1636
+ }
1637
+ const outDir = resolve4(cwd, "dist");
1638
+ const manifestPath = join11(outDir, "manifest.json");
1639
+ if (!existsSync6(manifestPath)) {
1402
1640
  throw new CLIError(
1403
1641
  "BUILD_NOT_FOUND",
1404
1642
  "No build output found.",
1405
1643
  "Run 'appfunnel build' first."
1406
1644
  );
1407
1645
  }
1408
- const manifest = JSON.parse(readFileSync8(manifestPath, "utf-8"));
1646
+ const manifest = JSON.parse(readFileSync9(manifestPath, "utf-8"));
1409
1647
  const assets = manifest.assets || [];
1410
- const s = spinner("Uploading build...");
1648
+ const s = spinner("Preparing assets...");
1411
1649
  const assetPayloads = [];
1412
- for (const asset of assets) {
1413
- const fullPath = join10(outDir, asset.path);
1414
- if (!existsSync5(fullPath)) {
1650
+ let totalBytes = 0;
1651
+ for (let i = 0; i < assets.length; i++) {
1652
+ const asset = assets[i];
1653
+ const fullPath = join11(outDir, asset.path);
1654
+ if (!existsSync6(fullPath)) {
1415
1655
  s.stop();
1416
1656
  throw new CLIError(
1417
1657
  "BUILD_NOT_FOUND",
@@ -1419,44 +1659,37 @@ async function publishCommand() {
1419
1659
  "Run 'appfunnel build' to regenerate."
1420
1660
  );
1421
1661
  }
1662
+ const content = readFileSync9(fullPath);
1663
+ totalBytes += content.length;
1422
1664
  assetPayloads.push({
1423
1665
  path: asset.path,
1424
- content: readFileSync8(fullPath),
1666
+ content,
1425
1667
  contentType: getMimeType(asset.path)
1426
1668
  });
1669
+ s.text = `Preparing assets... ${i + 1}/${assets.length} ${pc8.dim(`(${formatSize2(totalBytes)})`)}`;
1427
1670
  }
1428
- const projectId = config.projectId;
1429
- const funnelId = config.funnelId;
1430
- if (!projectId) {
1431
- s.stop();
1432
- throw new CLIError(
1433
- "CONFIG_NOT_FOUND",
1434
- "No projectId in appfunnel.config.ts.",
1435
- "Add projectId to your config. You can find it in the dashboard."
1436
- );
1437
- }
1438
- if (!funnelId) {
1439
- s.stop();
1440
- throw new CLIError(
1441
- "CONFIG_NOT_FOUND",
1442
- "No funnelId in appfunnel.config.ts.",
1443
- "Add funnelId to your config, or create a new funnel from the dashboard."
1444
- );
1445
- }
1671
+ s.text = `Uploading ${assets.length} assets ${pc8.dim(`(${formatSize2(totalBytes)})`)}`;
1446
1672
  const result = await publishBuild(
1447
1673
  projectId,
1448
- funnelId,
1674
+ config.funnelId || "",
1449
1675
  manifest,
1450
1676
  assetPayloads,
1451
1677
  { token: creds.token }
1452
1678
  );
1453
1679
  s.stop();
1680
+ if (result.created && result.funnelId) {
1681
+ patchConfigFunnelId(cwd, result.funnelId);
1682
+ info(`Funnel created \u2014 funnelId added to appfunnel.config.ts`);
1683
+ }
1454
1684
  console.log();
1455
1685
  success("Published successfully");
1456
1686
  console.log();
1457
1687
  console.log(` ${pc8.dim("Build ID:")} ${result.buildId}`);
1688
+ if (result.funnelId && !config.funnelId) {
1689
+ console.log(` ${pc8.dim("Funnel:")} ${result.funnelId}`);
1690
+ }
1458
1691
  console.log(` ${pc8.dim("URL:")} ${pc8.cyan(result.url)}`);
1459
- console.log(` ${pc8.dim("Assets:")} ${assets.length} files`);
1692
+ console.log(` ${pc8.dim("Assets:")} ${assets.length} files ${pc8.dim(`(${formatSize2(totalBytes)})`)}`);
1460
1693
  console.log();
1461
1694
  }
1462
1695
  var MIME_TYPES;
@@ -1469,6 +1702,7 @@ var init_publish = __esm({
1469
1702
  init_version();
1470
1703
  init_api();
1471
1704
  init_errors();
1705
+ init_config_patch();
1472
1706
  MIME_TYPES = {
1473
1707
  ".js": "application/javascript",
1474
1708
  ".css": "text/css",
@@ -1485,21 +1719,10 @@ var init_publish = __esm({
1485
1719
 
1486
1720
  // src/index.ts
1487
1721
  init_errors();
1488
- import { readFileSync as readFileSync9 } from "fs";
1489
1722
  import { Command } from "commander";
1490
1723
  import pc9 from "picocolors";
1491
- function getCliVersion2() {
1492
- try {
1493
- const pkg = JSON.parse(
1494
- readFileSync9(new URL("../package.json", import.meta.url), "utf-8")
1495
- );
1496
- return pkg.version;
1497
- } catch {
1498
- return "0.0.0";
1499
- }
1500
- }
1501
1724
  var program = new Command();
1502
- program.name("appfunnel").description("Build and publish headless AppFunnel projects").version(getCliVersion2());
1725
+ program.name("appfunnel").description("Build and publish headless AppFunnel projects").version("0.6.0");
1503
1726
  program.command("init <name>").description("Create a new AppFunnel project").action(async (name) => {
1504
1727
  const { initCommand: initCommand2 } = await Promise.resolve().then(() => (init_init(), init_exports));
1505
1728
  await initCommand2(name);