Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a733d147fb | ||
|
|
60ba274275 | ||
|
|
d64a8bd89a | ||
|
|
4d9e2c78e6 | ||
|
|
e08b1e0f2f | ||
|
|
e55d846883 |
21
App.tsx
21
App.tsx
@@ -1,9 +1,8 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import TemplateList from './components/TemplateList';
|
import TemplateList from './components/TemplateList';
|
||||||
import TemplateEditor from './components/TemplateEditor';
|
import TemplateEditor from './components/TemplateEditor';
|
||||||
import { ViewState, EmailTemplate, SQL_SCHEMA } from './types';
|
import { ViewState, EmailTemplate, SQL_SCHEMA } from './types';
|
||||||
import { getTemplates, deleteTemplate, generateUUID } from './services/storage';
|
import { getTemplates, deleteTemplate } from './services/storage';
|
||||||
import { Database } from 'lucide-react';
|
import { Database } from 'lucide-react';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
@@ -19,14 +18,9 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
const refreshTemplates = async () => {
|
const refreshTemplates = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
|
||||||
const data = await getTemplates();
|
const data = await getTemplates();
|
||||||
setTemplates(data);
|
setTemplates(data);
|
||||||
} catch (err) {
|
|
||||||
console.error("Refresh Error:", err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
@@ -40,9 +34,10 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClone = (t: EmailTemplate) => {
|
const handleClone = (t: EmailTemplate) => {
|
||||||
|
// Create a copy of the template with a new ID and updated name
|
||||||
const clonedTemplate: EmailTemplate = {
|
const clonedTemplate: EmailTemplate = {
|
||||||
...t,
|
...t,
|
||||||
id: generateUUID(),
|
id: crypto.randomUUID(), // Generate new ID so it's treated as a new insert
|
||||||
name: `${t.name} (Copia)`,
|
name: `${t.name} (Copia)`,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
@@ -69,7 +64,7 @@ const App: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-screen items-center justify-center text-slate-500">
|
<div className="flex h-screen items-center justify-center text-slate-500">
|
||||||
Caricamento...
|
Caricamento template...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TemplateList
|
<TemplateList
|
||||||
@@ -100,13 +95,17 @@ const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Database Schema Modal (Global Help) */}
|
||||||
{showSchema && (
|
{showSchema && (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-6">
|
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-6">
|
||||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
<Database className="text-brand-600"/>
|
<Database className="text-brand-600"/>
|
||||||
Configurazione DB
|
Configurazione Database per n8n
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="text-slate-600 text-sm mb-4">
|
||||||
|
Per far funzionare questa app con il tuo software interno, crea questa tabella nel tuo database MySQL o PostgreSQL.
|
||||||
|
</p>
|
||||||
<div className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto custom-scrollbar mb-4">
|
<div className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto custom-scrollbar mb-4">
|
||||||
<pre className="text-xs font-mono leading-relaxed">{SQL_SCHEMA}</pre>
|
<pre className="text-xs font-mono leading-relaxed">{SQL_SCHEMA}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +113,7 @@ const App: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(SQL_SCHEMA);
|
navigator.clipboard.writeText(SQL_SCHEMA);
|
||||||
alert("Copiato!");
|
alert("Schema copiato!");
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 bg-slate-100 text-slate-700 font-medium rounded hover:bg-slate-200"
|
className="px-4 py-2 bg-slate-100 text-slate-700 font-medium rounded hover:bg-slate-200"
|
||||||
>
|
>
|
||||||
|
|||||||
29
Dockerfile
29
Dockerfile
@@ -3,41 +3,44 @@ FROM node:20-bookworm AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copiamo il package.json. Non usiamo npm ci perché richiede obbligatoriamente il package-lock.json
|
# Copy ONLY package.json
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|
||||||
# Installiamo tutte le dipendenze necessarie per il build (incluse le devDependencies)
|
# Install dependencies
|
||||||
|
# --legacy-peer-deps: Ignores peer dependency conflicts
|
||||||
|
# --no-audit: Skips vulnerability audit (faster, less noise)
|
||||||
|
# --no-fund: Hides funding messages
|
||||||
RUN npm install --legacy-peer-deps --no-audit --no-fund
|
RUN npm install --legacy-peer-deps --no-audit --no-fund
|
||||||
|
|
||||||
# Copiamo il resto del codice sorgente
|
# Copy the rest of the application source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Eseguiamo il build del frontend (genera la cartella /dist)
|
# Build the frontend assets
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Setup del Server di Produzione
|
# Stage 2: Setup the Production Server
|
||||||
FROM node:20-bookworm-slim
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copiamo il package.json per installare le dipendenze di runtime
|
# Copy ONLY package.json
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|
||||||
# Installiamo SOLO le dipendenze di produzione per mantenere l'immagine leggera
|
# Install ONLY production dependencies
|
||||||
RUN npm install --omit=dev --legacy-peer-deps --no-audit --no-fund
|
RUN npm install --omit=dev --legacy-peer-deps --no-audit --no-fund
|
||||||
|
|
||||||
# Copiamo i file compilati (dist) dallo stage builder
|
# Copy the built frontend assets from the 'builder' stage
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Copiamo il file del server Node.js
|
# Copy the server entry point
|
||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
|
|
||||||
# Configurazioni di ambiente
|
# Set environment variables
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# Esponiamo la porta 3000
|
# Expose the port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Avvio dell'applicazione
|
# Start the Node.js server
|
||||||
CMD ["node", "server.js"]
|
CMD ["npm", "start"]
|
||||||
40
README.md
40
README.md
@@ -2,41 +2,19 @@
|
|||||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Email Template Builder
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
Uno strumento visuale per creare, gestire ed esportare template email HTML con placeholder dinamici.
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/drive/1PJrPIeFdvYwt0ImdYe7PMH6YCDGTYQxH
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
|
|
||||||
**Prerequisites:** Node.js
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
1. Install dependencies: `npm install`
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
3. Run the app: `npm run dev`
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
## Deploy with Docker
|
|
||||||
|
|
||||||
This application can be deployed as a complete stack with its own MySQL database:
|
|
||||||
|
|
||||||
1. Set your `GEMINI_API_KEY` in the environment variables
|
|
||||||
2. Run `docker-compose up -d` to start the stack
|
|
||||||
3. Access the application at `http://localhost:3000`
|
|
||||||
|
|
||||||
The stack includes:
|
|
||||||
- Frontend/Backend service (Node.js + React)
|
|
||||||
- MySQL database (with persistent data)
|
|
||||||
- Automatic database initialization
|
|
||||||
|
|
||||||
## Deploy on Portainer
|
|
||||||
|
|
||||||
To deploy on Portainer as a stack:
|
|
||||||
|
|
||||||
1. Create a new stack in Portainer
|
|
||||||
2. Copy the contents of [docker-compose.yml](docker-compose.yml) into the editor
|
|
||||||
3. Add your `API_KEY` environment variable in the Portainer interface
|
|
||||||
4. Deploy the stack
|
|
||||||
|
|
||||||
The application will automatically:
|
|
||||||
- Create and initialize the MySQL database
|
|
||||||
- Start the application service
|
|
||||||
- Connect to the internal database
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { EmailTemplate } from '../types';
|
import { EmailTemplate } from '../types';
|
||||||
import { saveTemplate, generateSQL, generateSelectSQL, getTemplates, generateTemplateKey, generateN8nCode, generateUUID } from '../services/storage';
|
import { saveTemplate, generateSQL, generateSelectSQL, getTemplates, generateTemplateKey, generateN8nCode } from '../services/storage';
|
||||||
import { generateEmailContent } from '../services/geminiService';
|
import { generateEmailContent } from '../services/geminiService';
|
||||||
import RichTextEditor from './RichTextEditor';
|
import RichTextEditor from './RichTextEditor';
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +45,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
const [showAiModal, setShowAiModal] = useState(false);
|
const [showAiModal, setShowAiModal] = useState(false);
|
||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
|
|
||||||
|
// Variable detection logic
|
||||||
const detectVariables = useCallback(() => {
|
const detectVariables = useCallback(() => {
|
||||||
const regex = /\{\{([\w\d_-]+)\}\}/g;
|
const regex = /\{\{([\w\d_-]+)\}\}/g;
|
||||||
const allText = `${subject} ${header} ${body} ${footer}`;
|
const allText = `${subject} ${header} ${body} ${footer}`;
|
||||||
@@ -58,6 +58,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
detectVariables();
|
detectVariables();
|
||||||
}, [detectVariables]);
|
}, [detectVariables]);
|
||||||
|
|
||||||
|
// Clear name error when typing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (nameError) setNameError('');
|
if (nameError) setNameError('');
|
||||||
}, [name]);
|
}, [name]);
|
||||||
@@ -66,35 +67,30 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
const newKey = generateTemplateKey(name);
|
const newKey = generateTemplateKey(name);
|
||||||
|
|
||||||
if (!newKey) {
|
if (!newKey) {
|
||||||
setNameError('Il nome del template non può essere vuoto.');
|
setNameError('Il nome del template non può essere vuoto o contenere solo simboli.');
|
||||||
|
alert('Il nome del template non può essere vuoto.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
console.log("Saving process started for:", name);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Get current templates to check duplicates
|
// Check for duplicates
|
||||||
let allTemplates: EmailTemplate[] = [];
|
const allTemplates = await getTemplates();
|
||||||
try {
|
|
||||||
allTemplates = await getTemplates();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Could not fetch templates for duplicate check, proceeding anyway...", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDuplicate = allTemplates.some(t => {
|
const isDuplicate = allTemplates.some(t => {
|
||||||
|
// Exclude current template if we are editing
|
||||||
if (initialTemplate && t.id === initialTemplate.id) return false;
|
if (initialTemplate && t.id === initialTemplate.id) return false;
|
||||||
return generateTemplateKey(t.name) === newKey;
|
return generateTemplateKey(t.name) === newKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
setNameError('Esiste già un template con questo nome.');
|
setNameError('Esiste già un template con questo nome.');
|
||||||
|
alert('Un template con questo nome (o ID risultante) esiste già. Per favore scegli un nome univoco.');
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTemplate: EmailTemplate = {
|
const newTemplate: EmailTemplate = {
|
||||||
id: initialTemplate?.id || generateUUID(),
|
id: initialTemplate?.id || crypto.randomUUID(),
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
subject,
|
subject,
|
||||||
@@ -104,14 +100,10 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
variables: detectedVars,
|
variables: detectedVars,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Sending POST to server with data:", newTemplate);
|
|
||||||
await saveTemplate(newTemplate);
|
await saveTemplate(newTemplate);
|
||||||
console.log("Save successful!");
|
|
||||||
onSave();
|
onSave();
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
console.error("SAVE ERROR DETAILS:", e);
|
alert("Impossibile salvare il template. Controlla i log del server.");
|
||||||
alert(`Errore di salvataggio: ${e.message}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -166,6 +158,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Maps internal tab keys to Italian display names
|
||||||
const tabNames: Record<string, string> = {
|
const tabNames: Record<string, string> = {
|
||||||
header: 'Testata',
|
header: 'Testata',
|
||||||
body: 'Corpo',
|
body: 'Corpo',
|
||||||
@@ -174,6 +167,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-slate-50">
|
<div className="flex flex-col h-screen bg-slate-50">
|
||||||
|
{/* Top Bar */}
|
||||||
<header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between sticky top-0 z-20 shrink-0">
|
<header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between sticky top-0 z-20 shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button onClick={onBack} className="p-2 hover:bg-slate-100 rounded-full text-slate-500">
|
<button onClick={onBack} className="p-2 hover:bg-slate-100 rounded-full text-slate-500">
|
||||||
@@ -205,8 +199,11 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Left: Inputs */}
|
||||||
<div className="w-1/2 flex flex-col border-r border-slate-200 bg-white overflow-y-auto custom-scrollbar">
|
<div className="w-1/2 flex flex-col border-r border-slate-200 bg-white overflow-y-auto custom-scrollbar">
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Metadata */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-slate-700 mb-1">Nome Template</label>
|
<label className="block text-sm font-semibold text-slate-700 mb-1">Nome Template</label>
|
||||||
@@ -218,7 +215,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
placeholder="es. Email di Benvenuto"
|
placeholder="es. Email di Benvenuto"
|
||||||
/>
|
/>
|
||||||
{nameError && <p className="text-red-500 text-xs mt-1">{nameError}</p>}
|
{nameError && <p className="text-red-500 text-xs mt-1">{nameError}</p>}
|
||||||
<p className="text-xs text-slate-400 mt-1">Deve essere univoco. Chiave DB: <span className="font-mono bg-slate-100 px-1">{generateTemplateKey(name) || '...'}</span></p>
|
<p className="text-xs text-slate-400 mt-1">Deve essere univoco. Usato per generare la chiave DB: <span className="font-mono bg-slate-100 px-1">{generateTemplateKey(name) || '...'}</span></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -227,7 +224,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
value={description}
|
value={description}
|
||||||
onChange={e => setDescription(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white resize-none text-sm"
|
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white resize-none text-sm"
|
||||||
placeholder="Note interne..."
|
placeholder="Note interne (es. Usato per i nuovi iscritti)"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,17 +236,19 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
value={subject}
|
value={subject}
|
||||||
onChange={e => setSubject(e.target.value)}
|
onChange={e => setSubject(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white"
|
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none bg-white"
|
||||||
placeholder="Oggetto..."
|
placeholder="Oggetto... (supporta {{placeholder}})"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Variable Manager */}
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Variabili</span>
|
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Variabili Attive</span>
|
||||||
|
<span className="text-xs text-slate-400">Rilevate automaticamente dal testo</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{detectedVars.length === 0 && <span className="text-sm text-slate-400 italic">Nessuna variabile. Usa {'{{nome}}'}.</span>}
|
{detectedVars.length === 0 && <span className="text-sm text-slate-400 italic">Nessuna variabile rilevata. Scrivi {'{{nome}}'} per aggiungerne una.</span>}
|
||||||
{detectedVars.map(v => (
|
{detectedVars.map(v => (
|
||||||
<span key={v} className="px-2 py-1 bg-brand-100 text-brand-700 text-sm rounded border border-brand-200 font-mono">
|
<span key={v} className="px-2 py-1 bg-brand-100 text-brand-700 text-sm rounded border border-brand-200 font-mono">
|
||||||
{`{{${v}}}`}
|
{`{{${v}}}`}
|
||||||
@@ -258,6 +257,7 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-slate-200">
|
<div className="flex border-b border-slate-200">
|
||||||
{(['header', 'body', 'footer'] as const).map((tab) => (
|
{(['header', 'body', 'footer'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -274,43 +274,59 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Editor Area */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<label className="text-sm font-semibold text-slate-700">Editor</label>
|
<label className="text-sm font-semibold text-slate-700">Editor Contenuti</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAiModal(true)}
|
onClick={() => setShowAiModal(true)}
|
||||||
className="text-xs flex items-center gap-1 text-purple-600 hover:text-purple-700 font-medium bg-purple-50 px-2 py-1 rounded"
|
className="text-xs flex items-center gap-1 text-purple-600 hover:text-purple-700 font-medium bg-purple-50 px-2 py-1 rounded"
|
||||||
>
|
>
|
||||||
<Wand2 size={14} />
|
<Wand2 size={14} />
|
||||||
IA
|
Genera con IA
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="h-[400px]">
|
<div className="h-[400px]">
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
key={activeTab}
|
key={activeTab} // Force remount on tab change to sync contentEditable
|
||||||
value={getActiveContent()}
|
value={getActiveContent()}
|
||||||
onChange={setActiveContent}
|
onChange={setActiveContent}
|
||||||
placeholder={`Scrivi qui...`}
|
placeholder={`Crea qui la sezione ${tabNames[activeTab]}...`}
|
||||||
className="h-full shadow-sm"
|
className="h-full shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
Usa il pulsante "Variabile" nella toolbar per inserire placeholder come {'{{nome}}'}.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Live Preview */}
|
||||||
<div className="w-1/2 bg-slate-100 flex flex-col overflow-hidden">
|
<div className="w-1/2 bg-slate-100 flex flex-col overflow-hidden">
|
||||||
<div className="p-3 bg-white border-b border-slate-200 flex justify-between items-center shadow-sm shrink-0">
|
<div className="p-3 bg-white border-b border-slate-200 flex justify-between items-center shadow-sm z-10 shrink-0">
|
||||||
<span className="font-semibold text-slate-600 flex items-center gap-2">
|
<span className="font-semibold text-slate-600 flex items-center gap-2">
|
||||||
<Eye size={18} /> Anteprima
|
<Eye size={18} /> Anteprima Live
|
||||||
</span>
|
</span>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
Renderizzato come HTML standard
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Scrollable container: flex-1 ensures it takes available space, overflow-y-auto enables scrolling */}
|
||||||
<div className="flex-1 p-8 overflow-y-auto custom-scrollbar">
|
<div className="flex-1 p-8 overflow-y-auto custom-scrollbar">
|
||||||
|
{/* mx-auto centers the card without using flexbox on the parent which can cause scroll issues */}
|
||||||
<div className="w-full max-w-2xl bg-white shadow-xl rounded-lg overflow-hidden min-h-[600px] flex flex-col mx-auto">
|
<div className="w-full max-w-2xl bg-white shadow-xl rounded-lg overflow-hidden min-h-[600px] flex flex-col mx-auto">
|
||||||
|
{/* Simulate Subject Line in Preview */}
|
||||||
<div className="bg-slate-50 border-b border-slate-100 p-4">
|
<div className="bg-slate-50 border-b border-slate-100 p-4">
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase">Oggetto:</span>
|
<span className="text-xs font-bold text-slate-400 uppercase">Oggetto:</span>
|
||||||
<p className="text-sm font-medium text-slate-800">{subject}</p>
|
<p className="text-sm font-medium text-slate-800">{subject.replace(/\{\{([\w\d_-]+)\}\}/g, (match, p1) => `<span class="bg-yellow-100 text-yellow-800 px-1 rounded">${match}</span>`)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email Content */}
|
||||||
<div dangerouslySetInnerHTML={{ __html: header }} />
|
<div dangerouslySetInnerHTML={{ __html: header }} />
|
||||||
<div dangerouslySetInnerHTML={{ __html: body }} className="flex-1" />
|
<div dangerouslySetInnerHTML={{ __html: body }} className="flex-1" />
|
||||||
<div dangerouslySetInnerHTML={{ __html: footer }} />
|
<div dangerouslySetInnerHTML={{ __html: footer }} />
|
||||||
@@ -319,50 +335,122 @@ const Editor: React.FC<Props> = ({ initialTemplate, onBack, onSave }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SQL Modal */}
|
||||||
{showSqlModal && (
|
{showSqlModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl p-6 flex flex-col max-h-[90vh]">
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl p-6 flex flex-col max-h-[90vh]">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||||
<Database size={20} className="text-brand-600"/>
|
<Database size={20} className="text-brand-600"/>
|
||||||
Integrazione
|
Dettagli Integrazione
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => setShowSqlModal(false)} className="text-slate-400 hover:text-slate-600 text-2xl">×</button>
|
<button onClick={() => setShowSqlModal(false)} className="text-slate-400 hover:text-slate-600">
|
||||||
|
<span className="text-2xl">×</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6 overflow-y-auto">
|
|
||||||
|
<div className="space-y-8 overflow-y-auto px-1 pb-4">
|
||||||
|
{/* INSERT Section */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase block mb-1">SQL INSERT/UPDATE</label>
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2 block">1. Setup (Esegui una volta nel DB)</label>
|
||||||
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg text-sm font-mono overflow-x-auto">{currentInsertSql}</pre>
|
<div className="relative">
|
||||||
|
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono custom-scrollbar h-40">
|
||||||
|
{currentInsertSql}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(currentInsertSql)}
|
||||||
|
className="absolute top-2 right-2 p-2 bg-slate-700 text-white rounded hover:bg-slate-600"
|
||||||
|
title="Copia SQL INSERT"
|
||||||
|
>
|
||||||
|
<Copy size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase block mb-1">JS n8n</label>
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2 block">2. Recupero (Nodo SQL n8n)</label>
|
||||||
<pre className="bg-slate-50 border border-slate-200 p-4 rounded-lg text-sm font-mono overflow-x-auto">{currentN8nCode}</pre>
|
<div className="relative">
|
||||||
|
<pre className="bg-slate-800 text-slate-50 p-4 rounded-lg overflow-x-auto text-sm font-mono custom-scrollbar h-40">
|
||||||
|
{currentSelectSql}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(currentSelectSql)}
|
||||||
|
className="absolute top-2 right-2 p-2 bg-slate-600 text-white rounded hover:bg-slate-500"
|
||||||
|
title="Copia SQL SELECT"
|
||||||
|
>
|
||||||
|
<Copy size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* n8n Code Section */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-purple-600 uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||||
|
<Code size={16} />
|
||||||
|
3. Popolamento (Nodo Codice n8n)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="bg-slate-50 border border-slate-200 text-slate-700 p-4 rounded-lg overflow-x-auto text-sm font-mono custom-scrollbar max-h-60">
|
||||||
|
{currentN8nCode}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(currentN8nCode)}
|
||||||
|
className="absolute top-2 right-2 p-2 bg-white border border-slate-200 text-slate-600 rounded hover:bg-slate-50 shadow-sm"
|
||||||
|
title="Copia Codice JS"
|
||||||
|
>
|
||||||
|
<Copy size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
Incolla questo codice in un <strong>Nodo Code</strong> collegato dopo il tuo nodo SQL. Sostituisci <code>REPLACE_WITH_VALUE</code> con le variabili reali (es. <code>$('NodeName').item.json.name</code>).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Modal */}
|
||||||
{showAiModal && (
|
{showAiModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6">
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6">
|
||||||
<h3 className="text-lg font-bold text-slate-800 mb-2">Generatore IA</h3>
|
<h3 className="text-lg font-bold text-slate-800 mb-2 flex items-center gap-2">
|
||||||
|
<Wand2 className="text-purple-600"/>
|
||||||
|
Generatore Contenuti IA
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600 mb-4">
|
||||||
|
Descrivi cosa vuoi per la sezione <strong>{tabNames[activeTab]}</strong>.
|
||||||
|
L'IA genererà il codice HTML con i placeholder necessari.
|
||||||
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-32 border border-slate-300 rounded p-3 text-sm focus:ring-2 focus:ring-purple-500 outline-none mb-4"
|
className="w-full h-32 border border-slate-300 rounded p-3 text-sm focus:ring-2 focus:ring-purple-500 outline-none mb-4"
|
||||||
placeholder="Descrivi cosa generare..."
|
placeholder="es. Scrivi una notifica gentile che avvisi l'utente del cambio password avvenuto con successo. Includi un placeholder per il nome utente."
|
||||||
value={aiPrompt}
|
value={aiPrompt}
|
||||||
onChange={(e) => setAiPrompt(e.target.value)}
|
onChange={(e) => setAiPrompt(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button onClick={() => setShowAiModal(false)} className="px-4 py-2 text-slate-600">Annulla</button>
|
<button
|
||||||
|
onClick={() => setShowAiModal(false)}
|
||||||
|
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleAiGenerate}
|
onClick={handleAiGenerate}
|
||||||
disabled={isGenerating || !aiPrompt}
|
disabled={isGenerating || !aiPrompt}
|
||||||
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 flex items-center gap-2"
|
className={`px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 flex items-center gap-2 ${isGenerating ? 'opacity-70' : ''}`}
|
||||||
>
|
>
|
||||||
{isGenerating ? 'Generazione...' : 'Genera HTML'}
|
{isGenerating ? 'Generazione...' : 'Genera HTML'}
|
||||||
|
{!isGenerating && <Wand2 size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{!process.env.API_KEY && (
|
||||||
|
<p className="mt-3 text-xs text-red-500">
|
||||||
|
Nota: API_KEY non rilevata nell'ambiente. Questa funzione richiede una chiave API Gemini.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,41 +1,20 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
|
||||||
image: mysql:8.0
|
|
||||||
container_name: email_templates_db
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: rootpassword
|
|
||||||
MYSQL_DATABASE: email_templates
|
|
||||||
MYSQL_USER: appuser
|
|
||||||
MYSQL_PASSWORD: apppassword
|
|
||||||
ports:
|
|
||||||
- "3306:3306"
|
|
||||||
volumes:
|
|
||||||
- db_data:/var/lib/mysql
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
|
||||||
timeout: 20s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
container_name: email_templates_app
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
# Database Connection Configuration
|
||||||
DB_USER: appuser
|
# We removed the ':-default' syntax.
|
||||||
DB_PASSWORD: apppassword
|
# Now, the container will strictly use what is provided by the host environment.
|
||||||
DB_NAME: email_templates
|
- DB_TYPE=${DB_TYPE}
|
||||||
DB_PORT: 3306
|
- DB_HOST=${DB_HOST}
|
||||||
DB_TYPE: mysql
|
- DB_PORT=${DB_PORT}
|
||||||
API_KEY: ${API_KEY}
|
- DB_USER=${DB_USER}
|
||||||
depends_on:
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
db:
|
- DB_NAME=${DB_NAME}
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
# Application Keys
|
||||||
db_data:
|
- API_KEY=${API_KEY}
|
||||||
|
|||||||
133
server.js
133
server.js
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
@@ -15,27 +14,12 @@ const __dirname = path.dirname(__filename);
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// 1. CORS deve essere la prima cosa per gestire le richieste OPTIONS dei browser
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.options('*', cors());
|
app.use(express.json());
|
||||||
|
|
||||||
// 2. Logging immediato di ogni richiesta
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
const start = Date.now();
|
|
||||||
res.on('finish', () => {
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} - Status: ${res.statusCode} (${duration}ms)`);
|
|
||||||
});
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Parser JSON con limite aumentato (fondamentale per template HTML grandi)
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
|
||||||
app.use(express.urlencoded({ limit: '10mb', extended: true }));
|
|
||||||
|
|
||||||
// 4. File statici
|
|
||||||
app.use(express.static(path.join(__dirname, 'dist')));
|
app.use(express.static(path.join(__dirname, 'dist')));
|
||||||
|
|
||||||
|
// DB Configuration
|
||||||
|
// Normalize DB_TYPE: allow 'postgres', 'postgresql', 'Postgres', etc.
|
||||||
const rawDbType = (process.env.DB_TYPE || 'mysql').toLowerCase().trim();
|
const rawDbType = (process.env.DB_TYPE || 'mysql').toLowerCase().trim();
|
||||||
const DB_TYPE = (rawDbType === 'postgres' || rawDbType === 'postgresql') ? 'postgres' : 'mysql';
|
const DB_TYPE = (rawDbType === 'postgres' || rawDbType === 'postgresql') ? 'postgres' : 'mysql';
|
||||||
|
|
||||||
@@ -47,48 +31,83 @@ const dbConfig = {
|
|||||||
port: process.env.DB_PORT || (DB_TYPE === 'postgres' ? 5432 : 3306),
|
port: process.env.DB_PORT || (DB_TYPE === 'postgres' ? 5432 : 3306),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('--- App Version: 1.0.2 ---');
|
// Debug Log: Print config to console (masking password)
|
||||||
console.log(`DB Type: ${DB_TYPE}`);
|
console.log('--- Database Configuration ---');
|
||||||
console.log(`DB Host: ${dbConfig.host}`);
|
console.log(`Type: ${DB_TYPE} (Input: ${process.env.DB_TYPE || 'default'})`);
|
||||||
|
console.log(`Host: ${dbConfig.host}`);
|
||||||
|
console.log(`User: ${dbConfig.user}`);
|
||||||
|
console.log(`Database: ${dbConfig.database}`);
|
||||||
|
console.log(`Port: ${dbConfig.port}`);
|
||||||
|
console.log(`Password: ${dbConfig.password ? '******' : '(Not Set)'}`);
|
||||||
|
console.log('------------------------------');
|
||||||
|
|
||||||
let pool;
|
let pool;
|
||||||
|
|
||||||
const initDB = async (retries = 5) => {
|
// Initialize DB Connection
|
||||||
while (retries > 0) {
|
const initDB = async () => {
|
||||||
try {
|
try {
|
||||||
if (!dbConfig.host || !dbConfig.user || !dbConfig.database) {
|
if (!dbConfig.host || !dbConfig.user || !dbConfig.database) {
|
||||||
throw new Error("Missing required database environment variables (DB_HOST, DB_USER, DB_NAME).");
|
throw new Error("Missing required database environment variables (DB_HOST, DB_USER, or DB_NAME).");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DB_TYPE === 'postgres') {
|
if (DB_TYPE === 'postgres') {
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
pool = new Pool(dbConfig);
|
pool = new Pool(dbConfig);
|
||||||
await pool.query('SELECT 1'); // Test connection
|
|
||||||
|
// Create table for Postgres
|
||||||
|
// Using JSONB for variables
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_templates (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
template_key VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
subject VARCHAR(255),
|
||||||
|
header_html TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
footer_html TEXT,
|
||||||
|
full_html TEXT,
|
||||||
|
required_variables JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
} else {
|
} else {
|
||||||
|
// MySQL
|
||||||
pool = mysql.createPool(dbConfig);
|
pool = mysql.createPool(dbConfig);
|
||||||
await pool.query('SELECT 1'); // Test connection
|
|
||||||
|
// Create table for MySQL
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_templates (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
template_key VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
subject VARCHAR(255),
|
||||||
|
header_html TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
footer_html TEXT,
|
||||||
|
full_html TEXT,
|
||||||
|
required_variables JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
console.log(`Connected to ${DB_TYPE} database successfully.`);
|
console.log(`Connected to ${DB_TYPE} database successfully.`);
|
||||||
return;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
retries -= 1;
|
console.error('Database connection failed:', err);
|
||||||
console.error(`Database connection failed (${retries} retries left):`, err.message);
|
process.exit(1);
|
||||||
if (retries === 0) {
|
|
||||||
console.error("FATAL: Could not connect to database.");
|
|
||||||
// Non usciamo per permettere al server di servire i file statici e mostrare errori via API
|
|
||||||
} else {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initDB();
|
initDB();
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
|
||||||
|
// GET All Templates
|
||||||
app.get('/api/templates', async (req, res) => {
|
app.get('/api/templates', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!pool) return res.status(503).json({ error: "Database not connected" });
|
|
||||||
|
|
||||||
let rows;
|
let rows;
|
||||||
if (DB_TYPE === 'postgres') {
|
if (DB_TYPE === 'postgres') {
|
||||||
const result = await pool.query('SELECT * FROM email_templates ORDER BY updated_at DESC');
|
const result = await pool.query('SELECT * FROM email_templates ORDER BY updated_at DESC');
|
||||||
@@ -98,6 +117,7 @@ app.get('/api/templates', async (req, res) => {
|
|||||||
rows = result;
|
rows = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map DB fields back to frontend types
|
||||||
const templates = rows.map(row => ({
|
const templates = rows.map(row => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -106,24 +126,21 @@ app.get('/api/templates', async (req, res) => {
|
|||||||
header: row.header_html,
|
header: row.header_html,
|
||||||
body: row.body_html,
|
body: row.body_html,
|
||||||
footer: row.footer_html,
|
footer: row.footer_html,
|
||||||
variables: typeof row.required_variables === 'string' ? JSON.parse(row.required_variables) : (row.required_variables || []),
|
variables: typeof row.required_variables === 'string' ? JSON.parse(row.required_variables) : row.required_variables,
|
||||||
updatedAt: row.updated_at
|
updatedAt: row.updated_at
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json(templates);
|
res.json(templates);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Fetch Error:", err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Failed to fetch templates', details: err.message });
|
res.status(500).json({ error: 'Failed to fetch templates' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SAVE (Upsert) Template
|
||||||
app.post('/api/templates', async (req, res) => {
|
app.post('/api/templates', async (req, res) => {
|
||||||
const t = req.body;
|
const t = req.body;
|
||||||
|
// Ensure default values to prevent undefined errors in Postgres
|
||||||
if (!t.name || !t.id) {
|
|
||||||
return res.status(400).json({ error: "Name and ID are required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = t.header || '';
|
const header = t.header || '';
|
||||||
const body = t.body || '';
|
const body = t.body || '';
|
||||||
const footer = t.footer || '';
|
const footer = t.footer || '';
|
||||||
@@ -145,9 +162,8 @@ app.post('/api/templates', async (req, res) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!pool) throw new Error("Database not connected");
|
|
||||||
|
|
||||||
if (DB_TYPE === 'postgres') {
|
if (DB_TYPE === 'postgres') {
|
||||||
|
// Postgres requires explicit casting for JSONB parameters when passed as strings ($10::jsonb)
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables, updated_at)
|
INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, NOW())
|
||||||
@@ -166,8 +182,8 @@ app.post('/api/templates', async (req, res) => {
|
|||||||
await pool.query(query, params);
|
await pool.query(query, params);
|
||||||
} else {
|
} else {
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables)
|
INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
template_key = VALUES(template_key),
|
template_key = VALUES(template_key),
|
||||||
name = VALUES(name),
|
name = VALUES(name),
|
||||||
@@ -177,20 +193,22 @@ app.post('/api/templates', async (req, res) => {
|
|||||||
body_html = VALUES(body_html),
|
body_html = VALUES(body_html),
|
||||||
footer_html = VALUES(footer_html),
|
footer_html = VALUES(footer_html),
|
||||||
full_html = VALUES(full_html),
|
full_html = VALUES(full_html),
|
||||||
required_variables = VALUES(required_variables);
|
required_variables = VALUES(required_variables),
|
||||||
|
updated_at = NOW();
|
||||||
`;
|
`;
|
||||||
await pool.query(query, params);
|
await pool.query(query, params);
|
||||||
}
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("DB Save Error:", err.message);
|
console.error("Save Template Error:", err);
|
||||||
res.status(500).json({ error: 'Database save failed', details: err.message });
|
// Return specific error details to help debugging on the client
|
||||||
|
res.status(500).json({ error: 'Failed to save template', details: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DELETE Template
|
||||||
app.delete('/api/templates/:id', async (req, res) => {
|
app.delete('/api/templates/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!pool) return res.status(503).json({ error: "Database not connected" });
|
|
||||||
if (DB_TYPE === 'postgres') {
|
if (DB_TYPE === 'postgres') {
|
||||||
await pool.query('DELETE FROM email_templates WHERE id = $1', [req.params.id]);
|
await pool.query('DELETE FROM email_templates WHERE id = $1', [req.params.id]);
|
||||||
} else {
|
} else {
|
||||||
@@ -198,15 +216,16 @@ app.delete('/api/templates/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Delete Error:", err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Failed to delete template' });
|
res.status(500).json({ error: 'Failed to delete template' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle React Routing
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server running on port ${PORT}`);
|
console.log(`Server running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
|
|
||||||
import { EmailTemplate } from '../types';
|
import { EmailTemplate } from '../types';
|
||||||
|
|
||||||
// Fallback for crypto.randomUUID in non-secure (HTTP) contexts
|
// Helper functions remain synchronous as they are utility functions
|
||||||
export const generateUUID = () => {
|
|
||||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
||||||
try {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback if it exists but fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
||||||
const r = Math.random() * 16 | 0;
|
|
||||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateTemplateKey = (name: string): string => {
|
export const generateTemplateKey = (name: string): string => {
|
||||||
return name.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
|
return name.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
|
||||||
};
|
};
|
||||||
@@ -29,15 +14,10 @@ export const generateSQL = (template: EmailTemplate): string => {
|
|||||||
const subject = template.subject.replace(/'/g, "''");
|
const subject = template.subject.replace(/'/g, "''");
|
||||||
const vars = JSON.stringify(template.variables).replace(/'/g, "''");
|
const vars = JSON.stringify(template.variables).replace(/'/g, "''");
|
||||||
const key = generateTemplateKey(template.name);
|
const key = generateTemplateKey(template.name);
|
||||||
const name = template.name.replace(/'/g, "''");
|
|
||||||
const desc = (template.description || '').replace(/'/g, "''");
|
|
||||||
|
|
||||||
return `INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables)
|
return `INSERT INTO email_templates (id, template_key, name, description, subject, header_html, body_html, footer_html, full_html, required_variables)
|
||||||
VALUES ('${template.id}', '${key}', '${name}', '${desc}', '${subject}', '${header}', '${body}', '${footer}', '${fullHtml}', '${vars}')
|
VALUES ('${template.id}', '${key}', '${template.name.replace(/'/g, "''")}', '${template.description?.replace(/'/g, "''") || ''}', '${subject}', '${header}', '${body}', '${footer}', '${fullHtml}', '${vars}')
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
template_key = VALUES(template_key),
|
|
||||||
name = VALUES(name),
|
|
||||||
description = VALUES(description),
|
|
||||||
subject = VALUES(subject),
|
subject = VALUES(subject),
|
||||||
header_html = VALUES(header_html),
|
header_html = VALUES(header_html),
|
||||||
body_html = VALUES(body_html),
|
body_html = VALUES(body_html),
|
||||||
@@ -56,18 +36,24 @@ export const generateN8nCode = (template: EmailTemplate): string => {
|
|||||||
const hasVars = template.variables.length > 0;
|
const hasVars = template.variables.length > 0;
|
||||||
|
|
||||||
return `// Nodo Code n8n - Popolatore Template
|
return `// Nodo Code n8n - Popolatore Template
|
||||||
|
// 1. Assicurati che il nodo precedente (SQL) restituisca 'full_html' e 'subject'.
|
||||||
|
// 2. Aggiusta il percorso (item.json.full_html) se l'output del tuo nodo SQL è diverso.
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const templateHtml = item.json.full_html;
|
const templateHtml = item.json.full_html;
|
||||||
const templateSubject = item.json.subject;
|
const templateSubject = item.json.subject;
|
||||||
|
|
||||||
|
// Definisci qui i tuoi dati dinamici
|
||||||
const replacements = {
|
const replacements = {
|
||||||
${hasVars ? varsMap : ' // Nessuna variabile rilevata'}
|
${hasVars ? varsMap : ' // Nessuna variabile rilevata in questo template'}
|
||||||
};
|
};
|
||||||
|
|
||||||
let finalHtml = templateHtml;
|
let finalHtml = templateHtml;
|
||||||
let finalSubject = templateSubject;
|
let finalSubject = templateSubject;
|
||||||
|
|
||||||
|
// Esegui sostituzione
|
||||||
for (const [key, value] of Object.entries(replacements)) {
|
for (const [key, value] of Object.entries(replacements)) {
|
||||||
|
// Sostituisce {{key}} globalmente nell'HTML e nell'Oggetto
|
||||||
const regex = new RegExp('{{' + key + '}}', 'g');
|
const regex = new RegExp('{{' + key + '}}', 'g');
|
||||||
finalHtml = finalHtml.replace(regex, value);
|
finalHtml = finalHtml.replace(regex, value);
|
||||||
if (finalSubject) {
|
if (finalSubject) {
|
||||||
@@ -75,24 +61,28 @@ ${hasVars ? varsMap : ' // Nessuna variabile rilevata'}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Output del contenuto processato
|
||||||
item.json.processed_html = finalHtml;
|
item.json.processed_html = finalHtml;
|
||||||
item.json.processed_subject = finalSubject;
|
item.json.processed_subject = finalSubject;
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;`;
|
return items;`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Async API calls to replace synchronous localStorage
|
||||||
export const getTemplates = async (): Promise<EmailTemplate[]> => {
|
export const getTemplates = async (): Promise<EmailTemplate[]> => {
|
||||||
console.log("Fetching templates from /api/templates...");
|
try {
|
||||||
const response = await fetch('/api/templates');
|
const response = await fetch('/api/templates');
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error('Fallito il recupero');
|
||||||
const errorBody = await response.text();
|
|
||||||
throw new Error(`Errore API (${response.status}): ${errorBody || response.statusText}`);
|
|
||||||
}
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fallito il caricamento dei template", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveTemplate = async (template: EmailTemplate): Promise<void> => {
|
export const saveTemplate = async (template: EmailTemplate): Promise<void> => {
|
||||||
console.log("Saving template to /api/templates...", template.name);
|
try {
|
||||||
const response = await fetch('/api/templates', {
|
const response = await fetch('/api/templates', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -102,21 +92,23 @@ export const saveTemplate = async (template: EmailTemplate): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMessage = `Errore HTTP ${response.status}`;
|
const error = await response.json();
|
||||||
try {
|
throw new Error(error.message || 'Salvataggio fallito');
|
||||||
const errorData = await response.json();
|
|
||||||
errorMessage = errorData.details || errorData.error || errorMessage;
|
|
||||||
} catch (e) {
|
|
||||||
const textError = await response.text();
|
|
||||||
if (textError) errorMessage = textError;
|
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
} catch (e) {
|
||||||
|
console.error("Fallito il salvataggio del template", e);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteTemplate = async (id: string): Promise<void> => {
|
export const deleteTemplate = async (id: string): Promise<void> => {
|
||||||
|
try {
|
||||||
const response = await fetch(`/api/templates/${id}`, {
|
const response = await fetch(`/api/templates/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Eliminazione fallita');
|
if (!response.ok) throw new Error('Eliminazione fallita');
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fallita l'eliminazione del template", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
31
types.ts
31
types.ts
@@ -18,37 +18,18 @@ export interface ToastMessage {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uniform schema for both Postgres and MySQL, showing the VARCHAR id needed for UUIDs
|
// Simple schema for n8n SQL generation
|
||||||
export const SQL_SCHEMA = `
|
export const SQL_SCHEMA = `
|
||||||
-- Per MySQL
|
|
||||||
CREATE TABLE IF NOT EXISTS email_templates (
|
CREATE TABLE IF NOT EXISTS email_templates (
|
||||||
id VARCHAR(255) PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
template_key VARCHAR(255) UNIQUE NOT NULL,
|
template_key VARCHAR(255) UNIQUE NOT NULL,
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
subject VARCHAR(255),
|
subject VARCHAR(255),
|
||||||
header_html MEDIUMTEXT,
|
header_html TEXT,
|
||||||
body_html MEDIUMTEXT,
|
body_html TEXT,
|
||||||
footer_html MEDIUMTEXT,
|
footer_html TEXT,
|
||||||
full_html MEDIUMTEXT,
|
full_html TEXT,
|
||||||
required_variables JSON,
|
required_variables JSON,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Per PostgreSQL
|
|
||||||
-- CREATE TABLE IF NOT EXISTS email_templates (
|
|
||||||
-- id VARCHAR(255) PRIMARY KEY,
|
|
||||||
-- template_key VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
-- name VARCHAR(255) NOT NULL,
|
|
||||||
-- description TEXT,
|
|
||||||
-- subject VARCHAR(255),
|
|
||||||
-- header_html TEXT,
|
|
||||||
-- body_html TEXT,
|
|
||||||
-- footer_html TEXT,
|
|
||||||
-- full_html TEXT,
|
|
||||||
-- required_variables JSONB,
|
|
||||||
-- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
-- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
-- );
|
|
||||||
`;
|
`;
|
||||||
Reference in New Issue
Block a user