Ver código fonte

Estesa notifica degli errori utente

main
Lorenzo Pollutri 2 semanas atrás
pai
commit
0acff68db9
13 arquivos alterados com 180 adições e 78 exclusões
  1. +5
    -5
      app/admin/page.tsx
  2. +4
    -1
      app/api/admin/backup/route.ts
  3. +8
    -2
      app/api/admin/factory-preset/route.ts
  4. +5
    -2
      app/api/admin/factory-reset/route.ts
  5. +34
    -12
      app/api/admin/fonts/route.ts
  6. +12
    -3
      app/api/admin/restore/route.ts
  7. +22
    -7
      app/api/cards/route.ts
  8. +13
    -4
      app/api/portals/route.ts
  9. +45
    -18
      app/api/upload/route.ts
  10. +13
    -5
      lib/restore-zip.ts
  11. +2
    -2
      lib/svg-sanitize.ts
  12. +1
    -1
      lib/transcode.ts
  13. +16
    -16
      lib/validation.ts

+ 5
- 5
app/admin/page.tsx Ver arquivo

@@ -317,7 +317,7 @@ export default function AdminDashboard() {
// Auto-seleziona il font appena caricato
if (data.name) setPortal(p => ({ ...p, fontFamily: data.name }));
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
} finally {
setUploadingFont(false);
}
@@ -336,7 +336,7 @@ export default function AdminDashboard() {
await refreshFonts();
if (portal.fontFamily === name) setPortal(p => ({ ...p, fontFamily: '' }));
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
}
};
@@ -669,7 +669,7 @@ export default function AdminDashboard() {
showToast(`Restore completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`);
setTimeout(() => window.location.reload(), 1200);
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
} finally {
setRestoring(false);
}
@@ -707,7 +707,7 @@ export default function AdminDashboard() {
showToast('Factory preset updated.');
await refreshFactoryPreset();
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
} finally {
setSavingPreset(false);
}
@@ -726,7 +726,7 @@ export default function AdminDashboard() {
showToast(`Factory reset completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`);
setTimeout(() => window.location.reload(), 1200);
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error');
} finally {
setFactoryResetting(false);
}


+ 4
- 1
app/api/admin/backup/route.ts Ver arquivo

