appfunnel 0.5.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
@@ -211,12 +211,12 @@ async function initCommand(name) {
211
211
  publish: "appfunnel publish"
212
212
  },
213
213
  dependencies: {
214
- "@appfunnel-dev/sdk": "^0.5.0",
214
+ "@appfunnel-dev/sdk": "^0.6.0",
215
215
  react: "^18.3.0",
216
216
  "react-dom": "^18.3.0"
217
217
  },
218
218
  devDependencies: {
219
- appfunnel: "^0.5.0",
219
+ appfunnel: "^0.6.0",
220
220
  typescript: "^5.4.0",
221
221
  "@types/react": "^18.2.0",
222
222
  "@types/react-dom": "^18.2.0",
@@ -364,7 +364,7 @@ import { randomUUID } from "crypto";
364
364
  import open from "open";
365
365
  async function loginCommand() {
366
366
  const state = randomUUID();
367
- return new Promise((resolve6, reject) => {
367
+ return new Promise((resolve5, reject) => {
368
368
  const server = createServer((req, res) => {
369
369
  const url = new URL(req.url || "/", `http://localhost`);
370
370
  if (url.pathname !== "/callback") {
@@ -403,7 +403,7 @@ async function loginCommand() {
403
403
  spinner2.stop();
404
404
  success(`Logged in as ${email || userId}`);
405
405
  server.close();
406
- resolve6();
406
+ resolve5();
407
407
  });
408
408
  server.listen(0, "127.0.0.1", () => {
409
409
  const addr = server.address();
@@ -539,7 +539,7 @@ var init_config = __esm({
539
539
  import { readFileSync as readFileSync3 } from "fs";
540
540
  import { join as join4 } from "path";
541
541
  function checkVersionCompatibility(cwd) {
542
- const cliVersion = "0.5.0";
542
+ const cliVersion = "0.6.0";
543
543
  const sdkVersion = getSdkVersion(cwd);
544
544
  const [cliMajor, cliMinor] = cliVersion.split(".").map(Number);
545
545
  const [sdkMajor, sdkMinor] = sdkVersion.split(".").map(Number);
@@ -694,6 +694,7 @@ var init_pages = __esm({
694
694
 
695
695
  // src/vite/entry.ts
696
696
  import { join as join6 } from "path";
697
+ import { existsSync as existsSync4 } from "fs";
697
698
  function generateEntrySource(options) {
698
699
  const { config, pages, pagesDir, funnelTsxPath, isDev } = options;
699
700
  const pageKeys = Object.keys(pages);
@@ -721,76 +722,197 @@ function generateEntrySource(options) {
721
722
  for (const [key, def] of Object.entries(pages)) {
722
723
  slugMap[def.slug || key] = key;
723
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
+ })();`;
724
736
  const trackingCode = isDev ? `
725
737
  // Dev mode: mock tracking \u2014 log events to console
726
- const originalFetch = globalThis.fetch;
727
738
  globalThis.__APPFUNNEL_DEV__ = true;
728
739
  ` : "";
740
+ const appCssPath = join6(pagesDir, "..", "app.css").replace(/\\/g, "/");
741
+ const hasAppCss = existsSync4(join6(pagesDir, "..", "app.css"));
729
742
  return `
730
- import { StrictMode, lazy, Suspense, useState, useEffect, useCallback } from 'react'
743
+ import { StrictMode, Component, lazy, Suspense, useState, useCallback, useEffect, useTransition, useDeferredValue, useSyncExternalStore } from 'react'
731
744
  import { createRoot } from 'react-dom/client'
732
- import { FunnelProvider } from '@appfunnel-dev/sdk/internal'
745
+ import { FunnelProvider, useNavigation } from '@appfunnel-dev/sdk'
746
+ ${hasAppCss ? `import '${appCssPath}'` : ""}
733
747
  import FunnelWrapper from '${funnelTsxPath.replace(/\\/g, "/")}'
734
748
 
735
749
  ${trackingCode}
736
750
 
737
- const pages = {
751
+ const pageComponents = {
738
752
  ${pageImports}
739
753
  }
740
754
 
755
+ ${priceDataCode}
756
+
741
757
  const config = ${JSON.stringify(fullConfig, null, 2)}
742
758
 
743
- const slugToKey = ${JSON.stringify(slugMap)}
744
759
  const keyToSlug = ${JSON.stringify(
745
760
  Object.fromEntries(Object.entries(slugMap).map(([s, k]) => [k, s]))
746
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
+ }
747
788
 
748
- function getInitialPage() {
749
- const path = window.location.pathname.split('/').filter(Boolean).pop() || ''
750
- 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
+ }`}
751
802
  }
752
803
 
753
- function App() {
754
- 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
+ ` : ""}
755
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
756
865
  useEffect(() => {
757
866
  const handlePopState = () => {
758
867
  const path = window.location.pathname.split('/').filter(Boolean).pop() || ''
759
- const pageKey = slugToKey[path]
760
- if (pageKey && pageKey !== currentPage) {
761
- setCurrentPage(pageKey)
868
+ const key = slugToKey[path]
869
+ if (key && key !== routerPageKey) {
870
+ goToPage(key)
762
871
  }
763
872
  }
764
873
  window.addEventListener('popstate', handlePopState)
765
874
  return () => window.removeEventListener('popstate', handlePopState)
766
- }, [currentPage])
767
-
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
- }, [])
875
+ }, [routerPageKey, goToPage])
777
876
 
778
- const PageComponent = pages[currentPage]
877
+ const PageComponent = pageComponents[displayedKey]
779
878
 
780
879
  if (!PageComponent) {
781
- 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>
782
881
  }
783
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
+
784
905
  return (
785
906
  <FunnelProvider
786
- config={config}
787
- initialPage={currentPage}
788
- apiBaseUrl={${isDev ? "''" : "config.settings?.apiBaseUrl || ''"}}
907
+ config={runtimeConfig}
908
+ initialPage={initialPage}
909
+ basePath={basePath}
910
+ campaignSlug={campaignSlug}
911
+ priceData={priceData}
912
+ sessionData={sessionData}
789
913
  >
790
914
  <FunnelWrapper>
791
- <Suspense fallback={null}>
792
- <PageComponent />
793
- </Suspense>
915
+ <PageRenderer />
794
916
  </FunnelWrapper>
795
917
  </FunnelProvider>
796
918
  )
@@ -798,9 +920,14 @@ function App() {
798
920
 
799
921
  createRoot(document.getElementById('root')).render(
800
922
  <StrictMode>
801
- <App />
923
+ ${isDev ? "<ErrorBoundary>" : ""}
924
+ <App />
925
+ ${isDev ? "</ErrorBoundary>" : ""}
802
926
  </StrictMode>
803
927
  )
928
+
929
+ // Reveal body (the host page may set opacity:0 for a loading transition)
930
+ document.body.style.opacity = '1'
804
931
  `;
805
932
  }
806
933
  var init_entry = __esm({
@@ -832,54 +959,66 @@ var init_html = __esm({
832
959
 
833
960
  // src/vite/plugin.ts
834
961
  import { resolve as resolve2, join as join7 } from "path";
835
- import { existsSync as existsSync4 } from "fs";
962
+ import { existsSync as existsSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5 } from "fs";
836
963
  function appfunnelPlugin(options) {
837
964
  const { cwd, config, isDev } = options;
838
965
  let pages = options.pages;
839
966
  const pagesDir = resolve2(cwd, "src", "pages");
840
967
  const funnelTsxPath = resolve2(cwd, "src", "funnel.tsx");
841
- let server;
968
+ const appfunnelDir = join7(cwd, APPFUNNEL_DIR);
969
+ const htmlPath = join7(appfunnelDir, "index.html");
842
970
  function getEntrySource() {
843
971
  return generateEntrySource({
844
972
  config,
845
973
  pages,
846
974
  pagesDir,
847
975
  funnelTsxPath,
848
- isDev
976
+ isDev,
977
+ priceData: options.priceData
849
978
  });
850
979
  }
851
980
  return {
852
981
  name: "appfunnel",
853
982
  config() {
983
+ mkdirSync3(appfunnelDir, { recursive: true });
984
+ writeFileSync3(htmlPath, generateHtml(config.name || "AppFunnel"));
854
985
  return {
986
+ // Don't let Vite auto-serve index.html — we handle it ourselves
987
+ appType: "custom",
855
988
  resolve: {
856
989
  alias: {
857
990
  "@": resolve2(cwd, "src")
858
991
  }
859
992
  },
860
- // Ensure we can import .tsx files
861
993
  esbuild: {
862
994
  jsx: "automatic"
863
995
  },
864
996
  optimizeDeps: {
865
- include: ["react", "react-dom", "react/jsx-runtime"]
997
+ include: ["react", "react-dom", "react/jsx-runtime"],
998
+ force: true
866
999
  }
867
1000
  };
868
1001
  },
869
1002
  resolveId(id) {
870
- if (id === VIRTUAL_ENTRY_ID) {
1003
+ if (id === VIRTUAL_ENTRY_ID || id === "/" + VIRTUAL_ENTRY_ID) {
871
1004
  return RESOLVED_VIRTUAL_ENTRY_ID;
872
1005
  }
873
1006
  return null;
874
1007
  },
875
- load(id) {
1008
+ async load(id) {
876
1009
  if (id === RESOLVED_VIRTUAL_ENTRY_ID) {
877
- 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;
878
1018
  }
879
1019
  return null;
880
1020
  },
881
1021
  configureServer(devServer) {
882
- server = devServer;
883
1022
  const watcher = devServer.watcher;
884
1023
  watcher.add(pagesDir);
885
1024
  const handlePagesChange = async () => {
@@ -903,7 +1042,7 @@ function appfunnelPlugin(options) {
903
1042
  }
904
1043
  });
905
1044
  const configPath = join7(cwd, "appfunnel.config.ts");
906
- if (existsSync4(configPath)) {
1045
+ if (existsSync5(configPath)) {
907
1046
  watcher.add(configPath);
908
1047
  watcher.on("change", (file) => {
909
1048
  if (file === configPath) {
@@ -912,33 +1051,129 @@ function appfunnelPlugin(options) {
912
1051
  });
913
1052
  }
914
1053
  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(".")) {
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")) {
917
1057
  return next();
918
1058
  }
919
- const html = generateHtml(config.name || "AppFunnel");
920
- 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);
921
1062
  res.statusCode = 200;
922
1063
  res.setHeader("Content-Type", "text/html");
923
- res.end(transformed);
924
- }).catch(next);
1064
+ res.end(html);
1065
+ } catch (err) {
1066
+ next(err);
1067
+ }
925
1068
  });
926
1069
  };
927
1070
  },
928
- // For production build: inject the HTML as the input
929
1071
  transformIndexHtml(html) {
930
1072
  return html;
931
1073
  }
932
1074
  };
933
1075
  }
934
- var VIRTUAL_ENTRY_ID, RESOLVED_VIRTUAL_ENTRY_ID;
1076
+ var VIRTUAL_ENTRY_ID, RESOLVED_VIRTUAL_ENTRY_ID, APPFUNNEL_DIR;
935
1077
  var init_plugin = __esm({
936
1078
  "src/vite/plugin.ts"() {
937
1079
  "use strict";
938
1080
  init_entry();
939
1081
  init_html();
940
1082
  VIRTUAL_ENTRY_ID = "@appfunnel/entry";
941
- 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";
942
1177
  }
943
1178
  });
944
1179
 
@@ -947,7 +1182,7 @@ var dev_exports = {};
947
1182
  __export(dev_exports, {
948
1183
  devCommand: () => devCommand
949
1184
  });
950
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1185
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
951
1186
  import { join as join8 } from "path";
952
1187
  import pc6 from "picocolors";
953
1188
  async function devCommand(options) {
@@ -963,13 +1198,22 @@ async function devCommand(options) {
963
1198
  const projectId = await promptForProject(creds.token);
964
1199
  config.projectId = projectId;
965
1200
  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
- );
1201
+ const configSource = readFileSync6(configPath, "utf-8");
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
+ }
971
1215
  if (updated !== configSource) {
972
- writeFileSync3(configPath, updated);
1216
+ writeFileSync4(configPath, updated);
973
1217
  success(`Updated projectId in appfunnel.config.ts`);
974
1218
  } else {
975
1219
  warn(`Could not auto-update appfunnel.config.ts \u2014 add projectId: '${projectId}' manually.`);
@@ -980,8 +1224,43 @@ async function devCommand(options) {
980
1224
  let pages = await extractPageDefinitions(cwd, pageKeys);
981
1225
  s2.stop();
982
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
+ }
983
1253
  const { createServer: createServer2 } = await import("vite");
984
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
+ }
985
1264
  const server = await createServer2({
986
1265
  root: cwd,
987
1266
  server: {
@@ -990,11 +1269,13 @@ async function devCommand(options) {
990
1269
  },
991
1270
  plugins: [
992
1271
  react.default(),
1272
+ ...tailwindPlugin ? [tailwindPlugin()] : [],
993
1273
  appfunnelPlugin({
994
1274
  cwd,
995
1275
  config,
996
1276
  pages,
997
1277
  isDev: true,
1278
+ priceData,
998
1279
  async onPagesChange() {
999
1280
  pageKeys = scanPages(cwd);
1000
1281
  pages = await extractPageDefinitions(cwd, pageKeys);
@@ -1028,6 +1309,7 @@ var init_dev = __esm({
1028
1309
  init_pages();
1029
1310
  init_plugin();
1030
1311
  init_projects();
1312
+ init_api();
1031
1313
  }
1032
1314
  });
1033
1315
 
@@ -1036,8 +1318,9 @@ var build_exports = {};
1036
1318
  __export(build_exports, {
1037
1319
  buildCommand: () => buildCommand
1038
1320
  });
1039
- import { resolve as resolve4, join as join9 } from "path";
1040
- import { readFileSync as readFileSync6, 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";
1041
1324
  import pc7 from "picocolors";
1042
1325
  async function buildCommand() {
1043
1326
  const cwd = process.cwd();
@@ -1058,15 +1341,25 @@ async function buildCommand() {
1058
1341
  s.stop();
1059
1342
  validateRoutes(config, pages, pageKeys);
1060
1343
  info(`Building ${pageKeys.length} pages...`);
1061
- const outDir = resolve4(cwd, ".appfunnel");
1344
+ const outDir = resolve3(cwd, "dist");
1062
1345
  const { build } = await import("vite");
1063
1346
  const react = await import("@vitejs/plugin-react");
1064
- 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");
1065
1357
  const htmlContent = generateHtml(config.name || "AppFunnel");
1066
- writeFileSync4(htmlPath, htmlContent);
1358
+ writeFileSync5(htmlPath, htmlContent);
1067
1359
  try {
1068
1360
  await build({
1069
1361
  root: cwd,
1362
+ base: "./",
1070
1363
  build: {
1071
1364
  outDir,
1072
1365
  emptyOutDir: true,
@@ -1087,6 +1380,7 @@ async function buildCommand() {
1087
1380
  },
1088
1381
  plugins: [
1089
1382
  react.default(),
1383
+ ...tailwindPlugin ? [tailwindPlugin()] : [],
1090
1384
  appfunnelPlugin({
1091
1385
  cwd,
1092
1386
  config,
@@ -1122,9 +1416,12 @@ async function buildCommand() {
1122
1416
  const totalSize = assets.reduce((sum, a) => sum + a.size, 0);
1123
1417
  const manifest = {
1124
1418
  version: 1,
1419
+ buildHash: randomUUID2(),
1125
1420
  sdkVersion: getSdkVersion2(cwd),
1126
1421
  projectId: config.projectId,
1127
1422
  funnelId: config.funnelId,
1423
+ name: config.name,
1424
+ initialPageKey: config.initialPageKey,
1128
1425
  pages: { ...config.pages, ...mergedPages },
1129
1426
  routes: { ...config.routes, ...mergedRoutes },
1130
1427
  responses: config.responses || {},
@@ -1134,11 +1431,11 @@ async function buildCommand() {
1134
1431
  assets,
1135
1432
  totalSize
1136
1433
  };
1137
- writeFileSync4(join9(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
1434
+ writeFileSync5(join9(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
1138
1435
  console.log();
1139
1436
  success("Build complete");
1140
1437
  console.log();
1141
- console.log(` ${pc7.dim("Output:")} .appfunnel/`);
1438
+ console.log(` ${pc7.dim("Output:")} dist/`);
1142
1439
  console.log(` ${pc7.dim("Pages:")} ${pageKeys.length}`);
1143
1440
  console.log(` ${pc7.dim("Size:")} ${formatSize(totalSize)}`);
1144
1441
  console.log();
@@ -1254,7 +1551,7 @@ function formatSize(bytes) {
1254
1551
  }
1255
1552
  function getSdkVersion2(cwd) {
1256
1553
  try {
1257
- const pkg = JSON.parse(readFileSync6(join9(cwd, "node_modules", "@appfunnel", "sdk", "package.json"), "utf-8"));
1554
+ const pkg = JSON.parse(readFileSync7(join9(cwd, "node_modules", "@appfunnel", "sdk", "package.json"), "utf-8"));
1258
1555
  return pkg.version;
1259
1556
  } catch {
1260
1557
  return "0.0.0";
@@ -1285,83 +1582,25 @@ var init_build = __esm({
1285
1582
  }
1286
1583
  });
1287
1584
 
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;
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");
1357
1599
  }
1358
1600
  }
1359
- var DEFAULT_API_BASE3;
1360
- var init_api = __esm({
1361
- "src/lib/api.ts"() {
1601
+ var init_config_patch = __esm({
1602
+ "src/lib/config-patch.ts"() {
1362
1603
  "use strict";
1363
- init_errors();
1364
- DEFAULT_API_BASE3 = "https://api.appfunnel.net";
1365
1604
  }
1366
1605
  });
1367
1606
 
@@ -1370,34 +1609,49 @@ var publish_exports = {};
1370
1609
  __export(publish_exports, {
1371
1610
  publishCommand: () => publishCommand
1372
1611
  });
1373
- import { resolve as resolve5, join as join10 } from "path";
1374
- import { readFileSync as readFileSync7, 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";
1375
1614
  import pc8 from "picocolors";
1376
1615
  function getMimeType(path) {
1377
1616
  const ext = path.substring(path.lastIndexOf("."));
1378
1617
  return MIME_TYPES[ext] || "application/octet-stream";
1379
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
+ }
1380
1624
  async function publishCommand() {
1381
1625
  const cwd = process.cwd();
1382
1626
  const creds = requireAuth();
1383
1627
  checkVersionCompatibility(cwd);
1384
1628
  const config = await loadConfig(cwd);
1385
- const outDir = resolve5(cwd, ".appfunnel");
1386
- const manifestPath = join10(outDir, "manifest.json");
1387
- 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)) {
1388
1640
  throw new CLIError(
1389
1641
  "BUILD_NOT_FOUND",
1390
1642
  "No build output found.",
1391
1643
  "Run 'appfunnel build' first."
1392
1644
  );
1393
1645
  }
1394
- const manifest = JSON.parse(readFileSync7(manifestPath, "utf-8"));
1646
+ const manifest = JSON.parse(readFileSync9(manifestPath, "utf-8"));
1395
1647
  const assets = manifest.assets || [];
1396
- const s = spinner("Uploading build...");
1648
+ const s = spinner("Preparing assets...");
1397
1649
  const assetPayloads = [];
1398
- for (const asset of assets) {
1399
- const fullPath = join10(outDir, asset.path);
1400
- 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)) {
1401
1655
  s.stop();
1402
1656
  throw new CLIError(
1403
1657
  "BUILD_NOT_FOUND",
@@ -1405,44 +1659,37 @@ async function publishCommand() {
1405
1659
  "Run 'appfunnel build' to regenerate."
1406
1660
  );
1407
1661
  }
1662
+ const content = readFileSync9(fullPath);
1663
+ totalBytes += content.length;
1408
1664
  assetPayloads.push({
1409
1665
  path: asset.path,
1410
- content: readFileSync7(fullPath),
1666
+ content,
1411
1667
  contentType: getMimeType(asset.path)
1412
1668
  });
1669
+ s.text = `Preparing assets... ${i + 1}/${assets.length} ${pc8.dim(`(${formatSize2(totalBytes)})`)}`;
1413
1670
  }
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
- }
1671
+ s.text = `Uploading ${assets.length} assets ${pc8.dim(`(${formatSize2(totalBytes)})`)}`;
1432
1672
  const result = await publishBuild(
1433
1673
  projectId,
1434
- funnelId,
1674
+ config.funnelId || "",
1435
1675
  manifest,
1436
1676
  assetPayloads,
1437
1677
  { token: creds.token }
1438
1678
  );
1439
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
+ }
1440
1684
  console.log();
1441
1685
  success("Published successfully");
1442
1686
  console.log();
1443
1687
  console.log(` ${pc8.dim("Build ID:")} ${result.buildId}`);
1688
+ if (result.funnelId && !config.funnelId) {
1689
+ console.log(` ${pc8.dim("Funnel:")} ${result.funnelId}`);
1690
+ }
1444
1691
  console.log(` ${pc8.dim("URL:")} ${pc8.cyan(result.url)}`);
1445
- console.log(` ${pc8.dim("Assets:")} ${assets.length} files`);
1692
+ console.log(` ${pc8.dim("Assets:")} ${assets.length} files ${pc8.dim(`(${formatSize2(totalBytes)})`)}`);
1446
1693
  console.log();
1447
1694
  }
1448
1695
  var MIME_TYPES;
@@ -1455,6 +1702,7 @@ var init_publish = __esm({
1455
1702
  init_version();
1456
1703
  init_api();
1457
1704
  init_errors();
1705
+ init_config_patch();
1458
1706
  MIME_TYPES = {
1459
1707
  ".js": "application/javascript",
1460
1708
  ".css": "text/css",
@@ -1474,7 +1722,7 @@ init_errors();
1474
1722
  import { Command } from "commander";
1475
1723
  import pc9 from "picocolors";
1476
1724
  var program = new Command();
1477
- program.name("appfunnel").description("Build and publish headless AppFunnel projects").version("0.5.0");
1725
+ program.name("appfunnel").description("Build and publish headless AppFunnel projects").version("0.6.0");
1478
1726
  program.command("init <name>").description("Create a new AppFunnel project").action(async (name) => {
1479
1727
  const { initCommand: initCommand2 } = await Promise.resolve().then(() => (init_init(), init_exports));
1480
1728
  await initCommand2(name);