@jsonpages/cli 3.0.62 → 3.0.64

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.
@@ -36,25 +36,26 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
36
36
  "dev:clean": "vite --force",
37
37
  "build": "tsc && vite build",
38
38
  "dist": "bash ./src2Code.sh src index.html package.json",
39
- "preview": "vite preview"
39
+ "preview": "vite preview",
40
+ "bake:email": "tsx scripts/bake-email.tsx",
41
+ "bakemail": "npm run bake:email --"
40
42
  },
41
43
  "dependencies": {
42
- "@tiptap/extension-image": "latest",
43
- "@tiptap/extension-link": "latest",
44
- "@tiptap/extension-underline": "latest",
45
- "@tiptap/react": "latest",
46
- "@tiptap/starter-kit": "latest",
47
- "@jsonpages/core": "^1.0.49",
44
+ "@tiptap/extension-image": "^2.11.5",
45
+ "@tiptap/extension-link": "^2.11.5",
46
+ "@tiptap/react": "^2.11.5",
47
+ "@tiptap/starter-kit": "^2.11.5",
48
+ "@jsonpages/core": "^1.0.51",
48
49
  "clsx": "^2.1.1",
49
50
  "lucide-react": "^0.474.0",
50
51
  "react": "^19.0.0",
51
- "react-markdown": "latest",
52
+ "react-markdown": "^9.0.1",
52
53
  "react-dom": "^19.0.0",
53
54
  "react-router-dom": "^6.30.3",
54
- "rehype-sanitize": "latest",
55
- "remark-gfm": "latest",
55
+ "rehype-sanitize": "^6.0.0",
56
+ "remark-gfm": "^4.0.1",
56
57
  "tailwind-merge": "^3.0.1",
57
- "tiptap-markdown": "latest",
58
+ "tiptap-markdown": "^0.8.10",
58
59
  "zod": "^3.24.1"
59
60
  },
60
61
  "devDependencies": {
@@ -63,7 +64,10 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
63
64
  "@types/react-dom": "^19.0.3",
64
65
  "@vitejs/plugin-react": "^4.3.4",
65
66
  "typescript": "^5.7.3",
66
- "vite": "^6.0.11"
67
+ "vite": "^6.0.11",
68
+ "@react-email/components": "^0.0.41",
69
+ "@react-email/render": "^1.0.5",
70
+ "tsx": "^4.20.5"
67
71
  }
68
72
  }
69
73
 