@@ -16,7 +16,10 @@ function timestamp(): string {

export async function GET() {
if (!(await checkSystemBin('zip', '-v'))) {
return NextResponse.json({ error: "Binario 'zip' non disponibile sul server." }, { status: 503 });
return NextResponse.json(
{ error: "Cannot create the backup: the 'zip' command is not installed on the server. Ask the administrator to install it." },
{ status: 503 },
);
}

// -r ricorsivo, - stdout, -x esclude pattern relativi al cwd.


+ 8
- 2
app/api/admin/factory-preset/route.ts Ver arquivo

@@ -31,7 +31,10 @@ export async function GET() {
// POST — crea il preset zippando lo stato corrente di data/.
export async function POST() {
if (!(await checkSystemBin('zip', '-v'))) {
return NextResponse.json({ error: "Binario 'zip' non disponibile sul server." }, { status: 503 });
return NextResponse.json(
{ error: "Cannot create the preset: the 'zip' command is not installed on the server. Ask the administrator to install it." },
{ status: 503 },
);
}
await mkdir(FACTORY_DIR, { recursive: true });

@@ -58,7 +61,10 @@ export async function POST() {
// 0 = ok, 12 = "nothing to do" (data/ vuoto: accettiamo, sarà uno zip minimale)
if (exit !== 0 && exit !== 12) {
try { await unlink(tmpPath); } catch { /* ignore */ }
return NextResponse.json({ error: `zip terminato con codice ${exit}` }, { status: 500 });
return NextResponse.json(
{ error: `Failed to create the preset archive: the 'zip' command exited with code ${exit}. Check the server logs for details. The previous preset (if any) has not been modified.` },
{ status: 500 },
);
}

await rename(tmpPath, PRESET_PATH);


+ 5
- 2
app/api/admin/factory-reset/route.ts Ver arquivo

@@ -11,14 +11,17 @@ const PRESET_PATH = path.join(process.cwd(), 'factory', 'preset.zip');

export async function POST() {
if (!(await checkSystemBin('unzip', '-v'))) {
return NextResponse.json({ error: "Binario 'unzip' non disponibile sul server." }, { status: 503 });
return NextResponse.json(
{ error: "Cannot perform Factory Reset: the 'unzip' command is not installed on the server. Ask the administrator to install it." },
{ status: 503 },
);
}

try {
await stat(PRESET_PATH);
} catch {
return NextResponse.json(
{ error: 'Nessun factory preset configurato. Imposta prima lo stato attuale come preset.' },
{ error: 'No factory preset is configured on this server: factory/preset.zip is missing. Ask a developer to create a preset first (either via "Save as Factory Preset" in admin or by uploading factory/preset.zip manually).' },
{ status: 404 },
);
}


+ 34
- 12
app/api/admin/fonts/route.ts Ver arquivo

@@ -37,24 +37,31 @@ export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file received.' }, { status: 400 });
return NextResponse.json(
{ error: 'No font file was received. Choose a font file before clicking Upload.' },
{ status: 400 },
);
}

const safeName = sanitizeFontName(file.name);
const ext = path.extname(safeName).toLowerCase();
if (!ALLOWED_EXT.has(ext)) {
return NextResponse.json(
{ error: `Unsupported font extension. Allowed: ${[...ALLOWED_EXT].join(', ')}` },
{ error: `Font extension "${ext || '(none)'}" is not supported. Allowed formats: ${[...ALLOWED_EXT].join(', ')}.` },
{ status: 400 },
);
}
if (!safeName || safeName === ext) {
return NextResponse.json({ error: 'Invalid font filename.' }, { status: 400 });
return NextResponse.json(
{ error: 'Invalid font filename. The file name must contain at least one letter, digit, hyphen or underscore before the extension.' },
{ status: 400 },
);
}
if (file.size > UPLOAD_LIMITS.font) {
const mb = (UPLOAD_LIMITS.font / (1024 * 1024)).toFixed(0);
const limitMB = (UPLOAD_LIMITS.font / (1024 * 1024)).toFixed(0);
const actualMB = (file.size / (1024 * 1024)).toFixed(1);
return NextResponse.json(
{ error: `Font too large (max ${mb} MB).` },
{ error: `Font is too large: ${actualMB} MB (limit: ${limitMB} MB). Web fonts are typically under 500 KB — convert the file to WOFF2 (e.g. with a font-tools subsetter) before uploading.` },
{ status: 413 },
);
}
@@ -65,14 +72,14 @@ export async function POST(request: Request) {
const detected = await fileTypeFromBuffer(buffer);
if (!detected) {
return NextResponse.json(
{ error: 'Font content not recognized (unknown format).' },
{ error: 'Could not identify the font content. The file may be corrupted or not actually a font. Try re-exporting from your font tool.' },
{ status: 400 },
);
}
const allowed = ALLOWED_DETECTED[ext] ?? [];
if (!allowed.includes(detected.ext)) {
return NextResponse.json(
{ error: `Font content does not match extension (${ext} declared, detected ${detected.ext}).` },
{ error: `Font content does not match its extension: the file looks like ${detected.ext.toUpperCase()} but the extension is "${ext}". Rename the file or re-export it in the declared format.` },
{ status: 400 },
);
}
@@ -83,7 +90,10 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true, name: safeName }, { status: 201 });
} catch (error) {
console.error('Font upload error:', error);
return NextResponse.json({ error: 'Failed to upload font.' }, { status: 500 });
return NextResponse.json(
{ error: 'Unexpected error while saving the font. The upload was aborted. Check the server logs for details and try again.' },
{ status: 500 },
);
}
}

@@ -93,25 +103,37 @@ export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const rawName = searchParams.get('name');
if (!rawName) {
return NextResponse.json({ error: 'Missing name parameter.' }, { status: 400 });
return NextResponse.json(
{ error: 'Missing "name" parameter. Specify which font file to delete.' },
{ status: 400 },
);
}
const safeName = sanitizeFontName(rawName);
const ext = path.extname(safeName).toLowerCase();
if (!ALLOWED_EXT.has(ext) || !safeName) {
return NextResponse.json({ error: 'Invalid font name.' }, { status: 400 });
return NextResponse.json(
{ error: `Invalid font name "${rawName}". Expected a basename ending in ${[...ALLOWED_EXT].join(' / ')}.` },
{ status: 400 },
);
}
try {
await unlink(path.join(FONTS_DIR, safeName));
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') {
return NextResponse.json({ error: 'Font not found.' }, { status: 404 });
return NextResponse.json(
{ error: `Font "${safeName}" was not found on the server. It may have already been deleted — refresh the page.` },
{ status: 404 },
);
}
throw err;
}
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Font delete error:', error);
return NextResponse.json({ error: 'Failed to delete font.' }, { status: 500 });
return NextResponse.json(
{ error: 'Unexpected error while deleting the font. Check the server logs and try again.' },
{ status: 500 },
);
}
}

