@olonjs/cli 3.0.151 → 3.0.152

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.
@@ -1817,9 +1817,9 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1817
1817
  "@tiptap/extension-link": "^2.11.5",
1818
1818
  "@tiptap/react": "^2.11.5",
1819
1819
  "@tiptap/starter-kit": "^2.11.5",
1820
- "@olonjs/core": "^1.1.18",
1821
- "@olonjs/react": "^0.1.1",
1822
- "@olonjs/studio": "^0.1.1",
1820
+ "@olonjs/core": "^1.1.19",
1821
+ "@olonjs/react": "^0.1.2",
1822
+ "@olonjs/studio": "^0.1.2",
1823
1823
  "class-variance-authority": "^0.7.1",
1824
1824
  "clsx": "^2.1.1",
1825
1825
  "lucide-react": "^0.474.0",
@@ -2952,7 +2952,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/App.tsx"
2952
2952
  * Bootstrap, persistence, and engine wiring — logic lives in lib/hooks.
2953
2953
  */
2954
2954
  import { useEffect, useMemo } from 'react';
2955
- import { JsonPagesEngine, OlonFormsContext } from '@olonjs/react';
2955
+ import { JsonPagesEngine, OlonFormsContext, createHotSaveHandler } from '@olonjs/react';
2956
2956
  import type { JsonPagesConfig, ProjectState } from '@olonjs/core';
2957
2957
  import { withBasePath } from '@olonjs/core';
2958
2958
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
@@ -2985,7 +2985,7 @@ import {
2985
2985
  useInjectedTenantCss,
2986
2986
  useTenantFontsReady,
2987
2987
  } from '@/lib/tenantCss';
2988
- import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL, TENANT_ID } from '@/lib/tenantEnv';
2988
+ import { APP_BASE_PATH, TENANT_ID, cloudPolicy } from '@/lib/tenantEnv';
2989
2989
  import { useAssetsManifest } from '@/lib/useAssetsManifest';
2990
2990
  import { useCloudSave } from '@/lib/useCloudSave';
2991
2991
  import { useTenantBootstrap } from '@/lib/useTenantBootstrap';
@@ -3010,16 +3010,40 @@ function App() {
3010
3010
  themeConfigSeed,
3011
3011
  });
3012
3012
  useAdminStudioContent({
3013
- enabled: bootstrap.isHotSaveMode,
3013
+ enabled: bootstrap.bootSource === 'live',
3014
3014
  apiCandidates: bootstrap.cloudApiCandidates,
3015
- apiKey: CLOUD_API_KEY,
3015
+ apiKey: cloudPolicy.apiKey,
3016
3016
  setPages: bootstrap.setPages,
3017
3017
  setSiteConfig: bootstrap.setSiteConfig,
3018
- setCollections: bootstrap.setCollections,
3018
+ onSettled: bootstrap.markCloudContentReady,
3019
3019
  });
3020
- const { assetsManifest, loadAssetsManifest, cloudApiCandidates } = useAssetsManifest(bootstrap.isCloudMode);
3020
+ const { assetsManifest, loadAssetsManifest, cloudApiCandidates } = useAssetsManifest(cloudPolicy.isCloudMode);
3021
3021
  const { cloudSaveUi, runCloudSave, closeCloudDrawer, retryCloudSave } = useCloudSave();
3022
3022
 
3023
+ const hotSave = useMemo(
3024
+ () =>
3025
+ createHotSaveHandler({
3026
+ apiUrl: cloudPolicy.apiUrl,
3027
+ apiKey: cloudPolicy.apiKey,
3028
+ onSuccess: ({ state, slug, apiUrl, apiKey }) => {
3029
+ const keyFingerprint = cloudFingerprintFromUrl(apiUrl, apiKey);
3030
+ const normalizedSlug = normalizeSlugForCache(slug);
3031
+ const existing = readCachedCloudContent(keyFingerprint);
3032
+ writeCachedCloudContent({
3033
+ keyFingerprint,
3034
+ savedAt: Date.now(),
3035
+ siteConfig: state.site ?? null,
3036
+ collections: state.collections,
3037
+ pages: {
3038
+ ...(existing?.pages ?? {}),
3039
+ [normalizedSlug]: state.page,
3040
+ },
3041
+ });
3042
+ },
3043
+ }),
3044
+ [],
3045
+ );
3046
+
3023
3047
  const tenantCssParts = useMemo(() => extractLeadingRemoteCssImports(tenantCss), []);
3024
3048
  const resolvedTenantCss = useMemo(
3025
3049
  () => [buildThemeFontVarsCss(bootstrap.themeConfig), tenantCssParts.rest].filter(Boolean).join('\n'),
@@ -3058,11 +3082,10 @@ function App() {
3058
3082
  }, [canPaintVisitor, bootstrap.enginePages, bootstrap.siteConfig]);
3059
3083
 
3060
3084
  const engineCollections =
3061
- bootstrap.isHotSaveMode || Object.keys(bootstrap.collections).length > 0
3085
+ bootstrap.bootSource === 'live' || Object.keys(bootstrap.collections).length > 0
3062
3086
  ? bootstrap.collections
3063
3087
  : fileCollections;
3064
3088
 
3065
- // TOASK: why this if we have a menuConfig Object?
3066
3089
  const engineRefDocuments = useMemo(
3067
3090
  () => ({
3068
3091
  'menu.json': bootstrap.menuConfig,
@@ -3071,7 +3094,6 @@ function App() {
3071
3094
  }),
3072
3095
  [bootstrap.menuConfig],
3073
3096
  );
3074
- // TOASK END
3075
3097
  const config: JsonPagesConfig = {
3076
3098
  tenantId: TENANT_ID,
3077
3099
  basePath: APP_BASE_PATH,
@@ -3110,48 +3132,13 @@ function App() {
3110
3132
  setCollections: bootstrap.setCollections,
3111
3133
  });
3112
3134
  },
3113
- async hotSave(state: ProjectState, slug: string): Promise<void> {
3114
- if (!bootstrap.isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
3115
- throw new Error('Cloud mode is not configured for hot save.');
3116
- }
3117
- const apiBase = CLOUD_API_URL.replace(/\/$/, '');
3118
- const res = await fetch(`${apiBase}/hotSave`, {
3119
- method: 'POST',
3120
- headers: {
3121
- 'Content-Type': 'application/json',
3122
- Authorization: `Bearer ${CLOUD_API_KEY}`,
3123
- },
3124
- body: JSON.stringify({
3125
- slug,
3126
- page: state.page,
3127
- siteConfig: state.site,
3128
- collections: state.collections,
3129
- }),
3130
- });
3131
- const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
3132
- if (!res.ok) {
3133
- throw new Error(body.error || body.code || `Hot save failed: ${res.status}`);
3134
- }
3135
- const keyFingerprint = cloudFingerprintFromUrl(CLOUD_API_URL, CLOUD_API_KEY);
3136
- const normalizedSlug = normalizeSlugForCache(slug);
3137
- const existing = readCachedCloudContent(keyFingerprint);
3138
- writeCachedCloudContent({
3139
- keyFingerprint,
3140
- savedAt: Date.now(),
3141
- siteConfig: state.site ?? null,
3142
- collections: state.collections,
3143
- pages: {
3144
- ...(existing?.pages ?? {}),
3145
- [normalizedSlug]: state.page,
3146
- },
3147
- });
3148
- },
3135
+ hotSave,
3149
3136
  async coldSave(state: ProjectState, slug: string): Promise<void> {
3150
3137
  await runCloudSave({ state, slug }, true);
3151
3138
  },
3152
- showLocalSave: !bootstrap.isCloudMode,
3153
- showHotSave: bootstrap.isHotSaveMode,
3154
- showColdSave: bootstrap.isSave2RepoMode,
3139
+ showLocalSave: cloudPolicy.showLocalSave,
3140
+ showHotSave: cloudPolicy.showHotSave,
3141
+ showColdSave: cloudPolicy.showColdSave,
3155
3142
  },