@@ -214,7 +218,6 @@ console.log("🔍 DEBUG ENV:", {
214
218
  export default App;
215
219
 
216
220
  END_OF_FILE_CONTENT
217
- # SKIP: src/App.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
218
221
  echo "Creating src/App_.tsx..."
219
222
  cat << 'END_OF_FILE_CONTENT' > "src/App_.tsx"
220
223
  import { useState, useEffect } from 'react';
@@ -3086,17 +3089,248 @@ export type ProductTriadSettings = z.infer<typeof BaseSectionSettingsSchema>;
3086
3089
 
3087
3090
  END_OF_FILE_CONTENT
3088
3091
  mkdir -p "src/components/save-drawer"
3089
- # SKIP: src/components/save-drawer/DeployConnector.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
3090
- # SKIP: src/components/save-drawer/DeployConnector.tsx:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
3091
- # SKIP: src/components/save-drawer/DeployNode.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
3092
- # SKIP: src/components/save-drawer/DeployNode.tsx:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
3093
- # SKIP: src/components/save-drawer/DopaDrawer.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
3094
- # SKIP: src/components/save-drawer/DopaDrawer.tsx:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
3095
- # SKIP: src/components/save-drawer/Visuals.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
3096
- # SKIP: src/components/save-drawer/Visuals.tsx:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
3097
- # SKIP: src/components/save-drawer/saverStyle.css:Zone.Identifier è un file binario e non può essere convertito in testo.
3098
- # SKIP: src/components/save-drawer/saverStyle.css:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
3099
3092
  mkdir -p "src/components/tiptap"
3093
+ echo "Creating src/components/tiptap/INTEGRATION.md..."
3094
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/INTEGRATION.md"
3095
+ # Tiptap Editorial — Integration Guide
3096
+
3097
+ How to add the `tiptap` section to a new tenant.
3098
+
3099
+ ---
3100
+
3101
+ ## 1. Copy the component
3102
+
3103
+ Copy the entire folder into the new tenant:
3104
+
3105
+ ```
3106
+ src/components/tiptap/
3107
+ index.ts
3108
+ types.ts
3109
+ View.tsx
3110
+ ```
3111
+
3112
+ ---
3113
+
3114
+ ## 2. Install npm dependencies
3115
+
3116
+ Add to the tenant's `package.json` and run `npm install`:
3117
+
3118
+ ```json
3119
+ "@tiptap/extension-image": "^2.11.5",
3120
+ "@tiptap/extension-link": "^2.11.5",
3121
+ "@tiptap/react": "^2.11.5",
3122
+ "@tiptap/starter-kit": "^2.11.5",
3123
+ "react-markdown": "^9.0.1",
3124
+ "rehype-sanitize": "^6.0.0",
3125
+ "remark-gfm": "^4.0.1",
3126
+ "tiptap-markdown": "^0.8.10"
3127
+ ```
3128
+
3129
+ ---
3130
+
3131
+ ## 3. Add CSS to `src/index.css`
3132
+
3133
+ Two blocks are required — one for the public (visitor) view, one for the editor (studio) view.
3134
+
3135
+ ```css
3136
+ /* ==========================================================================
3137
+ TIPTAP — Public content typography (visitor view)
3138
+ ========================================================================== */
3139
+ .jp-tiptap-content > * + * { margin-top: 0.75em; }
3140
+
3141
+ .jp-tiptap-content h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
3142
+ .jp-tiptap-content h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
3143
+ .jp-tiptap-content h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
3144
+ .jp-tiptap-content h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
3145
+
3146
+ .jp-tiptap-content p { line-height: 1.7; }
3147
+ .jp-tiptap-content strong { font-weight: 700; }
3148
+ .jp-tiptap-content em { font-style: italic; }
3149
+ .jp-tiptap-content s { text-decoration: line-through; }
3150
+
3151
+ .jp-tiptap-content a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
3152
+ .jp-tiptap-content a:hover { opacity: 0.8; }
3153
+
3154
+ .jp-tiptap-content code {
3155
+ font-family: var(--font-mono, ui-monospace, monospace);
3156
+ font-size: 0.875em;
3157
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
3158
+ border-radius: 0.25em;
3159
+ padding: 0.1em 0.35em;
3160
+ }
3161
+ .jp-tiptap-content pre {
3162
+ background: color-mix(in oklch, var(--background) 60%, black);
3163
+ border-radius: 0.5em;
3164
+ padding: 1em 1.25em;
3165
+ overflow-x: auto;
3166
+ }
3167
+ .jp-tiptap-content pre code { background: none; padding: 0; }
3168
+
3169
+ .jp-tiptap-content ul { list-style-type: disc; padding-left: 1.625em; }
3170
+ .jp-tiptap-content ol { list-style-type: decimal; padding-left: 1.625em; }
3171
+ .jp-tiptap-content li { line-height: 1.7; margin-top: 0.25em; }
3172
+ .jp-tiptap-content li + li { margin-top: 0.25em; }
3173
+
3174
+ .jp-tiptap-content blockquote {
3175
+ border-left: 3px solid var(--border);
3176
+ padding-left: 1em;
3177
+ color: var(--muted-foreground);
3178
+ font-style: italic;
3179
+ }
3180
+ .jp-tiptap-content hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
3181
+ .jp-tiptap-content img { max-width: 100%; height: auto; border-radius: 0.5rem; }
3182
+
3183
+ /* ==========================================================================
3184
+ TIPTAP / PROSEMIRROR — Editor typography (studio view)
3185
+ ========================================================================== */
3186
+ .jp-simple-editor .ProseMirror { outline: none; word-break: break-word; }
3187
+ .jp-simple-editor .ProseMirror > * + * { margin-top: 0.75em; }
3188
+
3189
+ .jp-simple-editor .ProseMirror h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
3190
+ .jp-simple-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
3191
+ .jp-simple-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
3192
+ .jp-simple-editor .ProseMirror h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
3193
+
3194
+ .jp-simple-editor .ProseMirror p { line-height: 1.7; }
3195
+ .jp-simple-editor .ProseMirror strong { font-weight: 700; }
3196
+ .jp-simple-editor .ProseMirror em { font-style: italic; }
3197
+ .jp-simple-editor .ProseMirror s { text-decoration: line-through; }
3198
+
3199
+ .jp-simple-editor .ProseMirror a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
3200
+ .jp-simple-editor .ProseMirror a:hover { opacity: 0.8; }
3201
+
3202
+ .jp-simple-editor .ProseMirror code {
3203
+ font-family: var(--font-mono, ui-monospace, monospace);
3204
+ font-size: 0.875em;
3205
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
3206
+ border-radius: 0.25em;
3207
+ padding: 0.1em 0.35em;
3208
+ }
3209
+ .jp-simple-editor .ProseMirror pre {
3210
+ background: color-mix(in oklch, var(--background) 60%, black);
3211
+ border-radius: 0.5em;
3212
+ padding: 1em 1.25em;
3213
+ overflow-x: auto;
3214
+ }
3215
+ .jp-simple-editor .ProseMirror pre code { background: none; padding: 0; }
3216
+
3217
+ .jp-simple-editor .ProseMirror ul { list-style-type: disc; padding-left: 1.625em; }
3218
+ .jp-simple-editor .ProseMirror ol { list-style-type: decimal; padding-left: 1.625em; }
3219
+ .jp-simple-editor .ProseMirror li { line-height: 1.7; margin-top: 0.25em; }
3220
+ .jp-simple-editor .ProseMirror li + li { margin-top: 0.25em; }
3221
+
3222
+ .jp-simple-editor .ProseMirror blockquote {
3223
+ border-left: 3px solid var(--border);
3224
+ padding-left: 1em;
3225
+ color: var(--muted-foreground);
3226
+ font-style: italic;
3227
+ }
3228
+ .jp-simple-editor .ProseMirror hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
3229
+
3230
+ .jp-simple-editor .ProseMirror img { max-width: 100%; height: auto; border-radius: 0.5rem; }
3231
+ .jp-simple-editor .ProseMirror img[data-uploading="true"] {
3232
+ opacity: 0.6;
3233
+ filter: grayscale(0.25);
3234
+ outline: 2px dashed rgb(59 130 246 / 0.7);
3235
+ outline-offset: 2px;
3236
+ }
3237
+ .jp-simple-editor .ProseMirror img[data-upload-error="true"] {
3238
+ outline: 2px solid rgb(239 68 68 / 0.8);
3239
+ outline-offset: 2px;
3240
+ }
3241
+ .jp-simple-editor .ProseMirror p.is-editor-empty:first-child::before {
3242
+ content: attr(data-placeholder);
3243
+ color: var(--muted-foreground);
3244
+ opacity: 0.5;
3245
+ pointer-events: none;
3246
+ float: left;
3247
+ height: 0;
3248
+ }
3249
+ ```
3250
+
3251
+ ---
3252
+
3253
+ ## 4. Register in `src/lib/schemas.ts`
3254
+
3255
+ ```ts
3256
+ import { TiptapSchema } from '@/components/tiptap';
3257
+
3258
+ export const SECTION_SCHEMAS = {
3259
+ // ... existing schemas
3260
+ 'tiptap': TiptapSchema,
3261
+ } as const;
3262
+ ```
3263
+
3264
+ ---
3265
+
3266
+ ## 5. Register in `src/lib/addSectionConfig.ts`
3267
+
3268
+ ```ts
3269
+ const addableSectionTypes = [
3270
+ // ... existing types
3271
+ 'tiptap',
3272
+ ] as const;
3273
+
3274
+ const sectionTypeLabels = {
3275
+ // ... existing labels
3276
+ 'tiptap': 'Tiptap Editorial',
3277
+ };
3278
+
3279
+ function getDefaultSectionData(type: string) {
3280
+ switch (type) {
3281
+ // ... existing cases
3282
+ case 'tiptap': return { content: '# Post title\n\nStart writing in Markdown...' };
3283
+ }
3284
+ }
3285
+ ```
3286
+
3287
+ ---
3288
+
3289
+ ## 6. Register in `src/lib/ComponentRegistry.tsx`
3290
+
3291
+ ```tsx
3292
+ import { Tiptap } from '@/components/tiptap';
3293
+
3294
+ export const ComponentRegistry = {
3295
+ // ... existing components
3296
+ 'tiptap': Tiptap,
3297
+ };
3298
+ ```
3299
+
3300
+ ---
3301
+
3302
+ ## 7. Register in `src/types.ts`
3303
+
3304
+ ```ts
3305
+ import type { TiptapData, TiptapSettings } from '@/components/tiptap';
3306
+
3307
+ export type SectionComponentPropsMap = {
3308
+ // ... existing entries
3309
+ 'tiptap': { data: TiptapData; settings?: TiptapSettings };
3310
+ };
3311
+
3312
+ declare module '@jsonpages/core' {
3313
+ export interface SectionDataRegistry {
3314
+ // ... existing entries
3315
+ 'tiptap': TiptapData;
3316
+ }
3317
+ export interface SectionSettingsRegistry {
3318
+ // ... existing entries
3319
+ 'tiptap': TiptapSettings;
3320
+ }
3321
+ }
3322
+ ```
3323
+
3324
+ ---
3325
+
3326
+ ## Notes
3327
+
3328
+ - Typography uses tenant CSS variables (`--primary`, `--border`, `--muted-foreground`, `--font-mono`) — no hardcoded colors.
3329
+ - `@tailwindcss/typography` is **not** required; the CSS blocks above replace it.
3330
+ - The toolbar is admin-only (studio mode). In visitor mode, content is rendered via `ReactMarkdown`.
3331
+ - Underline is intentionally excluded: `tiptap-markdown` with `html: false` cannot round-trip `<u>` tags.
3332
+
3333
+ END_OF_FILE_CONTENT
3100
3334
  echo "Creating src/components/tiptap/View.tsx..."
3101
3335
  cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/View.tsx"
3102
3336
  import React from 'react';
@@ -3108,30 +3342,20 @@ import type { Editor } from '@tiptap/react';
3108
3342
  import StarterKit from '@tiptap/starter-kit';
3109
3343
  import Link from '@tiptap/extension-link';
3110
3344
  import Image from '@tiptap/extension-image';
3111
- import Underline from '@tiptap/extension-underline';
3112
3345
  import { Markdown } from 'tiptap-markdown';
3113
3346
  import {
3114
- Undo2,
3115
- Redo2,
3116
- Heading,
3117
- List,
3118
- ListOrdered,
3119
- Bold,
3120
- Italic,
3121
- Strikethrough,
3122
- Underline as UnderlineIcon,
3123
- Code2,
3124
- Quote,
3125
- SquareCode,
3126
- Link2,
3127
- Unlink2,
3128
- ImagePlus,
3129
- Eraser,
3347
+ Undo2, Redo2,
3348
+ List, ListOrdered,
3349
+ Bold, Italic, Strikethrough,
3350
+ Code2, Quote, SquareCode,
3351
+ Link2, Unlink2, ImagePlus, Eraser,
3130
3352
  } from 'lucide-react';
3131
3353
  import { STUDIO_EVENTS, useConfig, useStudio } from '@jsonpages/core';
3132
3354
  import type { TiptapData, TiptapSettings } from './types';
3133
3355
 
3134
- const ToolbarButton: React.FC<{
3356
+ // ── UI primitives ─────────────────────────────────────────────────────────────
3357
+
3358
+ const Btn: React.FC<{
3135
3359
  active?: boolean;
3136
3360
  title: string;
3137
3361
  onClick: () => void;
@@ -3143,171 +3367,196 @@ const ToolbarButton: React.FC<{
3143
3367
  onMouseDown={(e) => e.preventDefault()}
3144
3368
  onClick={onClick}
3145
3369
  className={[
3146
- 'inline-flex h-7 min-w-7 items-center justify-center rounded-md border px-2 text-xs transition-colors',
3370
+ 'inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-xs transition-colors',
3147
3371
  active
3148
- ? 'bg-blue-600/25 border-blue-400/60 text-blue-100'
3149
- : 'bg-zinc-900 border-zinc-700 text-zinc-200 hover:bg-zinc-800',
3372
+ ? 'bg-zinc-700/70 text-zinc-100'
3373
+ : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200',
3150
3374
  ].join(' ')}
3151
3375
  >
3152
3376
  {children}
3153
3377
  </button>
3154
3378
  );
3155
3379
 
3156
- const ToolbarSeparator: React.FC = () => <span className="mx-1 h-5 w-px bg-zinc-700" aria-hidden />;
3380
+ const Sep: React.FC = () => (
3381
+ <span className="mx-0.5 h-5 w-px shrink-0 bg-zinc-800" aria-hidden />
3382
+ );
3383
+
3384
+ // ── Image extension with upload metadata ──────────────────────────────────────
3157
3385
 
3158
3386
  const UploadableImage = Image.extend({
3159
3387
  addAttributes() {
3388
+ const bool = (attr: string) => ({
3389
+ default: false,
3390
+ parseHTML: (el: HTMLElement) => el.getAttribute(attr) === 'true',
3391
+ renderHTML: (attrs: Record<string, unknown>) =>
3392
+ attrs[attr.replace('data-', '').replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())]
3393
+ ? { [attr]: 'true' }
3394
+ : {},
3395
+ });
3160
3396
  return {
3161
3397
  ...this.parent?.(),
3162
3398
  uploadId: {
3163
3399
  default: null,
3164
- parseHTML: (element: HTMLElement) => element.getAttribute('data-upload-id'),
3165
- renderHTML: (attributes: Record<string, unknown>) =>
3166
- attributes.uploadId ? { 'data-upload-id': String(attributes.uploadId) } : {},
3167
- },
3168
- uploading: {
3169
- default: false,
3170
- parseHTML: (element: HTMLElement) => element.getAttribute('data-uploading') === 'true',
3171
- renderHTML: (attributes: Record<string, unknown>) =>
3172
- attributes.uploading ? { 'data-uploading': 'true' } : {},
3173
- },
3174
- uploadError: {
3175
- default: false,
3176
- parseHTML: (element: HTMLElement) => element.getAttribute('data-upload-error') === 'true',
3177
- renderHTML: (attributes: Record<string, unknown>) =>
3178
- attributes.uploadError ? { 'data-upload-error': 'true' } : {},
3179
- },
3180
- awaitingUpload: {
3181
- default: false,
3182
- parseHTML: (element: HTMLElement) => element.getAttribute('data-awaiting-upload') === 'true',
3183
- renderHTML: (attributes: Record<string, unknown>) =>
3184
- attributes.awaitingUpload ? { 'data-awaiting-upload': 'true' } : {},
3400
+ parseHTML: (el: HTMLElement) => el.getAttribute('data-upload-id'),
3401
+ renderHTML: (attrs: Record<string, unknown>) =>
3402
+ attrs.uploadId ? { 'data-upload-id': String(attrs.uploadId) } : {},
3185
3403
  },
3404
+ uploading: bool('data-uploading'),
3405
+ uploadError: bool('data-upload-error'),
3406
+ awaitingUpload: bool('data-awaiting-upload'),
3186
3407
  };
3187
3408
  },
3188
3409
  });
3189
3410
 
3190
- const getEditorMarkdown = (editor: Editor | null | undefined): string => {
3191
- const storage = editor?.storage as
3192
- | {
3193
- markdown?: {
3194
- getMarkdown?: () => string;
3195
- };
3196
- }
3197
- | undefined;
3198
- return storage?.markdown?.getMarkdown?.() ?? '';
3199
- };
3411
+ // ── Helpers ───────────────────────────────────────────────────────────────────
3200
3412
 
3201
- const UPLOAD_PLACEHOLDER_SRC =
3202
- "data:image/svg+xml;utf8," +
3203
- encodeURIComponent(
3204
- `<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='420' viewBox='0 0 1200 420'>
3205
- <rect width='1200' height='420' fill='#090B14' stroke='#3F3F46' stroke-width='3' stroke-dasharray='10 10' rx='12' />
3206
- <g font-family='Inter,Arial,sans-serif' text-anchor='middle' fill='#A1A1AA'>
3207
- <text x='600' y='190' font-size='30' font-weight='700'>Uploading image...</text>
3208
- <text x='600' y='235' font-size='22'>Please wait while we process your file</text>
3209
- </g>
3210
- </svg>`
3211
- );
3413
+ const getMarkdown = (ed: Editor | null | undefined): string =>
3414
+ (ed?.storage as { markdown?: { getMarkdown?: () => string } } | undefined)
3415
+ ?.markdown?.getMarkdown?.() ?? '';
3212
3416
 
3213
- const UPLOAD_PICKER_PLACEHOLDER_SRC =
3214
- "data:image/svg+xml;utf8," +
3417
+ const svg = (body: string) =>
3418
+ 'data:image/svg+xml;utf8,' +
3215
3419
  encodeURIComponent(
3216
- `<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='420' viewBox='0 0 1200 420'>
3217
- <rect width='1200' height='420' fill='#090B14' stroke='#3F3F46' stroke-width='3' stroke-dasharray='10 10' rx='12' />
3218
- <g font-family='Inter,Arial,sans-serif' text-anchor='middle'>
3219
- <text x='600' y='210' font-size='34' font-weight='700' fill='#E4E4E7'>Click to upload or drag and drop</text>
3220
- <text x='600' y='255' font-size='24' font-weight='500' fill='#A1A1AA'>Maximum 3 files, 5MB each.</text>
3221
- </g>
3222
- </svg>`
3420
+ `<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='420' viewBox='0 0 1200 420'>${body}</svg>`
3223
3421
  );
3224
3422
 
3225
- const updateImageByUploadId = (
3226
- editor: Editor,
3423
+ const RECT = `<rect width='1200' height='420' fill='#090B14' stroke='#3F3F46' stroke-width='3' stroke-dasharray='10 10' rx='12'/>`;
3424
+
3425
+ const UPLOADING_SRC = svg(
3426
+ RECT +
3427
+ `<text x='600' y='215' font-family='Inter,Arial,sans-serif' font-size='28' font-weight='700' fill='#A1A1AA' text-anchor='middle'>Uploading image…</text>`
3428
+ );
3429
+
3430
+ const PICKER_SRC = svg(
3431
+ RECT +
3432
+ `<text x='600' y='200' font-family='Inter,Arial,sans-serif' font-size='32' font-weight='700' fill='#E4E4E7' text-anchor='middle'>Click to upload or drag &amp; drop</text>` +
3433
+ `<text x='600' y='248' font-family='Inter,Arial,sans-serif' font-size='22' fill='#A1A1AA' text-anchor='middle'>Max 5 MB per file</text>`
3434
+ );
3435
+
3436
+ const patchImage = (
3437
+ ed: Editor,
3227
3438
  uploadId: string,
3228
3439
  patch: Record<string, unknown>
3229
3440
  ): boolean => {
3230
- let targetPos: number | null = null;
3231
- editor.state.doc.descendants((node: { type: { name: string }; attrs?: Record<string, unknown> }, pos: number) => {
3232
- if (node.type.name === 'image' && node.attrs?.uploadId === uploadId) {
3233
- targetPos = pos;
3234
- return false;
3441
+ let pos: number | null = null;
3442
+ ed.state.doc.descendants(
3443
+ (node: { type: { name: string }; attrs?: Record<string, unknown> }, p: number) => {
3444
+ if (node.type.name === 'image' && node.attrs?.uploadId === uploadId) {
3445
+ pos = p;
3446
+ return false;
3447
+ }
3448
+ return true;
3235
3449
  }
3236
- return true;
3237
- });
3238
- if (targetPos == null) return false;
3239
- const current = editor.state.doc.nodeAt(targetPos);
3240
- if (!current) return false;
3241
- const attrs = { ...current.attrs, ...patch };
3242
- editor.view.dispatch(editor.state.tr.setNodeMarkup(targetPos, undefined, attrs));
3450
+ );
3451
+ if (pos == null) return false;
3452
+ const cur = ed.state.doc.nodeAt(pos);
3453
+ if (!cur) return false;
3454
+ ed.view.dispatch(ed.state.tr.setNodeMarkup(pos, undefined, { ...cur.attrs, ...patch }));
3243
3455
  return true;
3244
3456
  };
3245
3457
 
3458
+ // Extensions defined outside component — stable reference, no re-creation on render
3459
+ const EXTENSIONS = [
3460
+ StarterKit,
3461
+ Link.configure({ openOnClick: false, autolink: true }),
3462
+ UploadableImage,
3463
+ // NOTE: Underline is intentionally excluded.
3464
+ // tiptap-markdown with html:false cannot round-trip <u> tags, so underline
3465
+ // would be silently dropped on save. Use bold/italic instead.
3466
+ Markdown.configure({ html: false }),
3467
+ ];
3468
+
3469
+ const EDITOR_CLASSES =
3470
+ 'min-h-[220px] p-4 outline-none';
3471
+
3472
+ // ── Studio editor component ───────────────────────────────────────────────────
3473
+
3246
3474
  const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
3247
3475
  const { assets } = useConfig();
3248
- const rootRef = React.useRef<HTMLElement | null>(null);
3249
- const editorHostRef = React.useRef<HTMLDivElement | null>(null);
3250
- const editorInstanceRef = React.useRef<Editor | null>(null);
3476
+
3477
+ // DOM refs
3478
+ const hostRef = React.useRef<HTMLDivElement | null>(null);
3479
+ const sectionRef = React.useRef<HTMLElement | null>(null);
3251
3480
  const fileInputRef = React.useRef<HTMLInputElement | null>(null);
3252
- const pendingUploadsRef = React.useRef<Map<string, Promise<void>>>(new Map());
3253
- const pendingPickerUploadIdRef = React.useRef<string | null>(null);
3254
- const latestMarkdownRef = React.useRef<string>(data.content ?? '');
3255
- const latestEmittedMarkdownRef = React.useRef<string>(data.content ?? '');
3256
-
3257
- const resolveSectionId = React.useCallback(() => {
3258
- const sectionEl =
3259
- rootRef.current ??
3260
- (editorHostRef.current?.closest('[data-section-id]') as HTMLElement | null);
3261
- rootRef.current = sectionEl;
3262
- return sectionEl?.getAttribute('data-section-id') ?? null;
3481
+
3482
+ // Editor & upload state
3483
+ const editorRef = React.useRef<Editor | null>(null);
3484
+ const pendingUploads = React.useRef<Map<string, Promise<void>>>(new Map());
3485
+ const pendingPickerId = React.useRef<string | null>(null);
3486
+
3487
+ // Markdown sync refs
3488
+ const latestMd = React.useRef<string>(data.content ?? '');
3489
+ const emittedMd = React.useRef<string>(data.content ?? '');
3490
+
3491
+ // Link popover state
3492
+ const [linkOpen, setLinkOpen] = React.useState(false);
3493
+ const [linkUrl, setLinkUrl] = React.useState('');
3494
+ const linkInputRef = React.useRef<HTMLInputElement | null>(null);
3495
+
3496
+ // ── Core helpers ────────────────────────────────────────────────────────
3497
+
3498
+ const getSectionId = React.useCallback((): string | null => {
3499
+ const el =
3500
+ sectionRef.current ??
3501
+ (hostRef.current?.closest('[data-section-id]') as HTMLElement | null);
3502
+ sectionRef.current = el;
3503
+ return el?.getAttribute('data-section-id') ?? null;
3263
3504
  }, []);
3264
3505
 
3265
- const emitInlineUpdate = React.useCallback((markdown: string) => {
3266
- latestMarkdownRef.current = markdown;
3267
- const sectionId = resolveSectionId();
3268
- if (!sectionId) return;
3269
- window.parent.postMessage(
3270
- {
3271
- type: STUDIO_EVENTS.INLINE_FIELD_UPDATE,
3272
- sectionId,
3273
- fieldKey: 'content',
3274
- value: markdown,
3275
- },
3276
- window.location.origin
3277
- );
3278
- latestEmittedMarkdownRef.current = markdown;
3279
- }, [resolveSectionId]);
3506
+ const emit = React.useCallback(
3507
+ (markdown: string) => {
3508
+ latestMd.current = markdown;
3509
+ const sectionId = getSectionId();
3510
+ if (!sectionId) return;
3511
+ window.parent.postMessage(
3512
+ {
3513
+ type: STUDIO_EVENTS.INLINE_FIELD_UPDATE,
3514
+ sectionId,
3515
+ fieldKey: 'content',
3516
+ value: markdown,
3517
+ },
3518
+ window.location.origin
3519
+ );
3520
+ emittedMd.current = markdown;
3521
+ },
3522
+ [getSectionId]
3523
+ );
3524
+
3525
+ const setFocusLock = React.useCallback((on: boolean) => {
3526
+ sectionRef.current?.classList.toggle('jp-editorial-focus', on);
3527
+ }, []);
3528
+
3529
+ // ── Image upload ─────────────────────────────────────────────────────────
3280
3530
 
3281
- const insertUploadPlaceholder = React.useCallback(
3531
+ const insertPlaceholder = React.useCallback(
3282
3532
  (uploadId: string, src: string, awaitingUpload: boolean) => {
3283
- const editor = editorInstanceRef.current;
3284
- if (!editor) return;
3285
- editor
3286
- .chain()
3533
+ const ed = editorRef.current;
3534
+ if (!ed) return;
3535
+ ed.chain()
3287
3536
  .focus()
3288
3537
  .setImage({
3289
3538
  src,
3290
3539
  alt: 'upload-placeholder',
3291
- title: awaitingUpload ? 'Click to upload' : 'Uploading...',
3540
+ title: awaitingUpload ? 'Click to upload' : 'Uploading…',
3292
3541
  uploadId,
3293
3542
  uploading: !awaitingUpload,
3294
3543
  awaitingUpload,
3295
3544
  uploadError: false,
3296
3545
  } as any)
3297
3546
  .run();
3298
- emitInlineUpdate(getEditorMarkdown(editor));
3547
+ emit(getMarkdown(ed));
3299
3548
  },
3300
- [emitInlineUpdate]
3549
+ [emit]
3301
3550
  );
3302
3551
 
3303
- const startUploadForId = React.useCallback(
3552
+ const doUpload = React.useCallback(
3304
3553
  async (uploadId: string, file: File) => {
3305
- const uploadAsset = assets?.onAssetUpload;
3306
- if (!uploadAsset) return;
3307
- const editor = editorInstanceRef.current;
3308
- if (!editor) return;
3309
- updateImageByUploadId(editor, uploadId, {
3310
- src: UPLOAD_PLACEHOLDER_SRC,
3554
+ const uploadFn = assets?.onAssetUpload;
3555
+ if (!uploadFn) return;
3556
+ const ed = editorRef.current;
3557
+ if (!ed) return;
3558
+ patchImage(ed, uploadId, {
3559
+ src: UPLOADING_SRC,
3311
3560
  alt: file.name,
3312
3561
  title: file.name,
3313
3562
  uploading: true,
@@ -3316,10 +3565,10 @@ const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
3316
3565
  });
3317
3566
  const task = (async () => {
3318
3567
  try {
3319
- const url = await uploadAsset(file);
3320
- const currentEditor = editorInstanceRef.current;
3321
- if (currentEditor) {
3322
- updateImageByUploadId(currentEditor, uploadId, {
3568
+ const url = await uploadFn(file);
3569
+ const cur = editorRef.current;
3570
+ if (cur) {
3571
+ patchImage(cur, uploadId, {
3323
3572
  src: url,
3324
3573
  alt: file.name,
3325
3574
  title: file.name,
@@ -3328,328 +3577,453 @@ const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
3328
3577
  awaitingUpload: false,
3329
3578
  uploadError: false,
3330
3579
  });
3331
- emitInlineUpdate(getEditorMarkdown(currentEditor));
3580
+ emit(getMarkdown(cur));
3332
3581
  }
3333
3582
  } catch (err) {
3334
3583
  console.error('[tiptap] upload failed', err);
3335
- const currentEditor = editorInstanceRef.current;
3336
- if (currentEditor) {
3337
- updateImageByUploadId(currentEditor, uploadId, {
3584
+ const cur = editorRef.current;
3585
+ if (cur)
3586
+ patchImage(cur, uploadId, {
3338
3587
  uploading: false,
3339
3588
  awaitingUpload: false,
3340
3589
  uploadError: true,
3341
3590
  });
3342
- }
3343
3591
  } finally {
3344
- pendingUploadsRef.current.delete(uploadId);
3592
+ pendingUploads.current.delete(uploadId);
3345
3593
  }
3346
3594
  })();
3347
- pendingUploadsRef.current.set(uploadId, task);
3595
+ pendingUploads.current.set(uploadId, task);
3348
3596
  await task;
3349
3597
  },
3350
- [assets, emitInlineUpdate]
3598
+ [assets, emit]
3351
3599
  );
3352
3600
 
3353
- const uploadImage = React.useCallback(
3601
+ const uploadFile = React.useCallback(
3354
3602
  async (file: File) => {
3355
- const uploadId = crypto.randomUUID();
3356
- insertUploadPlaceholder(uploadId, UPLOAD_PLACEHOLDER_SRC, false);
3357
- await startUploadForId(uploadId, file);
3603
+ const id = crypto.randomUUID();
3604
+ insertPlaceholder(id, UPLOADING_SRC, false);
3605
+ await doUpload(id, file);
3358
3606
  },
3359
- [insertUploadPlaceholder, startUploadForId]
3607
+ [insertPlaceholder, doUpload]
3360
3608
  );
3361
3609
 
3362
- const setFocusLock = React.useCallback((enabled: boolean) => {
3363
- const sectionEl = rootRef.current;
3364
- if (!sectionEl) return;
3365
- if (enabled) sectionEl.classList.add('jp-editorial-focus');
3366
- else sectionEl.classList.remove('jp-editorial-focus');
3367
- }, []);
3368
-
3369
- const editor = useEditor(
3370
- {
3371
- extensions: [
3372
- StarterKit,
3373
- Link.configure({ openOnClick: false, autolink: true }),
3374
- Underline,
3375
- UploadableImage,
3376
- Markdown.configure({ html: false }),
3377
- ],
3378
- content: data.content ?? '',
3379
- autofocus: false,
3380
- editorProps: {
3381
- attributes: {
3382
- class:
3383
- 'prose prose-invert max-w-none min-h-[220px] rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 text-zinc-100 outline-none',
3384
- },
3385
- handleDrop: (_view: unknown, event: DragEvent) => {
3386
- const file = event.dataTransfer?.files?.[0];
3387
- if (!file || !file.type.startsWith('image/') || !assets?.onAssetUpload) return false;
3388
- event.preventDefault();
3389
- void (async () => {
3390
- try {
3391
- await uploadImage(file);
3392
- } catch (err) {
3393
- console.error('[tiptap] upload failed on drop', err);
3394
- }
3395
- })();
3396
- return true;
3397
- },
3398
- handlePaste: (_view: unknown, event: ClipboardEvent) => {
3399
- const file = Array.from(event.clipboardData?.files ?? []).find((f: File) =>
3400
- f.type.startsWith('image/')
3401
- );
3402
- if (!file || !assets?.onAssetUpload) return false;
3403
- event.preventDefault();
3404
- void (async () => {
3405
- try {
3406
- await uploadImage(file);
3407
- } catch (err) {
3408
- console.error('[tiptap] upload failed on paste', err);
3409
- }
3410
- })();
3411
- return true;
3412
- },
3413
- handleClickOn: (_view: unknown, _pos: number, node: { type: { name: string }; attrs?: Record<string, unknown> }) => {
3414
- if (node.type.name !== 'image') return false;
3415
- if (node.attrs?.awaitingUpload !== true) return false;
3416
- const uploadId = typeof node.attrs?.uploadId === 'string' ? node.attrs.uploadId : null;
3417
- if (!uploadId) return false;
3418
- pendingPickerUploadIdRef.current = uploadId;
3419
- fileInputRef.current?.click();
3420
- return true;
3421
- },
3422
- },
3423
- onUpdate: ({ editor: currentEditor }: { editor: Editor }) => {
3424
- const markdown = getEditorMarkdown(currentEditor);
3425
- emitInlineUpdate(markdown);
3610
+ // ── Stable editorProps via refs (avoids stale closures in useEditor) ─────
3611
+ // Reads refs at call-time so useEditor never needs to rebuild the editor.
3612
+
3613
+ const uploadFileRef = React.useRef(uploadFile);
3614
+ uploadFileRef.current = uploadFile;
3615
+ const assetsRef = React.useRef(assets);
3616
+ assetsRef.current = assets;
3617
+
3618
+ const editorProps = React.useMemo(
3619
+ () => ({
3620
+ attributes: { class: EDITOR_CLASSES },
3621
+ handleDrop: (_v: unknown, event: DragEvent) => {
3622
+ const file = event.dataTransfer?.files?.[0];
3623
+ if (!file?.type.startsWith('image/') || !assetsRef.current?.onAssetUpload) return false;
3624
+ event.preventDefault();
3625
+ void uploadFileRef.current(file).catch((e) =>
3626
+ console.error('[tiptap] drop upload failed', e)
3627
+ );
3628
+ return true;
3426
3629
  },
3427
- onFocus: () => {
3428
- setFocusLock(true);
3630
+ handlePaste: (_v: unknown, event: ClipboardEvent) => {
3631
+ const file = Array.from(event.clipboardData?.files ?? []).find((f: File) =>
3632
+ f.type.startsWith('image/')
3633
+ );
3634
+ if (!file || !assetsRef.current?.onAssetUpload) return false;
3635
+ event.preventDefault();
3636
+ void uploadFileRef.current(file).catch((e) =>
3637
+ console.error('[tiptap] paste upload failed', e)
3638
+ );
3639
+ return true;
3429
3640
  },
3430
- onBlur: ({ editor: currentEditor }: { editor: Editor }) => {
3431
- const markdown = getEditorMarkdown(currentEditor);
3432
- if (markdown !== latestEmittedMarkdownRef.current) {
3433
- emitInlineUpdate(markdown);
3434
- }
3435
- setFocusLock(false);
3641
+ handleClickOn: (
3642
+ _v: unknown,
3643
+ _p: number,
3644
+ node: { type: { name: string }; attrs?: Record<string, unknown> }
3645
+ ) => {
3646
+ if (node.type.name !== 'image' || node.attrs?.awaitingUpload !== true) return false;
3647
+ const uploadId =
3648
+ typeof node.attrs?.uploadId === 'string' ? node.attrs.uploadId : null;
3649
+ if (!uploadId) return false;
3650
+ pendingPickerId.current = uploadId;
3651
+ fileInputRef.current?.click();
3652
+ return true;
3436
3653
  },
3437
- }
3654
+ }),
3655
+ [] // intentionally empty — reads refs at call-time
3438
3656
  );
3439
3657
 
3658
+ // ── useEditor ─────────────────────────────────────────────────────────────
3659
+
3660
+ const emitRef = React.useRef(emit);
3661
+ emitRef.current = emit;
3662
+
3663
+ const editor = useEditor({
3664
+ extensions: EXTENSIONS,
3665
+ content: data.content ?? '',
3666
+ autofocus: false,
3667
+ editorProps,
3668
+ onUpdate: ({ editor: e }: { editor: Editor }) => emitRef.current(getMarkdown(e)),
3669
+ onFocus: () => setFocusLock(true),
3670
+ onBlur: ({ editor: e }: { editor: Editor }) => {
3671
+ const md = getMarkdown(e);
3672
+ if (md !== emittedMd.current) emitRef.current(md);
3673
+ setFocusLock(false);
3674
+ },
3675
+ });
3676
+
3677
+ // ── Effects ───────────────────────────────────────────────────────────────
3678
+
3440
3679
  React.useEffect(() => {
3441
- const sectionEl = editorHostRef.current?.closest('[data-section-id]') as HTMLElement | null;
3442
- rootRef.current = sectionEl;
3680
+ sectionRef.current =
3681
+ hostRef.current?.closest('[data-section-id]') as HTMLElement | null;
3443
3682
  }, []);
3444
3683
 
3445
3684
  React.useEffect(() => {
3446
- editorInstanceRef.current = editor ?? null;
3685
+ editorRef.current = editor ?? null;
3447
3686
  }, [editor]);
3448
3687
 
3688
+ // Sync external content changes into editor (e.g. engine-level undo)
3689
+ React.useEffect(() => {
3690
+ if (!editor) return;
3691
+ const next = data.content ?? '';
3692
+ if (next === latestMd.current) return;
3693
+ editor.commands.setContent(next);
3694
+ latestMd.current = next;
3695
+ }, [data.content, editor]);
3696
+
3697
+ // PreviewEntry receives REQUEST_INLINE_FLUSH via postMessage and re-dispatches
3698
+ // it as a DOM CustomEvent. Listen to the DOM event — do NOT send INLINE_FLUSHED
3699
+ // back (PreviewEntry already handles that acknowledgement).
3449
3700
  React.useEffect(() => {
3450
- const handleInlineFlushRequest = () => {
3701
+ const handler = () => {
3451
3702
  void (async () => {
3452
- const pending = Array.from(pendingUploadsRef.current.values());
3453
- if (pending.length > 0) {
3454
- await Promise.allSettled(pending);
3703
+ if (pendingUploads.current.size > 0) {
3704
+ await Promise.allSettled(Array.from(pendingUploads.current.values()));
3455
3705
  }
3456
- const markdown = getEditorMarkdown(editorInstanceRef.current);
3457
- emitInlineUpdate(markdown);
3706
+ emitRef.current(getMarkdown(editorRef.current));
3458
3707
  })();
3459
3708
  };
3460
- window.addEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handleInlineFlushRequest as EventListener);
3461
- return () => {
3462
- window.removeEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handleInlineFlushRequest as EventListener);
3463
- };
3464
- }, [emitInlineUpdate]);
3709
+ window.addEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handler);
3710
+ return () => window.removeEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handler);
3711
+ }, []);
3465
3712
 
3713
+ // File input cancel: modern browsers fire a 'cancel' event when user
3714
+ // closes the picker without selecting a file.
3466
3715
  React.useEffect(() => {
3467
- if (!editor) return;
3468
- const nextMarkdown = data.content ?? '';
3469
- if (nextMarkdown === latestMarkdownRef.current) return;
3470
- editor.commands.setContent(nextMarkdown);
3471
- latestMarkdownRef.current = nextMarkdown;
3472
- }, [data.content, editor]);
3716
+ const input = fileInputRef.current;
3717
+ if (!input) return;
3718
+ const onCancel = () => {
3719
+ const pickId = pendingPickerId.current;
3720
+ if (pickId && editorRef.current) {
3721
+ patchImage(editorRef.current, pickId, {
3722
+ uploading: false,
3723
+ awaitingUpload: false,
3724
+ uploadError: true,
3725
+ });
3726
+ }
3727
+ pendingPickerId.current = null;
3728
+ };
3729
+ input.addEventListener('cancel', onCancel);
3730
+ return () => input.removeEventListener('cancel', onCancel);
3731
+ }, []);
3473
3732
 
3733
+ // Emit on unmount (safety flush)
3474
3734
  React.useEffect(
3475
3735
  () => () => {
3476
- const editorInstance = editorInstanceRef.current;
3477
- const markdown = getEditorMarkdown(editorInstance);
3478
- if (markdown !== latestEmittedMarkdownRef.current) {
3479
- emitInlineUpdate(markdown);
3480
- }
3736
+ const md = getMarkdown(editorRef.current);
3737
+ if (md !== emittedMd.current) emitRef.current(md);
3481
3738
  setFocusLock(false);
3482
3739
  },
3483
- [emitInlineUpdate, setFocusLock]
3740
+ [setFocusLock]
3484
3741
  );
3485
3742
 
3486
- const setParagraph = () => editor?.chain().focus().setParagraph().run();
3487
- const setHeading = (level: 1 | 2 | 3) => editor?.chain().focus().toggleHeading({ level }).run();
3488
- const setLink = () => {
3743
+ // Focus link input when popover opens
3744
+ React.useEffect(() => {
3745
+ if (linkOpen) {
3746
+ const t = setTimeout(() => linkInputRef.current?.focus(), 0);
3747
+ return () => clearTimeout(t);
3748
+ }
3749
+ }, [linkOpen]);
3750
+
3751
+ // ── Toolbar actions ───────────────────────────────────────────────────────
3752
+
3753
+ const openLink = () => {
3489
3754
  if (!editor) return;
3490
3755
  const prev = editor.getAttributes('link').href as string | undefined;
3491
- const href = window.prompt('URL', prev ?? 'https://');
3492
- if (href == null) return;
3493
- if (href.trim() === '') {
3756
+ setLinkUrl(prev ?? 'https://');
3757
+ setLinkOpen(true);
3758
+ };
3759
+
3760
+ const applyLink = () => {
3761
+ if (!editor) return;
3762
+ const href = linkUrl.trim();
3763
+ if (href === '' || href === 'https://') {
3494
3764
  editor.chain().focus().unsetLink().run();
3495
- return;
3765
+ } else {
3766
+ editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
3496
3767
  }
3497
- editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
3768
+ setLinkOpen(false);
3498
3769
  };
3499
- const clearFormatting = () => {
3500
- editor?.chain().focus().unsetAllMarks().clearNodes().run();
3501
- };
3502
- const onPickImageClick = () => {
3503
- if (pendingPickerUploadIdRef.current) return;
3504
- const uploadId = crypto.randomUUID();
3505
- pendingPickerUploadIdRef.current = uploadId;
3506
- insertUploadPlaceholder(uploadId, UPLOAD_PICKER_PLACEHOLDER_SRC, true);
3507
- };
3508
- const onFileSelected = (event: React.ChangeEvent<HTMLInputElement>) => {
3509
- const file = event.target.files?.[0];
3510
- const pendingUploadId = pendingPickerUploadIdRef.current;
3511
- if (!file || !file.type.startsWith('image/') || !assets?.onAssetUpload) {
3512
- event.target.value = '';
3770
+
3771
+ const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
3772
+ const file = e.target.files?.[0];
3773
+ const pickId = pendingPickerId.current;
3774
+ e.target.value = '';
3775
+
3776
+ if (!file?.type.startsWith('image/') || !assets?.onAssetUpload) {
3777
+ // File picker opened but no valid file selected — clean up placeholder
3778
+ if (pickId && editorRef.current) {
3779
+ patchImage(editorRef.current, pickId, {
3780
+ uploading: false,
3781
+ awaitingUpload: false,
3782
+ uploadError: true,
3783
+ });
3784
+ }
3785
+ pendingPickerId.current = null;
3513
3786
  return;
3514
3787
  }
3788
+
3515
3789
  void (async () => {
3516
3790
  try {
3517
- if (pendingUploadId) {
3518
- await startUploadForId(pendingUploadId, file);
3519
- pendingPickerUploadIdRef.current = null;
3791
+ if (pickId) {
3792
+ await doUpload(pickId, file);
3793
+ pendingPickerId.current = null;
3520
3794
  } else {
3521
- await uploadImage(file);
3795
+ await uploadFile(file);
3522
3796
  }
3523
3797
  } catch (err) {
3524
- console.error('[tiptap] upload failed from picker', err);
3525
- } finally {
3526
- event.target.value = '';
3798
+ console.error('[tiptap] picker upload failed', err);
3799
+ pendingPickerId.current = null;
3527
3800
  }
3528
3801
  })();
3529
3802
  };
3530
- const isActive = (name: string, attrs?: Record<string, unknown>) => editor?.isActive(name, attrs) ?? false;
3803
+
3804
+ const onPickImage = () => {
3805
+ if (pendingPickerId.current) return;
3806
+ const id = crypto.randomUUID();
3807
+ pendingPickerId.current = id;
3808
+ insertPlaceholder(id, PICKER_SRC, true);
3809
+ };
3810
+
3811
+ const isActive = (name: string, attrs?: Record<string, unknown>) =>
3812
+ editor?.isActive(name, attrs) ?? false;
3813
+
3814
+ // ── Render ────────────────────────────────────────────────────────────────
3531
3815
 
3532
3816
  return (
3533
- <div
3534
- ref={editorHostRef}
3535
- data-jp-field="content"
3536
- className="space-y-3"
3537
- >
3817
+ <div ref={hostRef} data-jp-field="content" className="space-y-2">
3538
3818
  {editor && (
3539
3819
  <div
3540
3820
  data-jp-ignore-select="true"
3541
- className="sticky top-2 z-[65] rounded-md border border-zinc-700 bg-zinc-950/95 p-2 shadow-lg backdrop-blur"
3821
+ className="sticky top-0 z-[65] border-b border-zinc-800 bg-zinc-950"
3542
3822
  >
3543
- <div className="flex flex-wrap items-center gap-1.5">
3544
- <ToolbarButton title="Undo" onClick={() => editor.chain().focus().undo().run()}>
3545
- <Undo2 size={14} />
3546
- </ToolbarButton>
3547
- <ToolbarButton title="Redo" onClick={() => editor.chain().focus().redo().run()}>
3548
- <Redo2 size={14} />
3549
- </ToolbarButton>
3550
- <ToolbarSeparator />
3551
-
3552
- <ToolbarButton active={isActive('paragraph')} title="Paragraph" onClick={setParagraph}>
3823
+ {/* ── Main toolbar ── */}
3824
+ <div className="flex flex-wrap items-center justify-center gap-1 p-2">
3825
+ {/* History */}
3826
+ <Btn title="Undo" onClick={() => editor.chain().focus().undo().run()}>
3827
+ <Undo2 size={13} />
3828
+ </Btn>
3829
+ <Btn title="Redo" onClick={() => editor.chain().focus().redo().run()}>
3830
+ <Redo2 size={13} />
3831
+ </Btn>
3832
+ <Sep />
3833
+
3834
+ {/* Block type */}
3835
+ <Btn
3836
+ active={isActive('paragraph')}
3837
+ title="Paragraph"
3838
+ onClick={() => editor.chain().focus().setParagraph().run()}
3839
+ >
3553
3840
  P
3554
- </ToolbarButton>
3555
- <ToolbarButton active={isActive('heading', { level: 1 })} title="Heading 1" onClick={() => setHeading(1)}>
3841
+ </Btn>
3842
+ <Btn
3843
+ active={isActive('heading', { level: 1 })}
3844
+ title="Heading 1"
3845
+ onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
3846
+ >
3556
3847
  H1
3557
- </ToolbarButton>
3558
- <ToolbarButton active={isActive('heading', { level: 2 })} title="Heading 2" onClick={() => setHeading(2)}>
3848
+ </Btn>
3849
+ <Btn
3850
+ active={isActive('heading', { level: 2 })}
3851
+ title="Heading 2"
3852
+ onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
3853
+ >
3559
3854
  H2
3560
- </ToolbarButton>
3561
- <ToolbarButton active={isActive('heading', { level: 3 })} title="Heading 3" onClick={() => setHeading(3)}>
3855
+ </Btn>
3856
+ <Btn
3857
+ active={isActive('heading', { level: 3 })}
3858
+ title="Heading 3"
3859
+ onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
3860
+ >
3562
3861
  H3
3563
- </ToolbarButton>
3564
- <ToolbarButton title="Headings" onClick={() => setHeading(2)}>
3565
- <Heading size={14} />
3566
- </ToolbarButton>
3567
- <ToolbarSeparator />
3568
-
3569
- <ToolbarButton active={isActive('bold')} title="Bold" onClick={() => editor.chain().focus().toggleBold().run()}>
3570
- <Bold size={14} />
3571
- </ToolbarButton>
3572
- <ToolbarButton active={isActive('italic')} title="Italic" onClick={() => editor.chain().focus().toggleItalic().run()}>
3573
- <Italic size={14} />
3574
- </ToolbarButton>
3575
- <ToolbarButton active={isActive('underline')} title="Underline" onClick={() => editor.chain().focus().toggleUnderline().run()}>
3576
- <UnderlineIcon size={14} />
3577
- </ToolbarButton>
3578
- <ToolbarButton active={isActive('strike')} title="Strike" onClick={() => editor.chain().focus().toggleStrike().run()}>
3579
- <Strikethrough size={14} />
3580
- </ToolbarButton>
3581
- <ToolbarButton active={isActive('code')} title="Inline code" onClick={() => editor.chain().focus().toggleCode().run()}>
3582
- <Code2 size={14} />
3583
- </ToolbarButton>
3584
- <ToolbarSeparator />
3585
-
3586
- <ToolbarButton active={isActive('bulletList')} title="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}>
3587
- <List size={14} />
3588
- </ToolbarButton>
3589
- <ToolbarButton active={isActive('orderedList')} title="Ordered list" onClick={() => editor.chain().focus().toggleOrderedList().run()}>
3590
- <ListOrdered size={14} />
3591
- </ToolbarButton>
3592
- <ToolbarButton active={isActive('blockquote')} title="Blockquote" onClick={() => editor.chain().focus().toggleBlockquote().run()}>
3593
- <Quote size={14} />
3594
- </ToolbarButton>
3595
- <ToolbarButton active={isActive('codeBlock')} title="Code block" onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
3596
- <SquareCode size={14} />
3597
- </ToolbarButton>
3598
- <ToolbarSeparator />
3599
-
3600
- <ToolbarButton active={isActive('link')} title="Set link" onClick={setLink}>
3601
- <Link2 size={14} />
3602
- </ToolbarButton>
3603
- <ToolbarButton title="Remove link" onClick={() => editor.chain().focus().unsetLink().run()}>
3604
- <Unlink2 size={14} />
3605
- </ToolbarButton>
3606
- <ToolbarButton title="Add image" onClick={onPickImageClick}>
3607
- <ImagePlus size={14} />
3608
- </ToolbarButton>
3609
- <ToolbarButton title="Clear formatting" onClick={clearFormatting}>
3610
- <Eraser size={14} />
3611
- </ToolbarButton>
3862
+ </Btn>
3863
+ <Sep />
3864
+
3865
+ {/* Inline marks */}
3866
+ <Btn
3867
+ active={isActive('bold')}
3868
+ title="Bold (Ctrl+B)"
3869
+ onClick={() => editor.chain().focus().toggleBold().run()}
3870
+ >
3871
+ <Bold size={13} />
3872
+ </Btn>
3873
+ <Btn
3874
+ active={isActive('italic')}
3875
+ title="Italic (Ctrl+I)"
3876
+ onClick={() => editor.chain().focus().toggleItalic().run()}
3877
+ >
3878
+ <Italic size={13} />
3879
+ </Btn>
3880
+ <Btn
3881
+ active={isActive('strike')}
3882
+ title="Strikethrough"
3883
+ onClick={() => editor.chain().focus().toggleStrike().run()}
3884
+ >
3885
+ <Strikethrough size={13} />
3886
+ </Btn>
3887
+ <Btn
3888
+ active={isActive('code')}
3889
+ title="Inline code"
3890
+ onClick={() => editor.chain().focus().toggleCode().run()}
3891
+ >
3892
+ <Code2 size={13} />
3893
+ </Btn>
3894
+ <Sep />
3895
+
3896
+ {/* Lists & block nodes */}
3897
+ <Btn
3898
+ active={isActive('bulletList')}
3899
+ title="Bullet list"
3900
+ onClick={() => editor.chain().focus().toggleBulletList().run()}
3901
+ >
3902
+ <List size={13} />
3903
+ </Btn>
3904
+ <Btn
3905
+ active={isActive('orderedList')}
3906
+ title="Ordered list"
3907
+ onClick={() => editor.chain().focus().toggleOrderedList().run()}
3908
+ >
3909
+ <ListOrdered size={13} />
3910
+ </Btn>
3911
+ <Btn
3912
+ active={isActive('blockquote')}
3913
+ title="Blockquote"
3914
+ onClick={() => editor.chain().focus().toggleBlockquote().run()}
3915
+ >
3916
+ <Quote size={13} />
3917
+ </Btn>
3918
+ <Btn
3919
+ active={isActive('codeBlock')}
3920
+ title="Code block"
3921
+ onClick={() => editor.chain().focus().toggleCodeBlock().run()}
3922
+ >
3923
+ <SquareCode size={13} />
3924
+ </Btn>
3925
+ <Sep />
3926
+
3927
+ {/* Link / image / clear */}
3928
+ <Btn
3929
+ active={isActive('link') || linkOpen}
3930
+ title="Set link"
3931
+ onClick={openLink}
3932
+ >
3933
+ <Link2 size={13} />
3934
+ </Btn>
3935
+ <Btn
3936
+ title="Remove link"
3937
+ onClick={() => editor.chain().focus().unsetLink().run()}
3938
+ >
3939
+ <Unlink2 size={13} />
3940
+ </Btn>
3941
+ <Btn title="Insert image" onClick={onPickImage}>
3942
+ <ImagePlus size={13} />
3943
+ </Btn>
3944
+ <Btn
3945
+ title="Clear formatting"
3946
+ onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
3947
+ >
3948
+ <Eraser size={13} />
3949
+ </Btn>
3612
3950
  </div>
3613
- <input
3614
- ref={fileInputRef}
3615
- type="file"
3616
- accept="image/*"
3617
- className="hidden"
3618
- onChange={onFileSelected}
3619
- />
3951
+
3952
+ {/* ── Link popover row (replaces window.prompt) ── */}
3953
+ {linkOpen && (
3954
+ <div className="flex items-center gap-2 border-t border-zinc-700 px-2 py-1.5">
3955
+ <Link2 size={12} className="shrink-0 text-zinc-500" />
3956
+ <input
3957
+ ref={linkInputRef}
3958
+ type="url"
3959
+ value={linkUrl}
3960
+ onChange={(e) => setLinkUrl(e.target.value)}
3961
+ onKeyDown={(e) => {
3962
+ if (e.key === 'Enter') {
3963
+ e.preventDefault();
3964
+ applyLink();
3965
+ }
3966
+ if (e.key === 'Escape') setLinkOpen(false);
3967
+ }}
3968
+ placeholder="https://example.com"
3969
+ className="min-w-0 flex-1 bg-transparent text-xs text-zinc-100 placeholder:text-zinc-500 outline-none"
3970
+ />
3971
+ <button
3972
+ type="button"
3973
+ onMouseDown={(e) => e.preventDefault()}
3974
+ onClick={applyLink}
3975
+ className="shrink-0 rounded px-2 py-0.5 text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors"
3976
+ >
3977
+ Set
3978
+ </button>
3979
+ <button
3980
+ type="button"
3981
+ onMouseDown={(e) => e.preventDefault()}
3982
+ onClick={() => setLinkOpen(false)}
3983
+ className="shrink-0 rounded px-2 py-0.5 text-xs bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
3984
+ >
3985
+ Cancel
3986
+ </button>
3987
+ </div>
3988
+ )}
3620
3989
  </div>
3621
3990
  )}
3991
+
3622
3992
  <EditorContent editor={editor} className="jp-simple-editor" />
3993
+
3994
+ <input
3995
+ ref={fileInputRef}
3996
+ type="file"
3997
+ accept="image/*"
3998
+ className="hidden"
3999
+ onChange={onFileSelected}
4000
+ />
3623
4001
  </div>
3624
4002
  );
3625
4003
  };
3626
4004
 
4005
+ // ── Public view ───────────────────────────────────────────────────────────────
4006
+
3627
4007
  const PublicTiptapContent: React.FC<{ content: string }> = ({ content }) => (
3628
- <article className="prose max-w-none prose-zinc" data-jp-field="content">
4008
+ <article className="jp-tiptap-content" data-jp-field="content">
3629
4009
  <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
3630
4010
  {content}
3631
4011
  </ReactMarkdown>
3632
4012
  </article>
3633
4013
  );
3634
4014
 
4015
+ // ── Export ────────────────────────────────────────────────────────────────────
4016
+
3635
4017
  export const Tiptap: React.FC<{ data: TiptapData; settings?: TiptapSettings }> = ({ data }) => {
3636
4018
  const { mode } = useStudio();
3637
- const isStudio = mode === 'studio';
3638
-
3639
4019
  return (
3640
- <section
3641
- style={
3642
- {
3643
- '--local-bg': 'var(--background)',
3644
- '--local-surface': 'var(--card)',
3645
- '--local-text': 'var(--foreground)',
3646
- '--local-border': 'var(--border)',
3647
- } as React.CSSProperties
3648
- }
3649
- className="py-12 bg-[var(--local-bg)]"
3650
- >
3651
- <div className="container mx-auto px-6 max-w-4xl">
3652
- {isStudio ? <StudioTiptapEditor data={data} /> : <PublicTiptapContent content={data.content ?? ''} />}
4020
+ <section className="w-full py-12">
4021
+ <div className="container mx-auto px-6 max-w-3xl">
4022
+ {mode === 'studio' ? (
4023
+ <StudioTiptapEditor data={data} />
4024
+ ) : (
4025
+ <PublicTiptapContent content={data.content ?? ''} />
4026
+ )}
3653
4027
  </div>
3654
4028
  </section>
3655
4029
  );
@@ -4256,7 +4630,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/menu.json"
4256
4630
  ]
4257
4631
  }
4258
4632
  END_OF_FILE_CONTENT
4259
- # SKIP: src/data/config/menu.json:Zone.Identifier è un file binario e non può essere convertito in testo.
4260
4633
  echo "Creating src/data/config/site.json..."
4261
4634
  cat << 'END_OF_FILE_CONTENT' > "src/data/config/site.json"
4262
4635
  {
@@ -4344,7 +4717,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/site.json"
4344
4717
  }
4345
4718
  }
4346
4719
  END_OF_FILE_CONTENT
4347
- # SKIP: src/data/config/site.json:Zone.Identifier è un file binario e non può essere convertito in testo.
4348
4720
  echo "Creating src/data/config/theme.json..."
4349
4721
  cat << 'END_OF_FILE_CONTENT' > "src/data/config/theme.json"
4350
4722
  {
@@ -4376,7 +4748,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/theme.json"
4376
4748
  }
4377
4749
  }
4378
4750
  END_OF_FILE_CONTENT
4379
- # SKIP: src/data/config/theme.json:Zone.Identifier è un file binario e non può essere convertito in testo.
4380
4751
  mkdir -p "src/data/pages"
4381
4752
  echo "Creating src/data/pages/docs.json..."
4382
4753
  cat << 'END_OF_FILE_CONTENT' > "src/data/pages/docs.json"
@@ -4969,7 +5340,6 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/home.json"
4969
5340
  ]
4970
5341
  }
4971
5342
  END_OF_FILE_CONTENT
4972
- # SKIP: src/data/pages/home.json:Zone.Identifier è un file binario e non può essere convertito in testo.
4973
5343
  echo "Creating src/data/pages/post.json..."
4974
5344
  cat << 'END_OF_FILE_CONTENT' > "src/data/pages/post.json"
4975
5345
  {
@@ -4984,7 +5354,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/post.json"
4984
5354
  "id": "post-editorial-main",
4985
5355
  "type": "tiptap",
4986
5356
  "data": {
4987
- "content": "![Screenshot 2026-03-05 at 14-45-10 JsonPages.png](/assets/images/1772721027290-Screenshot_2026-03-05_at_14-45-10_JsonPages.png \"Screenshot 2026-03-05 at 14-45-10 JsonPages.png\")## qefqfqfqf**header / tiptap / footer** smoke flow.\n\nInline **editing** in Studio\n\n## `Markdown persistence on Save`\n\n- Safe render on public route"
5357
+ "content": "# JsonPages Cloud – Terms of Service & EULA\n\n---\n\n### **Last Updated:** March 2026\n\n### 1. THE SERVICE\n\nJsonPages provides a hybrid content management infrastructure consisting of:\n\n- **The Core:** An open-source library (@jsonpages/core) governed by the **MIT License**.\n- **The Cloud:** A proprietary SaaS platform (`cloud.jsonpages.io`) that provides the \"Git Bridge,\" Asset Pipeline, and Managed Infrastructure.\n\nBy using the Cloud Service, you agree to these terms.\n\n### 2. DATA SOVEREIGNTY & OWNERSHIP\n\n- **Your Content:** All data (JSON files), code, and assets managed through JsonPages remain your exclusive property. JsonPages acts only as an **orchestrator**.\n- **The Bridge:** You grant JsonPages the necessary permissions to perform Git operations (commits/pushes) on your behalf to your designated repositories (GitHub/GitLab).\n- **Portability:** Since your content is stored as flat JSON files in your own repository, you retain the right to migrate away from the Cloud Service at any time without data lock-in.\n- \n\n### 3. SUBSCRIPTIONS & ENTITLEMENTS\n\n- **Billing:** The Cloud Service is billed on a subscription basis (**Monthly Recurring Revenue**).\n- **Entitlements:** Each \"Project\" or \"Tenant\" consumes one entitlement. Active entitlements grant access to the Visual Studio (ICE) and the Cloud Save API.\n- **Third-Party Costs:** You are solely responsible for any costs incurred on third-party platforms (e.g., **Vercel** hosting, **GitHub** storage, **Cloudflare** workers).\n\n### 4. ACCEPTABLE USE\n\nYou may not use JsonPages Cloud to:\n\n- Host or manage illegal, harmful, or offensive content.\n- Attempt to reverse-engineer the proprietary Cloud Bridge or bypass entitlement checks.\n- Interfere with the stability of the API for other users.\n- \n\n### 5. LIMITATION OF LIABILITY\n\n- **\"As-Is\" Basis:** The service is provided \"as-is.\" While we strive for 99.9% uptime, JsonPages is not liable for data loss resulting from Git conflicts, third-party outages (Vercel/GitHub), or user error.\n- **No Warranty:** We do not warrant that the service will be error-free or uninterrupted.\n- \n\n### 6. TERMINATION\n\n- **By You:** You can cancel your subscription at any time. Your Studio access will remain active until the end of the current billing cycle.\n- \n- **By Us:** We reserve the right to suspend accounts that violate these terms or fail to settle outstanding invoices.\n\n### 7. GOVERNING LAW\n\nThese terms are governed by the laws of **Italy/European Union**, without regard to conflict of law principles."
4988
5358
  },
4989
5359
  "settings": {}
4990
5360
  }
@@ -4994,35 +5364,35 @@ END_OF_FILE_CONTENT
4994
5364
  mkdir -p "src/data/pages/servizi"
4995
5365
  echo "Creating src/data/pages/servizi/trattamento.json..."
4996
5366
  cat << 'END_OF_FILE_CONTENT' > "src/data/pages/servizi/trattamento.json"
4997
- {
4998
- "id": "servizi-trattamento-page",
4999
- "slug": "servizi/trattamento",
5000
- "meta": {
5001
- "title": "Servizi - Trattamento",
5002
- "description": "Pagina nested di smoke test per verificare routing visitor/admin/preview."
5003
- },
5004
- "sections": [
5005
- {
5006
- "id": "hero-servizi-trattamento",
5007
- "type": "hero",
5008
- "data": {
5009
- "badge": "Smoke Test",
5010
- "title": "Trattamento",
5011
- "titleHighlight": "Pagina Nested",
5012
- "description": "Questa pagina verifica il supporto ai nested slug su filesystem e router.",
5013
- "ctas": [
5014
- {
5015
- "id": "cta-home",
5016
- "label": "Torna Home",
5017
- "href": "/",
5018
- "variant": "primary"
5019
- }
5020
- ]
5021
- },
5022
- "settings": {}
5023
- }
5024
- ]
5025
- }
5367
+ {
5368
+ "id": "servizi-trattamento-page",
5369
+ "slug": "servizi/trattamento",
5370
+ "meta": {
5371
+ "title": "Servizi - Trattamento",
5372
+ "description": "Pagina nested di smoke test per verificare routing visitor/admin/preview."
5373
+ },
5374
+ "sections": [
5375
+ {
5376
+ "id": "hero-servizi-trattamento",
5377
+ "type": "hero",
5378
+ "data": {
5379
+ "badge": "Smoke Test",
5380
+ "title": "Trattamento",
5381
+ "titleHighlight": "Pagina Nested",
5382
+ "description": "Questa pagina verifica il supporto ai nested slug su filesystem e router.",
5383
+ "ctas": [
5384
+ {
5385
+ "id": "cta-home",
5386
+ "label": "Torna Home",
5387
+ "href": "/",
5388
+ "variant": "primary"
5389
+ }
5390
+ ]
5391
+ },
5392
+ "settings": {}
5393
+ }
5394
+ ]
5395
+ }
5026
5396
 
5027
5397
  END_OF_FILE_CONTENT
5028
5398
  echo "Creating src/data/pages/servizi_trattamento.json..."
@@ -5056,6 +5426,317 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/servizi_trattamento.json"
5056
5426
  }
5057
5427
  ]
5058
5428
  }
5429
+ END_OF_FILE_CONTENT
5430
+ mkdir -p "src/emails"
5431
+ echo "Creating src/emails/LeadNotificationEmail.tsx..."
5432
+ cat << 'END_OF_FILE_CONTENT' > "src/emails/LeadNotificationEmail.tsx"
5433
+ import {
5434
+ Body,
5435
+ Button,
5436
+ Container,
5437
+ Head,
5438
+ Heading,
5439
+ Hr,
5440
+ Html,
5441
+ Img,
5442
+ Preview,
5443
+ Section,
5444
+ Text,
5445
+ } from "@react-email/components";
5446
+
5447
+ type LeadData = Record<string, unknown>;
5448
+
5449
+ type EmailTheme = {
5450
+ colors?: {
5451
+ primary?: string;
5452
+ secondary?: string;
5453
+ accent?: string;
5454
+ background?: string;
5455
+ surface?: string;
5456
+ surfaceAlt?: string;
5457
+ text?: string;
5458
+ textMuted?: string;
5459
+ border?: string;
5460
+ };
5461
+ typography?: {
5462
+ fontFamily?: {
5463
+ primary?: string;
5464
+ display?: string;
5465
+ mono?: string;
5466
+ };
5467
+ };
5468
+ borderRadius?: {
5469
+ sm?: string;
5470
+ md?: string;
5471
+ lg?: string;
5472
+ xl?: string;
5473
+ };
5474
+ };
5475
+
5476
+ export type LeadNotificationEmailProps = {
5477
+ tenantName: string;
5478
+ correlationId: string;
5479
+ replyTo?: string | null;
5480
+ leadData: LeadData;
5481
+ brandName?: string;
5482
+ logoUrl?: string;
5483
+ logoAlt?: string;
5484
+ tagline?: string;
5485
+ theme?: EmailTheme;
5486
+ };
5487
+
5488
+ function safeString(value: unknown): string {
5489
+ if (value == null) return "-";
5490
+ if (typeof value === "string") {
5491
+ const trimmed = value.trim();
5492
+ return trimmed || "-";
5493
+ }
5494
+ return JSON.stringify(value);
5495
+ }
5496
+
5497
+ function flattenLeadData(data: LeadData) {
5498
+ return Object.entries(data)
5499
+ .filter(([key]) => !key.startsWith("_"))
5500
+ .slice(0, 20)
5501
+ .map(([key, value]) => ({ label: key, value: safeString(value) }));
5502
+ }
5503
+
5504
+ export function LeadNotificationEmail({
5505
+ tenantName,
5506
+ correlationId,
5507
+ replyTo,
5508
+ leadData,
5509
+ brandName,
5510
+ logoUrl,
5511
+ logoAlt,
5512
+ tagline,
5513
+ theme,
5514
+ }: LeadNotificationEmailProps) {
5515
+ const fields = flattenLeadData(leadData);
5516
+ const brandLabel = brandName || tenantName;
5517
+
5518
+ const colors = {
5519
+ primary: theme?.colors?.primary || "#2D5016",
5520
+ background: theme?.colors?.background || "#FAFAF5",
5521
+ surface: theme?.colors?.surface || "#FFFFFF",
5522
+ text: theme?.colors?.text || "#1C1C14",
5523
+ textMuted: theme?.colors?.textMuted || "#5A5A4A",
5524
+ border: theme?.colors?.border || "#D8D5C5",
5525
+ };
5526
+
5527
+ const fonts = {
5528
+ primary: theme?.typography?.fontFamily?.primary || "Inter, Arial, sans-serif",
5529
+ display: theme?.typography?.fontFamily?.display || "Georgia, serif",
5530
+ };
5531
+
5532
+ const radius = {
5533
+ md: theme?.borderRadius?.md || "10px",
5534
+ lg: theme?.borderRadius?.lg || "16px",
5535
+ };
5536
+
5537
+ return (
5538
+ <Html>
5539
+ <Head />
5540
+ <Preview>Nuovo lead ricevuto da {brandLabel}</Preview>
5541
+ <Body style={{ backgroundColor: colors.background, color: colors.text, fontFamily: fonts.primary, padding: "24px" }}>
5542
+ <Container style={{ backgroundColor: colors.surface, border: `1px solid ${colors.border}`, borderRadius: radius.lg, padding: "24px" }}>
5543
+ <Section>
5544
+ {logoUrl ? <Img src={logoUrl} alt={logoAlt || brandLabel} height="44" style={{ marginBottom: "8px" }} /> : null}
5545
+ <Text style={{ color: colors.text, fontSize: "18px", fontWeight: 700, margin: "0 0 6px 0" }}>{brandLabel}</Text>
5546
+ <Text style={{ color: colors.textMuted, marginTop: "0", marginBottom: "0" }}>{tagline || "Notifica automatica lead"}</Text>
5547
+ </Section>
5548
+
5549
+ <Hr style={{ borderColor: colors.border, margin: "20px 0" }} />
5550
+
5551
+ <Heading as="h2" style={{ color: colors.text, margin: "0 0 12px 0", fontSize: "22px", fontFamily: fonts.display }}>
5552
+ Nuovo lead da {tenantName}
5553
+ </Heading>
5554
+ <Text style={{ color: colors.textMuted, marginTop: "0", marginBottom: "16px" }}>Correlation ID: {correlationId}</Text>
5555
+
5556
+ <Section style={{ border: `1px solid ${colors.border}`, borderRadius: radius.md, padding: "12px" }}>
5557
+ {fields.length === 0 ? (
5558
+ <Text style={{ color: colors.textMuted, margin: 0 }}>Nessun campo lead disponibile.</Text>
5559
+ ) : (
5560
+ fields.map((field) => (
5561
+ <Text key={field.label} style={{ margin: "0 0 8px 0", color: colors.text, fontSize: "14px", wordBreak: "break-word" }}>
5562
+ <strong>{field.label}:</strong> {field.value}
5563
+ </Text>
5564
+ ))
5565
+ )}
5566
+ </Section>
5567
+
5568
+ <Section style={{ marginTop: "18px" }}>
5569
+ <Button
5570
+ href={replyTo ? `mailto:${replyTo}` : "mailto:"}
5571
+ style={{
5572
+ backgroundColor: colors.primary,
5573
+ color: "#ffffff",
5574
+ borderRadius: radius.md,
5575
+ textDecoration: "none",
5576
+ padding: "12px 18px",
5577
+ fontWeight: 600,
5578
+ }}
5579
+ >
5580
+ Rispondi ora
5581
+ </Button>
5582
+ </Section>
5583
+ </Container>
5584
+ </Body>
5585
+ </Html>
5586
+ );
5587
+ }
5588
+
5589
+ export default LeadNotificationEmail;
5590
+
5591
+ END_OF_FILE_CONTENT
5592
+ echo "Creating src/emails/LeadSenderConfirmationEmail.tsx..."
5593
+ cat << 'END_OF_FILE_CONTENT' > "src/emails/LeadSenderConfirmationEmail.tsx"
5594
+ import {
5595
+ Body,
5596
+ Container,
5597
+ Head,
5598
+ Heading,
5599
+ Hr,
5600
+ Html,
5601
+ Img,
5602
+ Preview,
5603
+ Section,
5604
+ Text,
5605
+ } from "@react-email/components";
5606
+
5607
+ type LeadData = Record<string, unknown>;
5608
+
5609
+ type EmailTheme = {
5610
+ colors?: {
5611
+ primary?: string;
5612
+ secondary?: string;
5613
+ accent?: string;
5614
+ background?: string;
5615
+ surface?: string;
5616
+ surfaceAlt?: string;
5617
+ text?: string;
5618
+ textMuted?: string;
5619
+ border?: string;
5620
+ };
5621
+ typography?: {
5622
+ fontFamily?: {
5623
+ primary?: string;
5624
+ display?: string;
5625
+ mono?: string;
5626
+ };
5627
+ };
5628
+ borderRadius?: {
5629
+ sm?: string;
5630
+ md?: string;
5631
+ lg?: string;
5632
+ xl?: string;
5633
+ };
5634
+ };
5635
+
5636
+ export type LeadSenderConfirmationEmailProps = {
5637
+ tenantName: string;
5638
+ correlationId: string;
5639
+ leadData: LeadData;
5640
+ brandName?: string;
5641
+ logoUrl?: string;
5642
+ logoAlt?: string;
5643
+ tagline?: string;
5644
+ theme?: EmailTheme;
5645
+ };
5646
+
5647
+ function safeString(value: unknown): string {
5648
+ if (value == null) return "-";
5649
+ if (typeof value === "string") {
5650
+ const trimmed = value.trim();
5651
+ return trimmed || "-";
5652
+ }
5653
+ return JSON.stringify(value);
5654
+ }
5655
+
5656
+ function flattenLeadData(data: LeadData) {
5657
+ const skipKeys = new Set(["recipientEmail", "tenant", "source", "submittedAt", "email_confirm"]);
5658
+ return Object.entries(data)
5659
+ .filter(([key]) => !key.startsWith("_") && !skipKeys.has(key))
5660
+ .slice(0, 12)
5661
+ .map(([key, value]) => ({ label: key, value: safeString(value) }));
5662
+ }
5663
+
5664
+ export function LeadSenderConfirmationEmail({
5665
+ tenantName,
5666
+ correlationId,
5667
+ leadData,
5668
+ brandName,
5669
+ logoUrl,
5670
+ logoAlt,
5671
+ tagline,
5672
+ theme,
5673
+ }: LeadSenderConfirmationEmailProps) {
5674
+ const fields = flattenLeadData(leadData);
5675
+ const brandLabel = brandName || tenantName;
5676
+
5677
+ const colors = {
5678
+ primary: theme?.colors?.primary || "#2D5016",
5679
+ background: theme?.colors?.background || "#FAFAF5",
5680
+ surface: theme?.colors?.surface || "#FFFFFF",
5681
+ text: theme?.colors?.text || "#1C1C14",
5682
+ textMuted: theme?.colors?.textMuted || "#5A5A4A",
5683
+ border: theme?.colors?.border || "#D8D5C5",
5684
+ };
5685
+
5686
+ const fonts = {
5687
+ primary: theme?.typography?.fontFamily?.primary || "Inter, Arial, sans-serif",
5688
+ display: theme?.typography?.fontFamily?.display || "Georgia, serif",
5689
+ };
5690
+
5691
+ const radius = {
5692
+ md: theme?.borderRadius?.md || "10px",
5693
+ lg: theme?.borderRadius?.lg || "16px",
5694
+ };
5695
+
5696
+ return (
5697
+ <Html>
5698
+ <Head />
5699
+ <Preview>Conferma invio richiesta - {brandLabel}</Preview>
5700
+ <Body style={{ backgroundColor: colors.background, color: colors.background, fontFamily: fonts.primary, padding: "24px" }}>
5701
+ <Container style={{ backgroundColor: colors.primary, color: colors.background, border: `1px solid ${colors.border}`, borderRadius: radius.lg, padding: "24px" }}>
5702
+ <Section>
5703
+ {logoUrl ? <Img src={logoUrl} alt={logoAlt || brandLabel} height="44" style={{ marginBottom: "8px" }} /> : null}
5704
+ <Text style={{ color: colors.background, fontSize: "18px", fontWeight: 700, margin: "0 0 6px 0" }}>{brandLabel}</Text>
5705
+ <Text style={{ color: colors.background, marginTop: "0", marginBottom: "0" }}>{tagline || "Conferma automatica di ricezione"}</Text>
5706
+ </Section>
5707
+
5708
+ <Hr style={{ borderColor: colors.border, margin: "20px 0" }} />
5709
+
5710
+ <Heading as="h2" style={{ color: colors.background, margin: "0 0 12px 0", fontSize: "22px", fontFamily: fonts.display }}>
5711
+ Richiesta ricevuta
5712
+ </Heading>
5713
+ <Text style={{ color: colors.background, marginTop: "0", marginBottom: "16px" }}>
5714
+ Grazie, abbiamo ricevuto la tua richiesta per {tenantName}. Ti risponderemo il prima possibile.
5715
+ </Text>
5716
+
5717
+ <Section style={{ border: `1px solid ${colors.border}`, borderRadius: radius.md, padding: "12px" }}>
5718
+ <Text style={{ margin: "0 0 8px 0", color: colors.background, fontWeight: 600 }}>Riepilogo inviato</Text>
5719
+ {fields.length === 0 ? (
5720
+ <Text style={{ color: colors.background, margin: 0 }}>Nessun dettaglio disponibile.</Text>
5721
+ ) : (
5722
+ fields.map((field) => (
5723
+ <Text key={field.label} style={{ margin: "0 0 8px 0", color: colors.background, fontSize: "14px", wordBreak: "break-word" }}>
5724
+ <strong>{field.label}:</strong> {field.value}
5725
+ </Text>
5726
+ ))
5727
+ )}
5728
+ </Section>
5729
+
5730
+ <Hr style={{ borderColor: colors.border, margin: "20px 0 12px 0" }} />
5731
+ <Text style={{ color: colors.background, fontSize: "12px", margin: 0 }}>Riferimento richiesta: {correlationId}</Text>
5732
+ </Container>
5733
+ </Body>
5734
+ </Html>
5735
+ );
5736
+ }
5737
+
5738
+ export default LeadSenderConfirmationEmail;
5739
+
5059
5740
  END_OF_FILE_CONTENT
5060
5741
  echo "Creating src/fonts.css..."
5061
5742
  cat << 'END_OF_FILE_CONTENT' > "src/fonts.css"
@@ -5275,9 +5956,120 @@ html {
5275
5956
  pointer-events: none;
5276
5957
  }
5277
5958
 
5278
- /* Tiptap simple-editor-like behavior in Studio */
5279
- .jp-simple-editor .ProseMirror:focus {
5959
+ /* ==========================================================================
5960
+ TIPTAP — Public content typography (visitor view)
5961
+ ReactMarkdown renders plain HTML; preflight resets it. Re-apply here.
5962
+ ========================================================================== */
5963
+ .jp-tiptap-content > * + * { margin-top: 0.75em; }
5964
+
5965
+ .jp-tiptap-content h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
5966
+ .jp-tiptap-content h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
5967
+ .jp-tiptap-content h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
5968
+ .jp-tiptap-content h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
5969
+
5970
+ .jp-tiptap-content p { line-height: 1.7; }
5971
+
5972
+ .jp-tiptap-content strong { font-weight: 700; }
5973
+ .jp-tiptap-content em { font-style: italic; }
5974
+ .jp-tiptap-content s { text-decoration: line-through; }
5975
+
5976
+ .jp-tiptap-content a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
5977
+ .jp-tiptap-content a:hover { opacity: 0.8; }
5978
+
5979
+ .jp-tiptap-content code {
5980
+ font-family: var(--font-mono, ui-monospace, monospace);
5981
+ font-size: 0.875em;
5982
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
5983
+ border-radius: 0.25em;
5984
+ padding: 0.1em 0.35em;
5985
+ }
5986
+ .jp-tiptap-content pre {
5987
+ background: color-mix(in oklch, var(--background) 60%, black);
5988
+ border-radius: 0.5em;
5989
+ padding: 1em 1.25em;
5990
+ overflow-x: auto;
5991
+ }
5992
+ .jp-tiptap-content pre code { background: none; padding: 0; }
5993
+
5994
+ .jp-tiptap-content ul { list-style-type: disc; padding-left: 1.625em; }
5995
+ .jp-tiptap-content ol { list-style-type: decimal; padding-left: 1.625em; }
5996
+ .jp-tiptap-content li { line-height: 1.7; margin-top: 0.25em; }
5997
+ .jp-tiptap-content li + li { margin-top: 0.25em; }
5998
+
5999
+ .jp-tiptap-content blockquote {
6000
+ border-left: 3px solid var(--border);
6001
+ padding-left: 1em;
6002
+ color: var(--muted-foreground);
6003
+ font-style: italic;
6004
+ }
6005
+
6006
+ .jp-tiptap-content hr {
6007
+ border: none;
6008
+ border-top: 1px solid var(--border);
6009
+ margin: 1.5em 0;
6010
+ }
6011
+
6012
+ .jp-tiptap-content img { max-width: 100%; height: auto; border-radius: 0.5rem; }
6013
+
6014
+ /* ==========================================================================
6015
+ TIPTAP / PROSEMIRROR — Editor typography
6016
+ Tailwind preflight resets all heading/list styles. Re-apply here using
6017
+ tenant theme tokens so the editor is WYSIWYG.
6018
+ ========================================================================== */
6019
+ .jp-simple-editor .ProseMirror {
5280
6020
  outline: none;
6021
+ word-break: break-word;
6022
+ }
6023
+ .jp-simple-editor .ProseMirror > * + * { margin-top: 0.75em; }
6024
+
6025
+ .jp-simple-editor .ProseMirror h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
6026
+ .jp-simple-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
6027
+ .jp-simple-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
6028
+ .jp-simple-editor .ProseMirror h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
6029
+
6030
+ .jp-simple-editor .ProseMirror p { line-height: 1.7; }
6031
+
6032
+ .jp-simple-editor .ProseMirror strong { font-weight: 700; }
6033
+ .jp-simple-editor .ProseMirror em { font-style: italic; }
6034
+ .jp-simple-editor .ProseMirror s { text-decoration: line-through; }
6035
+
6036
+ .jp-simple-editor .ProseMirror a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
6037
+ .jp-simple-editor .ProseMirror a:hover { opacity: 0.8; }
6038
+
6039
+ .jp-simple-editor .ProseMirror code {
6040
+ font-family: var(--font-mono, ui-monospace, monospace);
6041
+ font-size: 0.875em;
6042
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
6043
+ border-radius: 0.25em;
6044
+ padding: 0.1em 0.35em;
6045
+ }
6046
+ .jp-simple-editor .ProseMirror pre {
6047
+ background: color-mix(in oklch, var(--background) 60%, black);
6048
+ border-radius: 0.5em;
6049
+ padding: 1em 1.25em;
6050
+ overflow-x: auto;
6051
+ }
6052
+ .jp-simple-editor .ProseMirror pre code {
6053
+ background: none;
6054
+ padding: 0;
6055
+ }
6056
+
6057
+ .jp-simple-editor .ProseMirror ul { list-style-type: disc; padding-left: 1.625em; }
6058
+ .jp-simple-editor .ProseMirror ol { list-style-type: decimal; padding-left: 1.625em; }
6059
+ .jp-simple-editor .ProseMirror li { line-height: 1.7; margin-top: 0.25em; }
6060
+ .jp-simple-editor .ProseMirror li + li { margin-top: 0.25em; }
6061
+
6062
+ .jp-simple-editor .ProseMirror blockquote {
6063
+ border-left: 3px solid var(--border);
6064
+ padding-left: 1em;
6065
+ color: var(--muted-foreground);
6066
+ font-style: italic;
6067
+ }
6068
+
6069
+ .jp-simple-editor .ProseMirror hr {
6070
+ border: none;
6071
+ border-top: 1px solid var(--border);
6072
+ margin: 1.5em 0;
5281
6073
  }
5282
6074
 
5283
6075
  .jp-simple-editor .ProseMirror img {
@@ -5298,6 +6090,16 @@ html {
5298
6090
  outline-offset: 2px;
5299
6091
  }
5300
6092
 
6093
+ /* Placeholder when editor is empty */
6094
+ .jp-simple-editor .ProseMirror p.is-editor-empty:first-child::before {
6095
+ content: attr(data-placeholder);
6096
+ color: var(--muted-foreground);
6097
+ opacity: 0.5;
6098
+ pointer-events: none;
6099
+ float: left;
6100
+ height: 0;
6101
+ }
6102
+
5301
6103
 
5302
6104
 
5303
6105
  END_OF_FILE_CONTENT
@@ -5353,7 +6155,6 @@ export const ComponentRegistry: {
5353
6155
  };
5354
6156
 
5355
6157
  END_OF_FILE_CONTENT
5356
- # SKIP: src/lib/ComponentRegistry.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
5357
6158
  echo "Creating src/lib/IconResolver.tsx..."
5358
6159
  cat << 'END_OF_FILE_CONTENT' > "src/lib/IconResolver.tsx"
5359
6160
  import React from 'react';
@@ -5412,7 +6213,6 @@ export const Icon: React.FC<IconProps> = ({ name, size = 20, className }) => {
5412
6213
 
5413
6214
 
5414
6215
  END_OF_FILE_CONTENT
5415
- # SKIP: src/lib/IconResolver.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
5416
6216
  echo "Creating src/lib/addSectionConfig.ts..."
5417
6217
  cat << 'END_OF_FILE_CONTENT' > "src/lib/addSectionConfig.ts"
5418
6218
  import type { AddSectionConfig } from '@jsonpages/core';
@@ -5474,7 +6274,6 @@ export const addSectionConfig: AddSectionConfig = {
5474
6274
  };
5475
6275
 
5476
6276
  END_OF_FILE_CONTENT
5477
- # SKIP: src/lib/addSectionConfig.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
5478
6277
  echo "Creating src/lib/base-schemas.ts..."
5479
6278
  cat << 'END_OF_FILE_CONTENT' > "src/lib/base-schemas.ts"
5480
6279
  import { z } from 'zod';
@@ -5517,11 +6316,6 @@ export const CtaSchema = z.object({
5517
6316
  });
5518
6317
 
5519
6318
  END_OF_FILE_CONTENT
5520
- # SKIP: src/lib/base-schemas.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
5521
- # SKIP: src/lib/cloudSaveStream.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
5522
- # SKIP: src/lib/cloudSaveStream.ts:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
5523
- # SKIP: src/lib/deploySteps.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
5524
- # SKIP: src/lib/deploySteps.ts:Zone.Identifier:Zone.Identifier è un file binario e non può essere convertito in testo.
5525
6319
  echo "Creating src/lib/draftStorage.ts..."
5526
6320
  cat << 'END_OF_FILE_CONTENT' > "src/lib/draftStorage.ts"
5527
6321
  /**
@@ -5550,7 +6344,6 @@ export function getHydratedData(
5550
6344
  }
5551
6345
 
5552
6346
  END_OF_FILE_CONTENT
5553
- # SKIP: src/lib/draftStorage.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
5554
6347
  echo "Creating src/lib/getFilePages.ts..."
5555
6348
  cat << 'END_OF_FILE_CONTENT' > "src/lib/getFilePages.ts"
5556
6349
  /**
@@ -5599,7 +6392,6 @@ export function getFilePages(): Record<string, PageConfig> {
5599
6392
  }
5600
6393
 
5601
6394
  END_OF_FILE_CONTENT
5602
- # SKIP: src/lib/getFilePages.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
5603
6395
  echo "Creating src/lib/schemas.ts..."
5604
6396
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
5605
6397
  export { BaseSectionData, BaseArrayItem, BaseSectionSettingsSchema, CtaSchema } from './base-schemas';
@@ -5649,7 +6441,95 @@ export const SECTION_SCHEMAS = {
5649
6441
  export type SectionType = keyof typeof SECTION_SCHEMAS;
5650
6442
 
5651
6443
  END_OF_FILE_CONTENT
5652
- # SKIP: src/lib/schemas.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
6444
+ echo "Creating src/lib/useFormSubmit.ts..."
6445
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/useFormSubmit.ts"
6446
+ import { useState, useCallback } from 'react';
6447
+
6448
+ export type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error';
6449
+
6450
+ interface UseFormSubmitOptions {
6451
+ source: string;
6452
+ tenantId: string;
6453
+ }
6454
+
6455
+ export function useFormSubmit({ source, tenantId }: UseFormSubmitOptions) {
6456
+ const [status, setStatus] = useState<SubmitStatus>('idle');
6457
+ const [message, setMessage] = useState<string>('');
6458
+
6459
+ const submit = useCallback(async (
6460
+ formData: FormData,
6461
+ recipientEmail: string,
6462
+ pageSlug: string,
6463
+ sectionId: string
6464
+ ) => {
6465
+ const cloudApiUrl = import.meta.env.VITE_JSONPAGES_CLOUD_URL as string | undefined;
6466
+ const cloudApiKey = import.meta.env.VITE_JSONPAGES_API_KEY as string | undefined;
6467
+
6468
+ if (!cloudApiUrl || !cloudApiKey) {
6469
+ setStatus('error');
6470
+ setMessage('Configurazione API non disponibile. Riprova tra poco.');
6471
+ return false;
6472
+ }
6473
+
6474
+ // Trasformiamo FormData in un oggetto piatto per il payload JSON
6475
+ const data: Record<string, any> = {};
6476
+ formData.forEach((value, key) => {
6477
+ data[key] = String(value).trim();
6478
+ });
6479
+
6480
+ const payload = {
6481
+ ...data,
6482
+ recipientEmail,
6483
+ page: pageSlug,
6484
+ section: sectionId,
6485
+ tenant: tenantId,
6486
+ source: source,
6487
+ submittedAt: new Date().toISOString(),
6488
+ };
6489
+
6490
+ // Idempotency Key per evitare doppi invii accidentali
6491
+ const idempotencyKey = `form-${sectionId}-${Date.now()}`;
6492
+
6493
+ setStatus('submitting');
6494
+ setMessage('Invio in corso...');
6495
+
6496
+ try {
6497
+ const apiBase = cloudApiUrl.replace(/\/$/, '');
6498
+ const response = await fetch(`${apiBase}/forms/submit`, {
6499
+ method: 'POST',
6500
+ headers: {
6501
+ Authorization: `Bearer ${cloudApiKey}`,
6502
+ 'Content-Type': 'application/json',
6503
+ 'Idempotency-Key': idempotencyKey,
6504
+ },
6505
+ body: JSON.stringify(payload),
6506
+ });
6507
+
6508
+ const body = (await response.json().catch(() => ({}))) as { error?: string; code?: string };
6509
+
6510
+ if (!response.ok) {
6511
+ throw new Error(body.error || body.code || `Submit failed (${response.status})`);
6512
+ }
6513
+
6514
+ setStatus('success');
6515
+ setMessage('Richiesta inviata con successo. Ti risponderemo al più presto.');
6516
+ return true;
6517
+ } catch (error: unknown) {
6518
+ const errorMsg = error instanceof Error ? error.message : 'Invio non riuscito. Riprova tra poco.';
6519
+ setStatus('error');
6520
+ setMessage(errorMsg);
6521
+ return false;
6522
+ }
6523
+ }, [source, tenantId]);
6524
+
6525
+ const reset = useCallback(() => {
6526
+ setStatus('idle');
6527
+ setMessage('');
6528
+ }, []);
6529
+
6530
+ return { submit, status, message, reset };
6531
+ }
6532
+ END_OF_FILE_CONTENT
5653
6533
  echo "Creating src/lib/utils.ts..."
5654
6534
  cat << 'END_OF_FILE_CONTENT' > "src/lib/utils.ts"
5655
6535
  import { clsx, type ClassValue } from 'clsx';
@@ -5660,7 +6540,6 @@ export function cn(...inputs: ClassValue[]) {
5660
6540
  }
5661
6541
 
5662
6542
  END_OF_FILE_CONTENT
5663
- # SKIP: src/lib/utils.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
5664
6543
  echo "Creating src/main.tsx..."
5665
6544
  cat << 'END_OF_FILE_CONTENT' > "src/main.tsx"
5666
6545
  import '@/types'; // TBP: load type augmentation from capsule-driven types
@@ -5679,7 +6558,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
5679
6558
 
5680
6559
 
5681
6560
  END_OF_FILE_CONTENT
5682
- # SKIP: src/main.tsx:Zone.Identifier è un file binario e non può essere convertito in testo.
5683
6561
  echo "Creating src/main_.tsx..."
5684
6562
  cat << 'END_OF_FILE_CONTENT' > "src/main_.tsx"
5685
6563
  import '@/types'; // TBP: load type augmentation from capsule-driven types
@@ -5794,7 +6672,6 @@ declare module '@jsonpages/core' {
5794
6672
  export * from '@jsonpages/core';
5795
6673
 
5796
6674
  END_OF_FILE_CONTENT
5797
- # SKIP: src/types/deploy.ts:Zone.Identifier è un file binario e non può essere convertito in testo.
5798
6675
  echo "Creating src/vite-env.d.ts..."
5799
6676
  cat << 'END_OF_FILE_CONTENT' > "src/vite-env.d.ts"
5800
6677
  /// <reference types="vite/client" />