+ 12
- 3
app/api/admin/restore/route.ts Ver arquivo

@@ -13,7 +13,10 @@ const UPLOAD_STAGING = path.join(PROJECT_ROOT, '.restore-staging');

export async function POST(request: Request) {
if (!(await checkSystemBin('unzip', '-v'))) {
return NextResponse.json({ error: "Binario 'unzip' non disponibile sul server." }, { status: 503 });
return NextResponse.json(
{ error: "Restore is unavailable: the 'unzip' command is not installed on the server. Ask the administrator to install it." },
{ status: 503 },
);
}

const sessionId = crypto.randomUUID();
@@ -24,7 +27,10 @@ export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'Nessun file ricevuto.' }, { status: 400 });
return NextResponse.json(
{ error: 'No backup file was received. Choose a .zip backup file before clicking Restore.' },
{ status: 400 },
);
}

await mkdir(sessionDir, { recursive: true });
@@ -41,7 +47,10 @@ export async function POST(request: Request) {
});
} catch (error) {
console.error('Restore error:', error);
return NextResponse.json({ error: 'Errore durante il ripristino.' }, { status: 500 });
return NextResponse.json(
{ error: 'Unexpected error during restore. Existing data was not changed. Check the server logs for details and try again.' },
{ status: 500 },
);
} finally {
try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ }
}


+ 22
- 7
app/api/cards/route.ts Ver arquivo

@@ -20,7 +20,10 @@ export async function POST(request: Request) {
const validation = validateCard(incomingCard);
if (!validation.valid) {
return NextResponse.json({ error: 'Validation failed', errors: validation.errors }, { status: 400 });
return NextResponse.json(
{ error: 'The card could not be saved because some fields are invalid. See the highlighted errors and fix them, then try again.', errors: validation.errors },
{ status: 400 },
);
}
if (typeof incomingCard.fullContent === 'string') {
@@ -40,7 +43,10 @@ export async function POST(request: Request) {
revalidatePath('/'); // Force public portal to update instantly
return NextResponse.json(incomingCard, { status: 200 });
} catch {
return NextResponse.json({ error: 'Failed to save card' }, { status: 500 });
return NextResponse.json(
{ error: 'Unexpected error while saving the card. The card was not saved. Check the server logs for details and try again.' },
{ status: 500 },
);
}
}
@@ -51,8 +57,11 @@ export async function PUT(request: Request) {
await saveCards(updatedCards);
revalidatePath('/'); // Force public portal to update instantly
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Failed to reorder cards' }, { status: 500 });
} catch {
return NextResponse.json(
{ error: 'Unexpected error while saving the new card order. The order was not saved. Reload the page and try again.' },
{ status: 500 },
);
}
}
@@ -61,7 +70,10 @@ export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'Card ID required' }, { status: 400 });
if (!id) return NextResponse.json(
{ error: 'Missing "id" parameter: cannot tell which card to delete. Reload the page and retry.' },
{ status: 400 },
);
const cards = await getCards();
const filteredCards = cards.filter(c => c.id !== id);
@@ -69,7 +81,10 @@ export async function DELETE(request: Request) {
await saveCards(filteredCards);
revalidatePath('/'); // Force public portal to update instantly
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Failed to delete card' }, { status: 500 });
} catch {
return NextResponse.json(
{ error: 'Unexpected error while deleting the card. The card was not deleted. Check the server logs and try again.' },
{ status: 500 },
);
}
}