3156
3143
  assets: {
3157
3144
  assetsBaseUrl: withBasePath('/assets', APP_BASE_PATH),
@@ -3159,9 +3146,9 @@ function App() {
3159
3146
  onAssetUpload: (file) =>
3160
3147
  uploadTenantAsset(file, {
3161
3148
  basePath: APP_BASE_PATH,
3162
- isCloudMode: bootstrap.isCloudMode,
3163
- cloudApiUrl: CLOUD_API_URL,
3164
- cloudApiKey: CLOUD_API_KEY,
3149
+ isCloudMode: cloudPolicy.isCloudMode,
3150
+ cloudApiUrl: cloudPolicy.apiUrl,
3151
+ cloudApiKey: cloudPolicy.apiKey,
3165
3152
  apiBases: cloudApiCandidates,
3166
3153
  onUploaded: loadAssetsManifest,
3167
3154
  }),
@@ -3172,7 +3159,7 @@ function App() {
3172
3159
  <ThemeProvider>
3173
3160
  <OlonFormsContext.Provider value={formStates}>
3174
3161
  <TenantBootstrapChrome
3175
- isCloudMode={bootstrap.isCloudMode}
3162
+ isCloudMode={cloudPolicy.isCloudMode}
3176
3163
  showTopProgress={bootstrap.showTopProgress || (bootstrap.shouldRenderEngine && !fontsReady)}
3177
3164
  contentMode={bootstrap.contentMode}
3178
3165
  contentFallback={bootstrap.contentFallback}
@@ -4089,15 +4076,15 @@ echo "Creating src/components/form-demo/View.tsx..."
4089
4076
  cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/View.tsx"
4090
4077
  import { Icon } from '@/lib/IconResolver';
4091
4078
  import { useFormState } from '@olonjs/react';
4079
+ import { cloudPolicy } from '@/lib/tenantEnv';
4092
4080
  import type { FormDemoData } from './types';
4093
4081
 
4094
4082
  type FormDemoViewProps = {
4095
4083
  data: FormDemoData;
4096
4084
  };
4097
4085
 
4098
- const missingEnv =
4099
- !import.meta.env.VITE_JSONPAGES_CLOUD_URL &&
4100
- !import.meta.env.VITE_OLONJS_CLOUD_URL;
4086
+ const missingCloudUrl = !cloudPolicy.apiUrl;
4087
+ const hasCloudKey = Boolean(cloudPolicy.apiKey);
4101
4088
 
4102
4089
  function SetupGuide({ recipientEmail }: { recipientEmail?: string }) {
4103
4090
  const steps = [
@@ -4107,14 +4094,14 @@ function SetupGuide({ recipientEmail }: { recipientEmail?: string }) {
4107
4094
  code: '"recipientEmail": "tu@esempio.it"',
4108
4095
  },
4109
4096
  {
4110
- done: !missingEnv,
4111
- label: 'VITE_JSONPAGES_CLOUD_URL nel file .env',
4112
- code: 'VITE_JSONPAGES_CLOUD_URL=https://cloud.olonjs.io',
4097
+ done: !missingCloudUrl,
4098
+ label: 'VITE_OLONJS_CLOUD_URL (o VITE_JSONPAGES_CLOUD_URL) nel file .env',
4099
+ code: 'VITE_OLONJS_CLOUD_URL=https://cloud.olonjs.io/api/v1',
4113
4100
  },
4114
4101
  {
4115
- done: !!import.meta.env.VITE_JSONPAGES_API_KEY || !!import.meta.env.VITE_OLONJS_API_KEY,
4116
- label: 'VITE_JSONPAGES_API_KEY nel file .env',
4117
- code: 'VITE_JSONPAGES_API_KEY=sk-...',
4102
+ done: hasCloudKey,
4103
+ label: 'VITE_OLONJS_API_KEY (o VITE_JSONPAGES_API_KEY) nel file .env',
4104
+ code: 'VITE_OLONJS_API_KEY=sk-...',
4118
4105
  },
4119
4106
  ];
4120
4107
 
@@ -11610,9 +11597,8 @@ echo "Creating src/lib/cloud/useAdminStudioContent.ts..."
11610
11597
  cat << 'END_OF_FILE_CONTENT' > "src/lib/cloud/useAdminStudioContent.ts"
11611
11598
  import { useEffect, useRef } from 'react';
11612
11599
  import type { Dispatch, SetStateAction } from 'react';
11613
- import type { JsonPagesConfig } from '@olonjs/core';
11614
11600
  import { applyLegacyCloudPayload, fetchLegacyCloudContentPayload } from '@/lib/cloud/cloudContentClient';
11615
- import { cloudFingerprint, writeCachedCloudContent } from '@/lib/cloud/cloudCache';
11601
+ import { cloudFingerprint, readCachedPages, writeCachedCloudContent } from '@/lib/cloud/cloudCache';
11616
11602
  import { isAdminPath, patchHistoryNavigation } from '@/lib/spp';
11617
11603
  import { APP_BASE_PATH } from '@/lib/tenantEnv';
11618
11604
  import type { PageConfig, SiteConfig } from '@/types';
@@ -11625,7 +11611,8 @@ type UseAdminStudioContentOptions = {
11625
11611
  apiKey: string;
11626
11612
  setPages: Dispatch<SetStateAction<Record<string, PageConfig>>>;
11627
11613
  setSiteConfig: Dispatch<SetStateAction<SiteConfig>>;
11628
- setCollections: Dispatch<SetStateAction<NonNullable<JsonPagesConfig['collections']>>>;
11614
+ /** Fired when /admin may paint — after cache hit and/or network attempt settles. */
11615
+ onSettled?: () => void;
11629
11616
  };
11630
11617
 
11631
11618
  /** Studio `/admin` sync via legacy `/content` — never mixed into visitor `/render` bootstrap. */
@@ -11635,13 +11622,28 @@ export function useAdminStudioContent({
11635
11622
  apiKey,
11636
11623
  setPages,
11637
11624
  setSiteConfig,
11638
- setCollections,
11625
+ onSettled,
11639
11626
  }: UseAdminStudioContentOptions) {
11640
11627
  const loadedRef = useRef(false);
11641
11628
  const inFlightRef = useRef<Promise<void> | null>(null);
11629
+ const settledRef = useRef(false);
11642
11630
 
11643
11631
  useEffect(() => {
11644
- if (!enabled || apiCandidates.length === 0 || !apiKey.trim()) return;
11632
+ settledRef.current = false;
11633
+ loadedRef.current = false;
11634
+
11635
+ if (!enabled) return;
11636
+
11637
+ const settle = () => {
11638
+ if (settledRef.current) return;
11639
+ settledRef.current = true;
11640
+ onSettled?.();
11641
+ };
11642
+
11643
+ if (apiCandidates.length === 0 || !apiKey.trim()) {
11644
+ settle();
11645
+ return;
11646
+ }
11645
11647
 
11646
11648
  const syncIfAdmin = () => {
11647
11649
  if (!isAdminPath(window.location.pathname, APP_BASE_PATH)) return;
@@ -11650,6 +11652,14 @@ export function useAdminStudioContent({
11650
11652
  const controller = new AbortController();
11651
11653
  const fingerprint = cloudFingerprint(apiCandidates[0]!, apiKey);
11652
11654
 
11655
+ // Paint from cache immediately when present — then refresh from network.
11656
+ const { cachedPages, cachedSite } = readCachedPages(fingerprint);
11657
+ if (cachedPages && Object.keys(cachedPages).length > 0) {
11658
+ setPages(cachedPages);
11659
+ if (cachedSite) setSiteConfig(cachedSite);
11660
+ settle();
11661
+ }
11662
+
11653
11663
  inFlightRef.current = fetchLegacyCloudContentPayload(
11654
11664
  apiCandidates,
11655
11665
  apiKey,
@@ -11676,6 +11686,7 @@ export function useAdminStudioContent({
11676
11686
  })
11677
11687
  .finally(() => {
11678
11688
  inFlightRef.current = null;
11689
+ settle();
11679
11690
  });
11680
11691
  };
11681
11692
 
@@ -11685,7 +11696,7 @@ export function useAdminStudioContent({
11685
11696
  unpatch();
11686
11697
  inFlightRef.current = null;
11687
11698
  };
11688
- }, [enabled, apiCandidates, apiKey, setPages, setSiteConfig, setCollections]);
11699
+ }, [enabled, apiCandidates, apiKey, setPages, setSiteConfig, onSettled]);
11689
11700
  }
11690
11701
 
11691
11702
  END_OF_FILE_CONTENT
@@ -11893,40 +11904,31 @@ END_OF_FILE_CONTENT
11893
11904
  mkdir -p "src/lib/spp"
11894
11905
  echo "Creating src/lib/spp/cloudConfig.ts..."
11895
11906
  cat << 'END_OF_FILE_CONTENT' > "src/lib/spp/cloudConfig.ts"
11896
- function normalizeApiBase(raw: string): string {
11897
- return raw.trim().replace(/\/+$/, '');
11898
- }
11907
+ import { buildApiCandidates } from '@olonjs/react';
11899
11908
 
11900
- export function buildApiCandidates(raw: string): string[] {
11901
- const base = normalizeApiBase(raw);
11902
- const withApi = /\/api\/v1$/i.test(base) ? base : `${base}/api/v1`;
11903
- return Array.from(new Set([withApi, base].filter(Boolean)));
11904
- }
11909
+ import { cloudPolicy } from '@/lib/tenantEnv';
11910
+
11911
+ export { buildApiCandidates };
11905
11912
 
11913
+ /**
11914
+ * Browser-runtime SPP cloud slices.
11915
+ * Credentials ⇒ cloud usable. Save2Repo does not mean “cloud off”
11916
+ * (boot may still be static; live slices remain available).
11917
+ * SSG/bake: local JSON only (SPP §3).
11918
+ */
11906
11919
  export function getSppCloudConfig(): {
11907
11920
  enabled: boolean;
11908
11921
  apiBases: string[];
11909
11922
  apiKey: string;
11910
11923
  } {
11911
- const apiUrl =
11912
- import.meta.env.VITE_OLONJS_CLOUD_URL?.trim() ||
11913
- import.meta.env.VITE_JSONPAGES_CLOUD_URL?.trim() ||
11914
- '';
11915
- const apiKey =
11916
- import.meta.env.VITE_OLONJS_API_KEY?.trim() ||
11917
- import.meta.env.VITE_JSONPAGES_API_KEY?.trim() ||
11918
- '';
11919
- const save2Repo = import.meta.env.VITE_SAVE2REPO === 'true';
11920
-
11921
- // SSG/bake: local resolved JSON only. Cloud slices are browser-runtime (SPP §3).
11922
- if (import.meta.env.SSR || !apiUrl || !apiKey || save2Repo) {
11924
+ if (import.meta.env.SSR || !cloudPolicy.isCloudMode) {
11923
11925
  return { enabled: false, apiBases: [], apiKey: '' };
11924
11926
  }
11925
11927
 
11926
11928
  return {
11927
11929
  enabled: true,
11928
- apiBases: buildApiCandidates(apiUrl),
11929
- apiKey,
11930
+ apiBases: buildApiCandidates(cloudPolicy.apiUrl),
11931
+ apiKey: cloudPolicy.apiKey,
11930
11932
  };
11931
11933
  }
11932
11934
 
@@ -13092,12 +13094,16 @@ END_OF_FILE_CONTENT
13092
13094
  echo "Creating src/lib/tenantEnv.ts..."
13093
13095
  cat << 'END_OF_FILE_CONTENT' > "src/lib/tenantEnv.ts"
13094
13096
  import { normalizeBasePath } from '@olonjs/core';
13097
+ import { readCloudEnvFromVite, resolveCloudPolicy, type CloudPolicy } from '@olonjs/react';
13095
13098
 
13096
- export const CLOUD_API_URL =
13097
- import.meta.env.VITE_OLONJS_CLOUD_URL ?? import.meta.env.VITE_JSONPAGES_CLOUD_URL;
13098
- export const CLOUD_API_KEY =
13099
- import.meta.env.VITE_OLONJS_API_KEY ?? import.meta.env.VITE_JSONPAGES_API_KEY;
13100
- export const SAVE2REPO_ENABLED = import.meta.env.VITE_SAVE2REPO === 'true';
13099
+ /** Single Vite → policy path. Prefer `cloudPolicy` over ad-hoc env reads. */
13100
+ export const cloudPolicy: CloudPolicy = resolveCloudPolicy(
13101
+ readCloudEnvFromVite(import.meta.env as Record<string, unknown>),
13102
+ );
13103
+
13104
+ /** Aliases of `cloudPolicy.apiUrl` / `apiKey` for DNA helpers. */
13105
+ export const CLOUD_API_URL = cloudPolicy.apiUrl;
13106
+ export const CLOUD_API_KEY = cloudPolicy.apiKey;
13101
13107
  export const APP_BASE_PATH = normalizeBasePath(import.meta.env.BASE_URL || '/');
13102
13108
  export const TENANT_ID = 'alpha';
13103
13109
 
@@ -13106,28 +13112,33 @@ echo "Creating src/lib/useAssetsManifest.ts..."
13106
13112
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useAssetsManifest.ts"
13107
13113
  import { useCallback, useEffect, useMemo, useState } from 'react';
13108
13114
  import type { LibraryImageEntry } from '@olonjs/core';
13109
- import { buildApiCandidates } from '@/lib/sppCloudConfig';
13110
- import { CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13115
+ import { buildApiCandidates } from '@olonjs/react';
13116
+
13117
+ import { cloudPolicy } from '@/lib/tenantEnv';
13111
13118
 
13112
13119
  function normalizeApiBase(raw: string): string {
13113
13120
  return raw.trim().replace(/\/+$/, '');
13114
13121
  }
13115
13122
 
13116
- export function useAssetsManifest(isCloudMode: boolean) {
13123
+ /** Asset library — cloud list when `cloudPolicy.isCloudMode`, else local `/api/list-assets`. */
13124
+ export function useAssetsManifest(isCloudMode: boolean = cloudPolicy.isCloudMode) {
13117
13125
  const [assetsManifest, setAssetsManifest] = useState<LibraryImageEntry[]>([]);
13118
13126
  const cloudApiCandidates = useMemo(
13119
- () => (isCloudMode && CLOUD_API_URL ? buildApiCandidates(CLOUD_API_URL) : []),
13127
+ () => (isCloudMode && cloudPolicy.apiUrl ? buildApiCandidates(cloudPolicy.apiUrl) : []),
13120
13128
  [isCloudMode],
13121
13129
  );
13122
13130
 
13123
13131
  const loadAssetsManifest = useCallback(async (): Promise<void> => {
13124
- if (isCloudMode && CLOUD_API_URL && CLOUD_API_KEY) {
13125
- const apiBases = cloudApiCandidates.length > 0 ? cloudApiCandidates : [normalizeApiBase(CLOUD_API_URL)];
13132
+ if (isCloudMode && cloudPolicy.apiUrl && cloudPolicy.apiKey) {
13133
+ const apiBases =
13134
+ cloudApiCandidates.length > 0
13135
+ ? cloudApiCandidates
13136
+ : [normalizeApiBase(cloudPolicy.apiUrl)];
13126
13137
  for (const apiBase of apiBases) {
13127
13138
  try {
13128
13139
  const res = await fetch(`${apiBase}/assets/list?limit=200`, {
13129
13140
  method: 'GET',
13130
- headers: { Authorization: `Bearer ${CLOUD_API_KEY}` },
13141
+ headers: { Authorization: `Bearer ${cloudPolicy.apiKey}` },
13131
13142
  });
13132
13143
  const body = (await res.json().catch(() => ({}))) as { items?: LibraryImageEntry[] };
13133
13144
  if (!res.ok) continue;
@@ -13401,6 +13412,8 @@ echo "Creating src/lib/useFormSubmit.ts..."
13401
13412
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useFormSubmit.ts"
13402
13413
  import { useState, useCallback } from 'react';
13403
13414
 
13415
+ import { cloudPolicy } from '@/lib/tenantEnv';
13416
+
13404
13417
  export type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error';
13405
13418
 
13406
13419
  interface UseFormSubmitOptions {
@@ -13413,22 +13426,20 @@ export function useFormSubmit({ source, tenantId }: UseFormSubmitOptions) {
13413
13426
  const [message, setMessage] = useState<string>('');
13414
13427
 
13415
13428
  const submit = useCallback(async (
13416
- formData: FormData,
13417
- recipientEmail: string,
13418
- pageSlug: string,
13429
+ formData: FormData,
13430
+ recipientEmail: string,
13431
+ pageSlug: string,
13419
13432
  sectionId: string
13420
13433
  ) => {
13421
- const cloudApiUrl = import.meta.env.VITE_JSONPAGES_CLOUD_URL as string | undefined;
13422
- const cloudApiKey = import.meta.env.VITE_JSONPAGES_API_KEY as string | undefined;
13434
+ const { apiUrl, apiKey } = cloudPolicy;
13423
13435
 
13424
- if (!cloudApiUrl || !cloudApiKey) {
13436
+ if (!apiUrl || !apiKey) {
13425
13437
  setStatus('error');
13426
13438
  setMessage('Configurazione API non disponibile. Riprova tra poco.');
13427
13439
  return false;
13428
13440
  }
13429
13441
 
13430
- // Trasformiamo FormData in un oggetto piatto per il payload JSON
13431
- const data: Record<string, any> = {};
13442
+ const data: Record<string, string> = {};
13432
13443
  formData.forEach((value, key) => {
13433
13444
  data[key] = String(value).trim();
13434
13445
  });
@@ -13443,18 +13454,17 @@ export function useFormSubmit({ source, tenantId }: UseFormSubmitOptions) {
13443
13454
  submittedAt: new Date().toISOString(),
13444
13455
  };
13445
13456
 
13446
- // Idempotency Key per evitare doppi invii accidentali
13447
13457
  const idempotencyKey = `form-${sectionId}-${Date.now()}`;
13448
13458
 
13449
13459
  setStatus('submitting');
13450
13460
  setMessage('Invio in corso...');
13451
13461
 
13452
13462
  try {
13453
- const apiBase = cloudApiUrl.replace(/\/$/, '');
13463
+ const apiBase = apiUrl.replace(/\/$/, '');
13454
13464
  const response = await fetch(`${apiBase}/forms/submit`, {
13455
13465
  method: 'POST',
13456
13466
  headers: {
13457
- Authorization: `Bearer ${cloudApiKey}`,
13467
+ Authorization: `Bearer ${apiKey}`,
13458
13468
  'Content-Type': 'application/json',
13459
13469
  'Idempotency-Key': idempotencyKey,
13460
13470
  },
@@ -13484,23 +13494,18 @@ export function useFormSubmit({ source, tenantId }: UseFormSubmitOptions) {
13484
13494
  }, []);
13485
13495
 
13486
13496
  return { submit, status, message, reset };
13487
- }
13497
+ }
13498
+
13488
13499
  END_OF_FILE_CONTENT
13489
13500
  echo "Creating src/lib/useOlonForms.ts..."
13490
13501
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useOlonForms.ts"
13491
13502
  import { useCallback, useEffect, useState } from 'react';
13492
13503
  import type { FormState } from '@olonjs/react';
13493
13504
 
13494
- const API_BASE =
13495
- (import.meta.env.VITE_OLONJS_CLOUD_URL as string | undefined) ??
13496
- (import.meta.env.VITE_JSONPAGES_CLOUD_URL as string | undefined);
13497
-
13498
- const API_KEY =
13499
- (import.meta.env.VITE_OLONJS_API_KEY as string | undefined) ??
13500
- (import.meta.env.VITE_JSONPAGES_API_KEY as string | undefined);
13505
+ import { cloudPolicy } from '@/lib/tenantEnv';
13501
13506
 
13502
13507
  interface UseOlonFormsOptions {
13503
- /** Override the submit endpoint. Defaults to VITE_OLONJS_CLOUD_URL/forms/submit */
13508
+ /** Override the submit endpoint. Defaults to cloudPolicy.apiUrl/forms/submit */
13504
13509
  endpoint?: string;
13505
13510
  }
13506
13511
 
@@ -13519,13 +13524,16 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
13519
13524
  }, []);
13520
13525
 
13521
13526
  useEffect(() => {
13527
+ const apiUrl = cloudPolicy.apiUrl;
13528
+ const apiKey = cloudPolicy.apiKey;
13529
+
13522
13530
  const resolvedBase = options?.endpoint
13523
13531
  ? options.endpoint.replace(/\/$/, '')
13524
- : API_BASE
13525
- ? API_BASE.replace(/\/$/, '')
13532
+ : apiUrl
13533
+ ? apiUrl.replace(/\/$/, '')
13526
13534
  : null;
13527
13535
 
13528
- if (!resolvedBase || !API_KEY) {
13536
+ if (!resolvedBase || !apiKey) {
13529
13537
  console.warn('[useOlonForms] Missing API endpoint or key — forms will not submit.');
13530
13538
  return;
13531
13539
  }
@@ -13568,7 +13576,7 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
13568
13576
  const response = await fetch(endpoint, {
13569
13577
  method: 'POST',
13570
13578
  headers: {
13571
- Authorization: `Bearer ${API_KEY}`,
13579
+ Authorization: `Bearer ${apiKey}`,
13572
13580
  'Content-Type': 'application/json',
13573
13581
  'Idempotency-Key': `form-${formId}-${Date.now()}`,
13574
13582
  },
@@ -13619,7 +13627,7 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
13619
13627
  END_OF_FILE_CONTENT
13620
13628
  echo "Creating src/lib/useTenantBootstrap.ts..."
13621
13629
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useTenantBootstrap.ts"
13622
- import { useEffect, useMemo, useRef, useState } from 'react';
13630
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
13623
13631
 
13624
13632
  import { logBootstrapEvent, toCloudLoadFailure } from '@/lib/cloud/bootstrapTelemetry';
13625
13633
 
@@ -13649,7 +13657,7 @@ import {
13649
13657
 
13650
13658
  import type { JsonPagesConfig } from '@olonjs/core';
13651
13659
 
13652
- import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL, SAVE2REPO_ENABLED } from '@/lib/tenantEnv';
13660
+ import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL, cloudPolicy } from '@/lib/tenantEnv';
13653
13661
 
13654
13662
  import type { MenuConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
13655
13663
 
@@ -13731,13 +13739,10 @@ export function useTenantBootstrap({
13731
13739
 
13732
13740
  }: UseTenantBootstrapOptions) {
13733
13741
 
13734
- const isCloudMode = Boolean(CLOUD_API_URL && CLOUD_API_KEY);
13735
-
13736
- const isSave2RepoMode = isCloudMode && SAVE2REPO_ENABLED;
13742
+ const { isCloudMode, bootSource } = cloudPolicy;
13737
13743
 
13738
- const isHotSaveMode = isCloudMode && !isSave2RepoMode;
13739
-
13740
- const useRenderBootstrap = isHotSaveMode;
13744
+ /** Live SPP render boot — only when bootSource is live (not static Save2Repo). */
13745
+ const useRenderBootstrap = bootSource === 'live';
13741
13746
 
13742
13747
 
13743
13748
 
@@ -13801,7 +13806,11 @@ export function useTenantBootstrap({
13801
13806
 
13802
13807
  const isTenantEmpty = Object.keys(pages).length === 0;
13803
13808
 
13804
-
13809
+ /** Call when live /admin content sync has finished (success, cache, or error). */
13810
+ const markCloudContentReady = useCallback(() => {
13811
+ setShowTopProgress(false);
13812
+ setHasInitialCloudResolved(true);
13813
+ }, []);
13805
13814
 
13806
13815
  const retryBootstrap = () => {
13807
13816
 
@@ -13841,7 +13850,7 @@ export function useTenantBootstrap({
13841
13850
 
13842
13851
 
13843
13852
 
13844
- if (isSave2RepoMode) {
13853
+ if (bootSource === 'static') {
13845
13854
 
13846
13855
  if (contentLoadInFlight.current) return;
13847
13856
 
@@ -13938,17 +13947,12 @@ export function useTenantBootstrap({
13938
13947
 
13939
13948
 
13940
13949
  if (isAdminPath(window.location.pathname, APP_BASE_PATH)) {
13941
-
13950
+ // Live /admin: content comes from useAdminStudioContent — do not paint empty first.
13942
13951
  setContentMode('cloud');
13943
-
13944
13952
  setContentFallback(null);
13945
-
13946
- setShowTopProgress(false);
13947
-
13948
- setHasInitialCloudResolved(true);
13949
-
13953
+ setShowTopProgress(true);
13954
+ setHasInitialCloudResolved(false);
13950
13955
  return;
13951
-
13952
13956
  }
13953
13957
 
13954
13958
 
@@ -14245,7 +14249,7 @@ export function useTenantBootstrap({
14245
14249
 
14246
14250
  isCloudMode,
14247
14251
 
14248
- isSave2RepoMode,
14252
+ bootSource,
14249
14253
 
14250
14254
  useRenderBootstrap,
14251
14255
 
@@ -14291,9 +14295,7 @@ export function useTenantBootstrap({
14291
14295
 
14292
14296
  isCloudMode,
14293
14297
 
14294
- isSave2RepoMode,
14295
-
14296
- isHotSaveMode,
14298
+ bootSource,
14297
14299
 
14298
14300
  contentMode,
14299
14301
 
@@ -14307,6 +14309,8 @@ export function useTenantBootstrap({
14307
14309
 
14308
14310
  isTenantEmpty,
14309
14311
 
14312
+ markCloudContentReady,
14313
+
14310
14314
  retryBootstrap,
14311
14315
 
14312
14316
  };
@@ -1817,9 +1817,9 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1817
1817
  "@tiptap/extension-link": "^2.11.5",
1818
1818
  "@tiptap/react": "^2.11.5",
1819
1819
  "@tiptap/starter-kit": "^2.11.5",
1820
- "@olonjs/core": "^1.1.18",
1821
- "@olonjs/react": "^0.1.1",
1822
- "@olonjs/studio": "^0.1.1",
1820
+ "@olonjs/core": "^1.1.19",
1821
+ "@olonjs/react": "^0.1.2",
1822
+ "@olonjs/studio": "^0.1.2",
1823
1823
  "class-variance-authority": "^0.7.1",
1824
1824
  "clsx": "^2.1.1",
1825
1825
  "lucide-react": "^0.474.0",
@@ -2952,7 +2952,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/App.tsx"
2952
2952
  * Bootstrap, persistence, and engine wiring — logic lives in lib/hooks.
2953
2953
  */
2954
2954
  import { useEffect, useMemo } from 'react';
2955
- import { JsonPagesEngine, OlonFormsContext } from '@olonjs/react';
2955
+ import { JsonPagesEngine, OlonFormsContext, createHotSaveHandler } from '@olonjs/react';
2956
2956
  import type { JsonPagesConfig, ProjectState } from '@olonjs/core';
2957
2957
  import { withBasePath } from '@olonjs/core';
2958
2958
  import { ComponentRegistry } from '@/lib/ComponentRegistry';
@@ -2985,7 +2985,7 @@ import {
2985
2985
  useInjectedTenantCss,
2986
2986
  useTenantFontsReady,
2987
2987
  } from '@/lib/tenantCss';
2988
- import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL, TENANT_ID } from '@/lib/tenantEnv';
2988
+ import { APP_BASE_PATH, TENANT_ID, cloudPolicy } from '@/lib/tenantEnv';
2989
2989
  import { useAssetsManifest } from '@/lib/useAssetsManifest';
2990
2990
  import { useCloudSave } from '@/lib/useCloudSave';
2991
2991
  import { useTenantBootstrap } from '@/lib/useTenantBootstrap';
@@ -3010,16 +3010,40 @@ function App() {
3010
3010
  themeConfigSeed,
3011
3011
  });
3012
3012
  useAdminStudioContent({
3013
- enabled: bootstrap.isHotSaveMode,
3013
+ enabled: bootstrap.bootSource === 'live',
3014
3014
  apiCandidates: bootstrap.cloudApiCandidates,
3015
- apiKey: CLOUD_API_KEY,
3015
+ apiKey: cloudPolicy.apiKey,
3016
3016
  setPages: bootstrap.setPages,
3017
3017
  setSiteConfig: bootstrap.setSiteConfig,
3018
- setCollections: bootstrap.setCollections,
3018
+ onSettled: bootstrap.markCloudContentReady,
3019
3019
  });
3020
- const { assetsManifest, loadAssetsManifest, cloudApiCandidates } = useAssetsManifest(bootstrap.isCloudMode);
3020
+ const { assetsManifest, loadAssetsManifest, cloudApiCandidates } = useAssetsManifest(cloudPolicy.isCloudMode);
3021
3021
  const { cloudSaveUi, runCloudSave, closeCloudDrawer, retryCloudSave } = useCloudSave();
3022
3022
 
3023
+ const hotSave = useMemo(
3024
+ () =>
3025
+ createHotSaveHandler({
3026
+ apiUrl: cloudPolicy.apiUrl,
3027
+ apiKey: cloudPolicy.apiKey,
3028
+ onSuccess: ({ state, slug, apiUrl, apiKey }) => {
3029
+ const keyFingerprint = cloudFingerprintFromUrl(apiUrl, apiKey);
3030
+ const normalizedSlug = normalizeSlugForCache(slug);
3031
+ const existing = readCachedCloudContent(keyFingerprint);
3032
+ writeCachedCloudContent({
3033
+ keyFingerprint,
3034
+ savedAt: Date.now(),
3035
+ siteConfig: state.site ?? null,
3036
+ collections: state.collections,
3037
+ pages: {
3038
+ ...(existing?.pages ?? {}),
3039
+ [normalizedSlug]: state.page,
3040
+ },
3041
+ });
3042
+ },
3043
+ }),
3044
+ [],
3045
+ );
3046
+
3023
3047
  const tenantCssParts = useMemo(() => extractLeadingRemoteCssImports(tenantCss), []);
3024
3048
  const resolvedTenantCss = useMemo(
3025
3049
  () => [buildThemeFontVarsCss(bootstrap.themeConfig), tenantCssParts.rest].filter(Boolean).join('\n'),
@@ -3058,11 +3082,10 @@ function App() {
3058
3082
  }, [canPaintVisitor, bootstrap.enginePages, bootstrap.siteConfig]);
3059
3083
 
3060
3084
  const engineCollections =
3061
- bootstrap.isHotSaveMode || Object.keys(bootstrap.collections).length > 0
3085
+ bootstrap.bootSource === 'live' || Object.keys(bootstrap.collections).length > 0
3062
3086
  ? bootstrap.collections
3063
3087
  : fileCollections;
3064
3088
 
3065
- // TOASK: why this if we have a menuConfig Object?
3066
3089
  const engineRefDocuments = useMemo(
3067
3090
  () => ({
3068
3091
  'menu.json': bootstrap.menuConfig,
@@ -3071,7 +3094,6 @@ function App() {
3071
3094
  }),
3072
3095
  [bootstrap.menuConfig],
3073
3096
  );
3074
- // TOASK END
3075
3097
  const config: JsonPagesConfig = {
3076
3098
  tenantId: TENANT_ID,
3077
3099
  basePath: APP_BASE_PATH,
@@ -3110,48 +3132,13 @@ function App() {
3110
3132
  setCollections: bootstrap.setCollections,
3111
3133
  });
3112
3134
  },
3113
- async hotSave(state: ProjectState, slug: string): Promise<void> {
3114
- if (!bootstrap.isCloudMode || !CLOUD_API_URL || !CLOUD_API_KEY) {
3115
- throw new Error('Cloud mode is not configured for hot save.');
3116
- }
3117
- const apiBase = CLOUD_API_URL.replace(/\/$/, '');
3118
- const res = await fetch(`${apiBase}/hotSave`, {
3119
- method: 'POST',
3120
- headers: {
3121
- 'Content-Type': 'application/json',
3122
- Authorization: `Bearer ${CLOUD_API_KEY}`,
3123
- },
3124
- body: JSON.stringify({
3125
- slug,
3126
- page: state.page,
3127
- siteConfig: state.site,
3128
- collections: state.collections,
3129
- }),
3130
- });
3131
- const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
3132
- if (!res.ok) {
3133
- throw new Error(body.error || body.code || `Hot save failed: ${res.status}`);
3134
- }
3135
- const keyFingerprint = cloudFingerprintFromUrl(CLOUD_API_URL, CLOUD_API_KEY);
3136
- const normalizedSlug = normalizeSlugForCache(slug);
3137
- const existing = readCachedCloudContent(keyFingerprint);
3138
- writeCachedCloudContent({
3139
- keyFingerprint,
3140
- savedAt: Date.now(),
3141
- siteConfig: state.site ?? null,
3142
- collections: state.collections,
3143
- pages: {
3144
- ...(existing?.pages ?? {}),
3145
- [normalizedSlug]: state.page,
3146
- },
3147
- });
3148
- },
3135
+ hotSave,
3149
3136
  async coldSave(state: ProjectState, slug: string): Promise<void> {
3150
3137
  await runCloudSave({ state, slug }, true);
3151
3138
  },
3152
- showLocalSave: !bootstrap.isCloudMode,
3153
- showHotSave: bootstrap.isHotSaveMode,
3154
- showColdSave: bootstrap.isSave2RepoMode,
3139
+ showLocalSave: cloudPolicy.showLocalSave,
3140
+ showHotSave: cloudPolicy.showHotSave,
3141
+ showColdSave: cloudPolicy.showColdSave,
3155
3142
  },
3156
3143
  assets: {
3157
3144
  assetsBaseUrl: withBasePath('/assets', APP_BASE_PATH),
@@ -3159,9 +3146,9 @@ function App() {
3159
3146
  onAssetUpload: (file) =>
3160
3147
  uploadTenantAsset(file, {
3161
3148
  basePath: APP_BASE_PATH,
3162
- isCloudMode: bootstrap.isCloudMode,
3163
- cloudApiUrl: CLOUD_API_URL,
3164
- cloudApiKey: CLOUD_API_KEY,
3149
+ isCloudMode: cloudPolicy.isCloudMode,
3150
+ cloudApiUrl: cloudPolicy.apiUrl,
3151
+ cloudApiKey: cloudPolicy.apiKey,
3165
3152
  apiBases: cloudApiCandidates,
3166
3153
  onUploaded: loadAssetsManifest,
3167
3154
  }),
@@ -3172,7 +3159,7 @@ function App() {
3172
3159
  <ThemeProvider>
3173
3160
  <OlonFormsContext.Provider value={formStates}>
3174
3161
  <TenantBootstrapChrome
3175
- isCloudMode={bootstrap.isCloudMode}
3162
+ isCloudMode={cloudPolicy.isCloudMode}
3176
3163
  showTopProgress={bootstrap.showTopProgress || (bootstrap.shouldRenderEngine && !fontsReady)}
3177
3164
  contentMode={bootstrap.contentMode}
3178
3165
  contentFallback={bootstrap.contentFallback}
@@ -4089,15 +4076,15 @@ echo "Creating src/components/form-demo/View.tsx..."
4089
4076
  cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/View.tsx"
4090
4077
  import { Icon } from '@/lib/IconResolver';
4091
4078
  import { useFormState } from '@olonjs/react';
4079
+ import { cloudPolicy } from '@/lib/tenantEnv';
4092
4080
  import type { FormDemoData } from './types';
4093
4081
 
4094
4082
  type FormDemoViewProps = {
4095
4083
  data: FormDemoData;
4096
4084
  };
4097
4085
 
4098
- const missingEnv =
4099
- !import.meta.env.VITE_JSONPAGES_CLOUD_URL &&
4100
- !import.meta.env.VITE_OLONJS_CLOUD_URL;
4086
+ const missingCloudUrl = !cloudPolicy.apiUrl;
4087
+ const hasCloudKey = Boolean(cloudPolicy.apiKey);
4101
4088
 
4102
4089
  function SetupGuide({ recipientEmail }: { recipientEmail?: string }) {
4103
4090
  const steps = [
@@ -4107,14 +4094,14 @@ function SetupGuide({ recipientEmail }: { recipientEmail?: string }) {
4107
4094
  code: '"recipientEmail": "tu@esempio.it"',
4108
4095
  },
4109
4096
  {
4110
- done: !missingEnv,
4111
- label: 'VITE_JSONPAGES_CLOUD_URL nel file .env',
4112
- code: 'VITE_JSONPAGES_CLOUD_URL=https://cloud.olonjs.io',
4097
+ done: !missingCloudUrl,
4098
+ label: 'VITE_OLONJS_CLOUD_URL (o VITE_JSONPAGES_CLOUD_URL) nel file .env',
4099
+ code: 'VITE_OLONJS_CLOUD_URL=https://cloud.olonjs.io/api/v1',
4113
4100
  },
4114
4101
  {
4115
- done: !!import.meta.env.VITE_JSONPAGES_API_KEY || !!import.meta.env.VITE_OLONJS_API_KEY,
4116
- label: 'VITE_JSONPAGES_API_KEY nel file .env',
4117
- code: 'VITE_JSONPAGES_API_KEY=sk-...',
4102
+ done: hasCloudKey,
4103
+ label: 'VITE_OLONJS_API_KEY (o VITE_JSONPAGES_API_KEY) nel file .env',
4104
+ code: 'VITE_OLONJS_API_KEY=sk-...',
4118
4105
  },
4119
4106
  ];
4120
4107
 
@@ -11610,9 +11597,8 @@ echo "Creating src/lib/cloud/useAdminStudioContent.ts..."
11610
11597
  cat << 'END_OF_FILE_CONTENT' > "src/lib/cloud/useAdminStudioContent.ts"
11611
11598
  import { useEffect, useRef } from 'react';
11612
11599
  import type { Dispatch, SetStateAction } from 'react';
11613
- import type { JsonPagesConfig } from '@olonjs/core';
11614
11600
  import { applyLegacyCloudPayload, fetchLegacyCloudContentPayload } from '@/lib/cloud/cloudContentClient';
11615
- import { cloudFingerprint, writeCachedCloudContent } from '@/lib/cloud/cloudCache';
11601
+ import { cloudFingerprint, readCachedPages, writeCachedCloudContent } from '@/lib/cloud/cloudCache';
11616
11602
  import { isAdminPath, patchHistoryNavigation } from '@/lib/spp';
11617
11603
  import { APP_BASE_PATH } from '@/lib/tenantEnv';
11618
11604
  import type { PageConfig, SiteConfig } from '@/types';
@@ -11625,7 +11611,8 @@ type UseAdminStudioContentOptions = {
11625
11611
  apiKey: string;
11626
11612
  setPages: Dispatch<SetStateAction<Record<string, PageConfig>>>;
11627
11613
  setSiteConfig: Dispatch<SetStateAction<SiteConfig>>;
11628
- setCollections: Dispatch<SetStateAction<NonNullable<JsonPagesConfig['collections']>>>;
11614
+ /** Fired when /admin may paint — after cache hit and/or network attempt settles. */
11615
+ onSettled?: () => void;
11629
11616
  };
11630
11617
 
11631
11618
  /** Studio `/admin` sync via legacy `/content` — never mixed into visitor `/render` bootstrap. */
@@ -11635,13 +11622,28 @@ export function useAdminStudioContent({
11635
11622
  apiKey,
11636
11623
  setPages,
11637
11624
  setSiteConfig,
11638
- setCollections,
11625
+ onSettled,
11639
11626
  }: UseAdminStudioContentOptions) {
11640
11627
  const loadedRef = useRef(false);
11641
11628
  const inFlightRef = useRef<Promise<void> | null>(null);
11629
+ const settledRef = useRef(false);
11642
11630
 
11643
11631
  useEffect(() => {
11644
- if (!enabled || apiCandidates.length === 0 || !apiKey.trim()) return;
11632
+ settledRef.current = false;
11633
+ loadedRef.current = false;
11634
+
11635
+ if (!enabled) return;
11636
+
11637
+ const settle = () => {
11638
+ if (settledRef.current) return;
11639
+ settledRef.current = true;
11640
+ onSettled?.();
11641
+ };
11642
+
11643
+ if (apiCandidates.length === 0 || !apiKey.trim()) {
11644
+ settle();
11645
+ return;
11646
+ }
11645
11647
 
11646
11648
  const syncIfAdmin = () => {
11647
11649
  if (!isAdminPath(window.location.pathname, APP_BASE_PATH)) return;
@@ -11650,6 +11652,14 @@ export function useAdminStudioContent({
11650
11652
  const controller = new AbortController();
11651
11653
  const fingerprint = cloudFingerprint(apiCandidates[0]!, apiKey);
11652
11654
 
11655
+ // Paint from cache immediately when present — then refresh from network.
11656
+ const { cachedPages, cachedSite } = readCachedPages(fingerprint);
11657
+ if (cachedPages && Object.keys(cachedPages).length > 0) {
11658
+ setPages(cachedPages);
11659
+ if (cachedSite) setSiteConfig(cachedSite);
11660
+ settle();
11661
+ }
11662
+
11653
11663
  inFlightRef.current = fetchLegacyCloudContentPayload(
11654
11664
  apiCandidates,
11655
11665
  apiKey,
@@ -11676,6 +11686,7 @@ export function useAdminStudioContent({
11676
11686
  })
11677
11687
  .finally(() => {
11678
11688
  inFlightRef.current = null;
11689
+ settle();
11679
11690
  });
11680
11691
  };
11681
11692
 
@@ -11685,7 +11696,7 @@ export function useAdminStudioContent({
11685
11696
  unpatch();
11686
11697
  inFlightRef.current = null;
11687
11698
  };
11688
- }, [enabled, apiCandidates, apiKey, setPages, setSiteConfig, setCollections]);
11699
+ }, [enabled, apiCandidates, apiKey, setPages, setSiteConfig, onSettled]);
11689
11700
  }
11690
11701
 
11691
11702
  END_OF_FILE_CONTENT
@@ -11893,40 +11904,31 @@ END_OF_FILE_CONTENT
11893
11904
  mkdir -p "src/lib/spp"
11894
11905
  echo "Creating src/lib/spp/cloudConfig.ts..."
11895
11906
  cat << 'END_OF_FILE_CONTENT' > "src/lib/spp/cloudConfig.ts"
11896
- function normalizeApiBase(raw: string): string {
11897
- return raw.trim().replace(/\/+$/, '');
11898
- }
11907
+ import { buildApiCandidates } from '@olonjs/react';
11899
11908
 
11900
- export function buildApiCandidates(raw: string): string[] {
11901
- const base = normalizeApiBase(raw);
11902
- const withApi = /\/api\/v1$/i.test(base) ? base : `${base}/api/v1`;
11903
- return Array.from(new Set([withApi, base].filter(Boolean)));
11904
- }
11909
+ import { cloudPolicy } from '@/lib/tenantEnv';
11910
+
11911
+ export { buildApiCandidates };
11905
11912
 
11913
+ /**
11914
+ * Browser-runtime SPP cloud slices.
11915
+ * Credentials ⇒ cloud usable. Save2Repo does not mean “cloud off”
11916
+ * (boot may still be static; live slices remain available).
11917
+ * SSG/bake: local JSON only (SPP §3).
11918
+ */
11906
11919
  export function getSppCloudConfig(): {
11907
11920
  enabled: boolean;
11908
11921
  apiBases: string[];
11909
11922
  apiKey: string;
11910
11923
  } {
11911
- const apiUrl =
11912
- import.meta.env.VITE_OLONJS_CLOUD_URL?.trim() ||
11913
- import.meta.env.VITE_JSONPAGES_CLOUD_URL?.trim() ||
11914
- '';
11915
- const apiKey =
11916
- import.meta.env.VITE_OLONJS_API_KEY?.trim() ||
11917
- import.meta.env.VITE_JSONPAGES_API_KEY?.trim() ||
11918
- '';
11919
- const save2Repo = import.meta.env.VITE_SAVE2REPO === 'true';
11920
-
11921
- // SSG/bake: local resolved JSON only. Cloud slices are browser-runtime (SPP §3).
11922
- if (import.meta.env.SSR || !apiUrl || !apiKey || save2Repo) {
11924
+ if (import.meta.env.SSR || !cloudPolicy.isCloudMode) {
11923
11925
  return { enabled: false, apiBases: [], apiKey: '' };
11924
11926
  }
11925
11927
 
11926
11928
  return {
11927
11929
  enabled: true,
11928
- apiBases: buildApiCandidates(apiUrl),
11929
- apiKey,
11930
+ apiBases: buildApiCandidates(cloudPolicy.apiUrl),
11931
+ apiKey: cloudPolicy.apiKey,
11930
11932
  };
11931
11933
  }
11932
11934
 
@@ -13092,12 +13094,16 @@ END_OF_FILE_CONTENT
13092
13094
  echo "Creating src/lib/tenantEnv.ts..."
13093
13095
  cat << 'END_OF_FILE_CONTENT' > "src/lib/tenantEnv.ts"
13094
13096
  import { normalizeBasePath } from '@olonjs/core';
13097
+ import { readCloudEnvFromVite, resolveCloudPolicy, type CloudPolicy } from '@olonjs/react';
13095
13098
 
13096
- export const CLOUD_API_URL =
13097
- import.meta.env.VITE_OLONJS_CLOUD_URL ?? import.meta.env.VITE_JSONPAGES_CLOUD_URL;
13098
- export const CLOUD_API_KEY =
13099
- import.meta.env.VITE_OLONJS_API_KEY ?? import.meta.env.VITE_JSONPAGES_API_KEY;
13100
- export const SAVE2REPO_ENABLED = import.meta.env.VITE_SAVE2REPO === 'true';
13099
+ /** Single Vite → policy path. Prefer `cloudPolicy` over ad-hoc env reads. */
13100
+ export const cloudPolicy: CloudPolicy = resolveCloudPolicy(
13101
+ readCloudEnvFromVite(import.meta.env as Record<string, unknown>),
13102
+ );
13103
+
13104
+ /** Aliases of `cloudPolicy.apiUrl` / `apiKey` for DNA helpers. */
13105
+ export const CLOUD_API_URL = cloudPolicy.apiUrl;
13106
+ export const CLOUD_API_KEY = cloudPolicy.apiKey;
13101
13107
  export const APP_BASE_PATH = normalizeBasePath(import.meta.env.BASE_URL || '/');
13102
13108
  export const TENANT_ID = 'alpha';
13103
13109
 
@@ -13106,28 +13112,33 @@ echo "Creating src/lib/useAssetsManifest.ts..."
13106
13112
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useAssetsManifest.ts"
13107
13113
  import { useCallback, useEffect, useMemo, useState } from 'react';
13108
13114
  import type { LibraryImageEntry } from '@olonjs/core';
13109
- import { buildApiCandidates } from '@/lib/sppCloudConfig';
13110
- import { CLOUD_API_KEY, CLOUD_API_URL } from '@/lib/tenantEnv';
13115
+ import { buildApiCandidates } from '@olonjs/react';
13116
+
13117
+ import { cloudPolicy } from '@/lib/tenantEnv';
13111
13118
 
13112
13119
  function normalizeApiBase(raw: string): string {
13113
13120
  return raw.trim().replace(/\/+$/, '');
13114
13121
  }
13115
13122
 
13116
- export function useAssetsManifest(isCloudMode: boolean) {
13123
+ /** Asset library — cloud list when `cloudPolicy.isCloudMode`, else local `/api/list-assets`. */
13124
+ export function useAssetsManifest(isCloudMode: boolean = cloudPolicy.isCloudMode) {
13117
13125
  const [assetsManifest, setAssetsManifest] = useState<LibraryImageEntry[]>([]);
13118
13126
  const cloudApiCandidates = useMemo(
13119
- () => (isCloudMode && CLOUD_API_URL ? buildApiCandidates(CLOUD_API_URL) : []),
13127
+ () => (isCloudMode && cloudPolicy.apiUrl ? buildApiCandidates(cloudPolicy.apiUrl) : []),
13120
13128
  [isCloudMode],
13121
13129
  );
13122
13130
 
13123
13131
  const loadAssetsManifest = useCallback(async (): Promise<void> => {
13124
- if (isCloudMode && CLOUD_API_URL && CLOUD_API_KEY) {
13125
- const apiBases = cloudApiCandidates.length > 0 ? cloudApiCandidates : [normalizeApiBase(CLOUD_API_URL)];
13132
+ if (isCloudMode && cloudPolicy.apiUrl && cloudPolicy.apiKey) {
13133
+ const apiBases =
13134
+ cloudApiCandidates.length > 0
13135
+ ? cloudApiCandidates
13136
+ : [normalizeApiBase(cloudPolicy.apiUrl)];
13126
13137
  for (const apiBase of apiBases) {
13127
13138
  try {
13128
13139
  const res = await fetch(`${apiBase}/assets/list?limit=200`, {
13129
13140
  method: 'GET',
13130
- headers: { Authorization: `Bearer ${CLOUD_API_KEY}` },
13141
+ headers: { Authorization: `Bearer ${cloudPolicy.apiKey}` },
13131
13142
  });
13132
13143
  const body = (await res.json().catch(() => ({}))) as { items?: LibraryImageEntry[] };
13133
13144
  if (!res.ok) continue;
@@ -13401,6 +13412,8 @@ echo "Creating src/lib/useFormSubmit.ts..."
13401
13412
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useFormSubmit.ts"
13402
13413
  import { useState, useCallback } from 'react';
13403
13414
 
13415
+ import { cloudPolicy } from '@/lib/tenantEnv';
13416
+
13404
13417
  export type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error';
13405
13418
 
13406
13419
  interface UseFormSubmitOptions {
@@ -13413,22 +13426,20 @@ export function useFormSubmit({ source, tenantId }: UseFormSubmitOptions) {
13413
13426
  const [message, setMessage] = useState<string>('');
13414
13427
 
13415
13428
  const submit = useCallback(async (
13416
- formData: FormData,
13417
- recipientEmail: string,
13418
- pageSlug: string,
13429
+ formData: FormData,
13430
+ recipientEmail: string,
13431
+ pageSlug: string,
13419
13432
  sectionId: string
13420
13433
  ) => {
13421
- const cloudApiUrl = import.meta.env.VITE_JSONPAGES_CLOUD_URL as string | undefined;
13422
- const cloudApiKey = import.meta.env.VITE_JSONPAGES_API_KEY as string | undefined;
13434
+ const { apiUrl, apiKey } = cloudPolicy;
13423
13435
 
13424
- if (!cloudApiUrl || !cloudApiKey) {
13436
+ if (!apiUrl || !apiKey) {
13425
13437
  setStatus('error');
13426
13438
  setMessage('Configurazione API non disponibile. Riprova tra poco.');
13427
13439
  return false;
13428
13440
  }
13429
13441
 
13430
- // Trasformiamo FormData in un oggetto piatto per il payload JSON
13431
- const data: Record<string, any> = {};
13442
+ const data: Record<string, string> = {};
13432
13443
  formData.forEach((value, key) => {
13433
13444
  data[key] = String(value).trim();
13434
13445
  });
@@ -13443,18 +13454,17 @@ export function useFormSubmit({ source, tenantId }: UseFormSubmitOptions) {
13443
13454
  submittedAt: new Date().toISOString(),
13444
13455
  };
13445
13456
 
13446
- // Idempotency Key per evitare doppi invii accidentali
13447
13457
  const idempotencyKey = `form-${sectionId}-${Date.now()}`;
13448
13458
 
13449
13459
  setStatus('submitting');
13450
13460
  setMessage('Invio in corso...');
13451
13461
 
13452
13462
  try {
13453
- const apiBase = cloudApiUrl.replace(/\/$/, '');
13463
+ const apiBase = apiUrl.replace(/\/$/, '');
13454
13464
  const response = await fetch(`${apiBase}/forms/submit`, {
13455
13465
  method: 'POST',
13456
13466
  headers: {
13457
- Authorization: `Bearer ${cloudApiKey}`,
13467
+ Authorization: `Bearer ${apiKey}`,
13458
13468
  'Content-Type': 'application/json',
13459
13469
  'Idempotency-Key': idempotencyKey,
13460
13470
  },
@@ -13484,23 +13494,18 @@ export function useFormSubmit({ source, tenantId }: UseFormSubmitOptions) {
13484
13494
  }, []);
13485
13495
 
13486
13496
  return { submit, status, message, reset };
13487
- }
13497
+ }
13498
+
13488
13499
  END_OF_FILE_CONTENT
13489
13500
  echo "Creating src/lib/useOlonForms.ts..."
13490
13501
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useOlonForms.ts"
13491
13502
  import { useCallback, useEffect, useState } from 'react';
13492
13503
  import type { FormState } from '@olonjs/react';
13493
13504
 
13494
- const API_BASE =
13495
- (import.meta.env.VITE_OLONJS_CLOUD_URL as string | undefined) ??
13496
- (import.meta.env.VITE_JSONPAGES_CLOUD_URL as string | undefined);
13497
-
13498
- const API_KEY =
13499
- (import.meta.env.VITE_OLONJS_API_KEY as string | undefined) ??
13500
- (import.meta.env.VITE_JSONPAGES_API_KEY as string | undefined);
13505
+ import { cloudPolicy } from '@/lib/tenantEnv';
13501
13506
 
13502
13507
  interface UseOlonFormsOptions {
13503
- /** Override the submit endpoint. Defaults to VITE_OLONJS_CLOUD_URL/forms/submit */
13508
+ /** Override the submit endpoint. Defaults to cloudPolicy.apiUrl/forms/submit */
13504
13509
  endpoint?: string;
13505
13510
  }
13506
13511
 
@@ -13519,13 +13524,16 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
13519
13524
  }, []);
13520
13525
 
13521
13526
  useEffect(() => {
13527
+ const apiUrl = cloudPolicy.apiUrl;
13528
+ const apiKey = cloudPolicy.apiKey;
13529
+
13522
13530
  const resolvedBase = options?.endpoint
13523
13531
  ? options.endpoint.replace(/\/$/, '')
13524
- : API_BASE
13525
- ? API_BASE.replace(/\/$/, '')
13532
+ : apiUrl
13533
+ ? apiUrl.replace(/\/$/, '')
13526
13534
  : null;
13527
13535
 
13528
- if (!resolvedBase || !API_KEY) {
13536
+ if (!resolvedBase || !apiKey) {
13529
13537
  console.warn('[useOlonForms] Missing API endpoint or key — forms will not submit.');
13530
13538
  return;
13531
13539
  }
@@ -13568,7 +13576,7 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
13568
13576
  const response = await fetch(endpoint, {
13569
13577
  method: 'POST',
13570
13578
  headers: {
13571
- Authorization: `Bearer ${API_KEY}`,
13579
+ Authorization: `Bearer ${apiKey}`,
13572
13580
  'Content-Type': 'application/json',
13573
13581
  'Idempotency-Key': `form-${formId}-${Date.now()}`,
13574
13582
  },
@@ -13619,7 +13627,7 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
13619
13627
  END_OF_FILE_CONTENT
13620
13628
  echo "Creating src/lib/useTenantBootstrap.ts..."
13621
13629
  cat << 'END_OF_FILE_CONTENT' > "src/lib/useTenantBootstrap.ts"
13622
- import { useEffect, useMemo, useRef, useState } from 'react';
13630
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
13623
13631
 
13624
13632
  import { logBootstrapEvent, toCloudLoadFailure } from '@/lib/cloud/bootstrapTelemetry';
13625
13633
 
@@ -13649,7 +13657,7 @@ import {
13649
13657
 
13650
13658
  import type { JsonPagesConfig } from '@olonjs/core';
13651
13659
 
13652
- import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL, SAVE2REPO_ENABLED } from '@/lib/tenantEnv';
13660
+ import { APP_BASE_PATH, CLOUD_API_KEY, CLOUD_API_URL, cloudPolicy } from '@/lib/tenantEnv';
13653
13661
 
13654
13662
  import type { MenuConfig, PageConfig, SiteConfig, ThemeConfig } from '@/types';
13655
13663
 
@@ -13731,13 +13739,10 @@ export function useTenantBootstrap({
13731
13739
 
13732
13740
  }: UseTenantBootstrapOptions) {
13733
13741
 
13734
- const isCloudMode = Boolean(CLOUD_API_URL && CLOUD_API_KEY);
13735
-
13736
- const isSave2RepoMode = isCloudMode && SAVE2REPO_ENABLED;
13742
+ const { isCloudMode, bootSource } = cloudPolicy;
13737
13743
 
13738
- const isHotSaveMode = isCloudMode && !isSave2RepoMode;
13739
-
13740
- const useRenderBootstrap = isHotSaveMode;
13744
+ /** Live SPP render boot — only when bootSource is live (not static Save2Repo). */
13745
+ const useRenderBootstrap = bootSource === 'live';
13741
13746
 
13742
13747
 
13743
13748
 
@@ -13801,7 +13806,11 @@ export function useTenantBootstrap({
13801
13806
 
13802
13807
  const isTenantEmpty = Object.keys(pages).length === 0;
13803
13808
 
13804
-
13809
+ /** Call when live /admin content sync has finished (success, cache, or error). */
13810
+ const markCloudContentReady = useCallback(() => {
13811
+ setShowTopProgress(false);
13812
+ setHasInitialCloudResolved(true);
13813
+ }, []);
13805
13814
 
13806
13815
  const retryBootstrap = () => {
13807
13816
 
@@ -13841,7 +13850,7 @@ export function useTenantBootstrap({
13841
13850
 
13842
13851
 
13843
13852
 
13844
- if (isSave2RepoMode) {
13853
+ if (bootSource === 'static') {
13845
13854
 
13846
13855
  if (contentLoadInFlight.current) return;
13847
13856
 
@@ -13938,17 +13947,12 @@ export function useTenantBootstrap({
13938
13947
 
13939
13948
 
13940
13949
  if (isAdminPath(window.location.pathname, APP_BASE_PATH)) {
13941
-
13950
+ // Live /admin: content comes from useAdminStudioContent — do not paint empty first.
13942
13951
  setContentMode('cloud');
13943
-
13944
13952
  setContentFallback(null);
13945
-
13946
- setShowTopProgress(false);
13947
-
13948
- setHasInitialCloudResolved(true);
13949
-
13953
+ setShowTopProgress(true);
13954
+ setHasInitialCloudResolved(false);
13950
13955
  return;
13951
-
13952
13956
  }
13953
13957
 
13954
13958
 
@@ -14245,7 +14249,7 @@ export function useTenantBootstrap({
14245
14249
 
14246
14250
  isCloudMode,
14247
14251
 
14248
- isSave2RepoMode,
14252
+ bootSource,
14249
14253
 
14250
14254
  useRenderBootstrap,
14251
14255
 
@@ -14291,9 +14295,7 @@ export function useTenantBootstrap({
14291
14295
 
14292
14296
  isCloudMode,
14293
14297
 
14294
- isSave2RepoMode,
14295
-
14296
- isHotSaveMode,
14298
+ bootSource,
14297
14299
 
14298
14300
  contentMode,
14299
14301
 
@@ -14307,6 +14309,8 @@ export function useTenantBootstrap({
14307
14309
 
14308
14310
  isTenantEmpty,
14309
14311
 
14312
+ markCloudContentReady,
14313
+
14310
14314
  retryBootstrap,
14311
14315
 
14312
14316
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olonjs/cli",
3
- "version": "3.0.151",
3
+ "version": "3.0.152",
4
4
  "description": "The Sovereign CLI Engine for OlonJS.",
5
5
  "type": "module",
6
6
  "bin": {