+ 13
- 4
app/api/portals/route.ts Ver arquivo

@@ -24,15 +24,21 @@ export async function POST(request: Request) {
const { valid, errors } = validatePortal(incomingPortal);
if (!valid) {
return NextResponse.json({ error: 'Validation failed', errors }, { status: 400 });
return NextResponse.json(
{ error: 'The portal settings could not be saved because some fields are invalid. See the highlighted errors and fix them, then try again.', errors },
{ status: 400 },
);
}
// themeColor goes into a <style dangerouslySetInnerHTML> in PublicGrid,
// so reject anything that is not a strict #RRGGBB.
if (incomingPortal.themeColor !== undefined && !isValidHexColor(incomingPortal.themeColor)) {
return NextResponse.json(
{ error: 'Validation failed', errors: [{ field: 'themeColor', message: 'Colore non valido (atteso #RRGGBB)' }] },
{ status: 400 }
{
error: 'Invalid theme color: expected a hex code in the form #RRGGBB (e.g. #1e3a8a).',
errors: [{ field: 'themeColor', message: 'Theme color must be a hex code like #RRGGBB.' }],
},
{ status: 400 },
);
}
@@ -51,6 +57,9 @@ export async function POST(request: Request) {
return NextResponse.json(portals[0], { status: 200 });
} catch {
return NextResponse.json({ error: 'Failed to save portal settings' }, { status: 500 });
return NextResponse.json(
{ error: 'Unexpected error while saving the portal settings. The changes were not saved. Check the server logs and try again.' },
{ status: 500 },
);
}
}

+ 45
- 18
app/api/upload/route.ts Ver arquivo

@@ -44,6 +44,18 @@ function extractExt(name: string): string {
return name.slice(lastDot + 1).toLowerCase();
}

// Formatta bytes in MB con 1 decimale per messaggi all'utente (es. "23.4 MB").
function fmtMB(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

// Lista leggibile delle estensioni accettate per famiglia, per i messaggi di errore.
const EXT_BY_FAMILY: Record<Family, string[]> = {
image: ['PNG', 'JPG', 'JPEG', 'GIF', 'WEBP'],
video: ['MP4', 'M4V', 'WEBM', 'MOV', 'OGV', 'OGG'],
pdf: ['PDF'],
};

export function normalizeFilename(originalName: string): string {
const ext = extractExt(originalName);
const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : 'bin';
@@ -71,7 +83,10 @@ export async function POST(request: Request) {
const file = formData.get('file') as File | null;

if (!file) {
return NextResponse.json({ error: 'No file received.' }, { status: 400 });
return NextResponse.json(
{ error: 'No file was received. Please choose a file before uploading.' },
{ status: 400 },
);
}

const ext = extractExt(file.name);
@@ -85,15 +100,14 @@ export async function POST(request: Request) {
if (ext === 'svg') {
if (!isLogoContext) {
return NextResponse.json(
{ error: 'SVG accettato solo per upload del logo.' },
{ status: 400 }
{ error: 'SVG files can only be used for the portal logo. For other images use PNG, JPG, GIF or WEBP.' },
{ status: 400 },
);
}
if (file.size > MAX_BYTES.image) {
const mb = (MAX_BYTES.image / (1024 * 1024)).toFixed(0);
return NextResponse.json(
{ error: `File troppo grande (max ${mb} MB per image).` },
{ status: 413 }
{ error: `SVG is too large: ${fmtMB(file.size)} (limit: ${fmtMB(MAX_BYTES.image)}). Optimize the SVG (e.g. SVGO) or remove embedded raster images.` },
{ status: 413 },
);
}
const text = Buffer.from(await file.arrayBuffer()).toString('utf-8');
@@ -116,17 +130,27 @@ export async function POST(request: Request) {

const family = EXT_FAMILY[ext];
if (!family) {
const allFormats = [
...EXT_BY_FAMILY.image,
...EXT_BY_FAMILY.video,
...EXT_BY_FAMILY.pdf,
'SVG (logo only)',
].join(', ');
return NextResponse.json(
{ error: `Estensione non permessa: .${ext || '(nessuna)'}` },
{ status: 400 }
{ error: `File extension ".${ext || '(none)'}" is not allowed. Accepted formats: ${allFormats}.` },
{ status: 400 },
);
}

if (file.size > MAX_BYTES[family]) {
const mb = (MAX_BYTES[family] / (1024 * 1024)).toFixed(0);
const hint = family === 'pdf'
? ' Compress the PDF (Ghostscript /ebook preset, Adobe "Reduce File Size", or an online PDF compressor) or split it into smaller documents.'
: family === 'video'
? ' Re-encode the video at a lower bitrate or resolution before uploading.'
: ' Resize or compress the image before uploading (e.g. lower resolution or convert to WEBP).';
return NextResponse.json(
{ error: `File troppo grande (max ${mb} MB per ${family}).` },
{ status: 413 }
{ error: `File is too large: ${fmtMB(file.size)} (limit for ${family.toUpperCase()}: ${fmtMB(MAX_BYTES[family])}).${hint}` },
{ status: 413 },
);
}

@@ -136,15 +160,15 @@ export async function POST(request: Request) {
const detected = await fileTypeFromBuffer(buffer);
if (!detected) {
return NextResponse.json(
{ error: 'Tipo del file non riconoscibile dal contenuto.' },
{ status: 400 }
{ error: `Could not identify the file content. The file may be corrupted, empty, or saved in an unsupported variant. Try re-exporting it as ${EXT_BY_FAMILY[family].join(' / ')}.` },
{ status: 400 },
);
}
const allowedMimes = ALLOWED_MIMES[ext] ?? [];
if (!allowedMimes.includes(detected.mime)) {
return NextResponse.json(
{ error: `Contenuto del file non corrisponde all'estensione (.${ext} dichiarato, rilevato ${detected.mime}).` },
{ status: 400 }
{ error: `File content does not match its extension: the file looks like ${detected.mime} but the extension is ".${ext}". Rename the file to the correct extension or re-export it in the declared format.` },
{ status: 400 },
);
}

@@ -180,8 +204,8 @@ export async function POST(request: Request) {
// Needs transcoding: bail out early if ffmpeg is unavailable.
if (!(await isFfmpegAvailable())) {
return NextResponse.json(
{ error: 'Video richiede ricodifica ma ffmpeg non è disponibile sul server.' },
{ status: 503 }
{ error: 'This video uses a codec that browsers cannot play and needs to be transcoded, but ffmpeg is not installed on the server. Upload the video already encoded as H.264 + AAC in an .mp4 container, or ask the administrator to install ffmpeg.' },
{ status: 503 },
);
}

@@ -207,6 +231,9 @@ export async function POST(request: Request) {
if (tmpPath) {
try { await unlink(tmpPath); } catch {}
}
return NextResponse.json({ error: 'Failed to upload file.' }, { status: 500 });
return NextResponse.json(
{ error: 'Unexpected error while saving the file. The upload was aborted; nothing was changed. Check the server logs for details and try again.' },
{ status: 500 },
);
}
}

+ 13
- 5
lib/restore-zip.ts Ver arquivo

@@ -41,7 +41,7 @@ export async function restoreFromZipFile(zipPath: string): Promise<RestoreResult
return {
ok: false,
status: 400,
error: 'Impossibile estrarre lo ZIP.',
error: 'Could not extract the archive. The file may not be a valid ZIP, or it may be corrupted. Verify the file by opening it locally before retrying.',
detail: stderr.split('\n').slice(0, 3).join(' '),
};
}
@@ -51,18 +51,26 @@ export async function restoreFromZipFile(zipPath: string): Promise<RestoreResult
try {
const raw = await readFile(path.join(extractDir, 'cards.txt'), 'utf-8');
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) throw new Error('cards.txt non è un array JSON');
if (!Array.isArray(parsed)) throw new Error('cards.txt is not a JSON array');
cards = parsed.length;
} catch (e) {
return { ok: false, status: 400, error: `Backup non valido: cards.txt assente o malformato (${(e as Error).message}).` };
return {
ok: false,
status: 400,
error: `Invalid backup: the archive is missing a valid cards.txt at its root (${(e as Error).message}). Make sure you are uploading a ZIP created by "Save backup (ZIP)", not just any archive.`,
};
}
try {
const raw = await readFile(path.join(extractDir, 'portals.txt'), 'utf-8');
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) throw new Error('portals.txt non è un array JSON');
if (!Array.isArray(parsed)) throw new Error('portals.txt is not a JSON array');
portals = parsed.length;
} catch (e) {
return { ok: false, status: 400, error: `Backup non valido: portals.txt assente o malformato (${(e as Error).message}).` };
return {
ok: false,
status: 400,
error: `Invalid backup: the archive is missing a valid portals.txt at its root (${(e as Error).message}). Make sure you are uploading a ZIP created by "Save backup (ZIP)", not just any archive.`,
};
}

// Atomic swap: data → data.bak-<ts>, extract → data.


+ 2
- 2
lib/svg-sanitize.ts Ver arquivo

@@ -41,12 +41,12 @@ export type SvgSanitizeResult =

export function sanitizeSvg(input: string): SvgSanitizeResult {
if (typeof input !== 'string' || input.length === 0) {
return { ok: false, error: 'Empty SVG content.' };
return { ok: false, error: 'The SVG file is empty. Choose a non-empty .svg file.' };
}
// Deve essere un documento SVG: opzionalmente un XML declaration, opzionalmente
// commenti, e poi un tag <svg>.
if (!/^\s*(<\?xml[^?]*\?>\s*)?(<!--[\s\S]*?-->\s*)*<svg[\s>]/i.test(input)) {
return { ok: false, error: 'Not a valid SVG document.' };
return { ok: false, error: 'Not a valid SVG document: the file does not start with an <svg> tag. It may be corrupted or be a different format saved with an .svg extension.' };
}

let out = input;


+ 1
- 1
lib/transcode.ts Ver arquivo

@@ -317,7 +317,7 @@ async function runJob(job: TranscodeJob): Promise<void> {
await updateJob(job.id, {
status: 'failed',
finishedAt: Date.now(),
error: `Output non valido: ${(e as Error).message}`,
error: `Transcoding produced an invalid output: ${(e as Error).message}. The video may be corrupted; try re-exporting from your source tool.`,
pid: undefined,
});
await cleanupTmpForJob(job);


+ 16
- 16
lib/validation.ts Ver arquivo

@@ -36,47 +36,47 @@ export function validateCard(card: Partial<Card>): ValidationResult {
const errors: ValidationError[] = [];

if (typeof card.title !== 'string' || card.title.trim().length === 0) {
errors.push({ field: 'title', message: 'Il titolo è obbligatorio' });
errors.push({ field: 'title', message: 'Title is required. Enter a title before saving.' });
} else if (card.title.length > CARD_LIMITS.title) {
errors.push({ field: 'title', message: 'Titolo troppo lungo', limit: CARD_LIMITS.title, actual: card.title.length });
errors.push({ field: 'title', message: 'Title is too long', limit: CARD_LIMITS.title, actual: card.title.length });
}

if (card.shortDescription !== undefined && typeof card.shortDescription !== 'string') {
errors.push({ field: 'shortDescription', message: 'Tipo non valido' });
errors.push({ field: 'shortDescription', message: 'Invalid type (expected text)' });
} else if (strLen(card.shortDescription) > CARD_LIMITS.shortDescription) {
errors.push({ field: 'shortDescription', message: 'Descrizione breve troppo lunga', limit: CARD_LIMITS.shortDescription, actual: strLen(card.shortDescription) });
errors.push({ field: 'shortDescription', message: 'Short description is too long', limit: CARD_LIMITS.shortDescription, actual: strLen(card.shortDescription) });
}

if (card.fullContent !== undefined && typeof card.fullContent !== 'string') {
errors.push({ field: 'fullContent', message: 'Tipo non valido' });
errors.push({ field: 'fullContent', message: 'Invalid type (expected text)' });
} else if (strLen(card.fullContent) > CARD_LIMITS.fullContent) {
errors.push({ field: 'fullContent', message: 'Contenuto troppo lungo', limit: CARD_LIMITS.fullContent, actual: strLen(card.fullContent) });
errors.push({ field: 'fullContent', message: 'Content is too long', limit: CARD_LIMITS.fullContent, actual: strLen(card.fullContent) });
}

// Le card External Link richiedono obbligatoriamente l'URL.
if (card.cardType === 'EXTERNAL_LINK' && (typeof card.actionUrl !== 'string' || card.actionUrl.trim() === '')) {
errors.push({ field: 'actionUrl', message: "L'URL è obbligatorio per le card External Link" });
errors.push({ field: 'actionUrl', message: 'URL is required for External Link cards. Enter the destination URL before saving.' });
}

if (card.actionUrl !== undefined && card.actionUrl !== '') {
if (typeof card.actionUrl !== 'string') {
errors.push({ field: 'actionUrl', message: 'Tipo non valido' });
errors.push({ field: 'actionUrl', message: 'Invalid type (expected text)' });
} else if (card.actionUrl.length > CARD_LIMITS.actionUrl) {
errors.push({ field: 'actionUrl', message: 'URL troppo lungo', limit: CARD_LIMITS.actionUrl, actual: card.actionUrl.length });
errors.push({ field: 'actionUrl', message: 'URL is too long', limit: CARD_LIMITS.actionUrl, actual: card.actionUrl.length });
} else {
try {
const parsed = new URL(card.actionUrl);
if (!ALLOWED_URL_SCHEMES.has(parsed.protocol)) {
errors.push({ field: 'actionUrl', message: `Schema URL non ammesso (${parsed.protocol}). Usa http, https, mailto o tel.` });
errors.push({ field: 'actionUrl', message: `URL scheme not allowed (${parsed.protocol}). Allowed schemes: http, https, mailto, tel.` });
}
} catch {
errors.push({ field: 'actionUrl', message: 'URL non valido' });
errors.push({ field: 'actionUrl', message: 'Invalid URL format. Use a complete URL such as https://example.com.' });
}
}
}

if (card.cardType !== undefined && !VALID_CARD_TYPES.includes(card.cardType as CardType)) {
errors.push({ field: 'cardType', message: `Tipo card non valido: ${String(card.cardType)}` });
errors.push({ field: 'cardType', message: `Invalid card type "${String(card.cardType)}". Pick a type from the dropdown.` });
}

return { valid: errors.length === 0, errors };
@@ -87,17 +87,17 @@ export function validatePortal(portal: Partial<Portal>): ValidationResult {

if (portal.title !== undefined) {
if (typeof portal.title !== 'string') {
errors.push({ field: 'title', message: 'Tipo non valido' });
errors.push({ field: 'title', message: 'Invalid type (expected text)' });
} else if (portal.title.length > PORTAL_LIMITS.title) {
errors.push({ field: 'title', message: 'Titolo portale troppo lungo', limit: PORTAL_LIMITS.title, actual: portal.title.length });
errors.push({ field: 'title', message: 'Portal title is too long', limit: PORTAL_LIMITS.title, actual: portal.title.length });
}
}

if (portal.welcomeText !== undefined) {
if (typeof portal.welcomeText !== 'string') {
errors.push({ field: 'welcomeText', message: 'Tipo non valido' });
errors.push({ field: 'welcomeText', message: 'Invalid type (expected text)' });
} else if (portal.welcomeText.length > PORTAL_LIMITS.welcomeText) {
errors.push({ field: 'welcomeText', message: 'Testo di benvenuto troppo lungo', limit: PORTAL_LIMITS.welcomeText, actual: portal.welcomeText.length });
errors.push({ field: 'welcomeText', message: 'Welcome text is too long', limit: PORTAL_LIMITS.welcomeText, actual: portal.welcomeText.length });
}
}



Carregando…
Cancelar
Salvar