Initial commit: ArchiTools modular dashboard platform
Complete Next.js 16 application with 13 fully implemented modules: Email Signature, Word XML Generator, Registratura, Dashboard, Tag Manager, IT Inventory, Address Book, Password Vault, Mini Utilities, Prompt Generator, Digital Signatures, Word Templates, and AI Chat. Includes core platform systems (module registry, feature flags, storage abstraction, i18n, theming, auth stub, tagging), 16 technical documentation files, Docker deployment config, and legacy HTML tool reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useSignatureConfig } from '../hooks/use-signature-config';
|
||||
import { useSavedSignatures } from '../hooks/use-saved-signatures';
|
||||
import { SignatureConfigurator } from './signature-configurator';
|
||||
import { SignaturePreview } from './signature-preview';
|
||||
import { SavedSignaturesPanel } from './saved-signatures-panel';
|
||||
import { Separator } from '@/shared/components/ui/separator';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
export function EmailSignatureModule() {
|
||||
const {
|
||||
config, updateField, updateColor, updateLayout,
|
||||
setVariant, setCompany, resetToDefaults, loadConfig,
|
||||
} = useSignatureConfig();
|
||||
|
||||
const { saved, loading, save, remove } = useSavedSignatures();
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
{/* Left panel — configurator */}
|
||||
<div className="space-y-6 overflow-y-auto lg:max-h-[calc(100vh-10rem)] lg:pr-2">
|
||||
<SignatureConfigurator
|
||||
config={config}
|
||||
onUpdateField={updateField}
|
||||
onUpdateColor={updateColor}
|
||||
onUpdateLayout={updateLayout}
|
||||
onSetVariant={setVariant}
|
||||
onSetCompany={setCompany}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SavedSignaturesPanel
|
||||
saved={saved}
|
||||
loading={loading}
|
||||
onSave={async (label, cfg) => { await save(label, cfg); }}
|
||||
onLoad={loadConfig}
|
||||
onRemove={remove}
|
||||
currentConfig={config}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button variant="outline" size="sm" onClick={resetToDefaults} className="w-full">
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Resetare la valorile implicite
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right panel — preview */}
|
||||
<div>
|
||||
<SignaturePreview config={config} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Trash2, Upload, Save } from 'lucide-react';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { Input } from '@/shared/components/ui/input';
|
||||
import type { SavedSignature, SignatureConfig } from '../types';
|
||||
|
||||
interface SavedSignaturesPanelProps {
|
||||
saved: SavedSignature[];
|
||||
loading: boolean;
|
||||
onSave: (label: string, config: SignatureConfig) => Promise<void>;
|
||||
onLoad: (config: SignatureConfig) => void;
|
||||
onRemove: (id: string) => Promise<void>;
|
||||
currentConfig: SignatureConfig;
|
||||
}
|
||||
|
||||
export function SavedSignaturesPanel({
|
||||
saved, loading, onSave, onLoad, onRemove, currentConfig,
|
||||
}: SavedSignaturesPanelProps) {
|
||||
const [label, setLabel] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!label.trim()) return;
|
||||
setSaving(true);
|
||||
await onSave(label.trim(), currentConfig);
|
||||
setLabel('');
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Semnături salvate</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Nume semnătură..."
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !label.trim()}>
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
Salvează
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-xs text-muted-foreground">Se încarcă...</p>
|
||||
) : saved.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Nicio semnătură salvată.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{saved.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{s.config.company} · {s.config.name || 'fără nume'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onLoad(s.config)}>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onRemove(s.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types';
|
||||
import { COMPANY_BRANDING } from '../services/company-branding';
|
||||
import { Input } from '@/shared/components/ui/input';
|
||||
import { Label } from '@/shared/components/ui/label';
|
||||
import { Switch } from '@/shared/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||
import { Separator } from '@/shared/components/ui/separator';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
interface SignatureConfiguratorProps {
|
||||
config: SignatureConfig;
|
||||
onUpdateField: <K extends keyof SignatureConfig>(key: K, value: SignatureConfig[K]) => void;
|
||||
onUpdateColor: (key: keyof SignatureColors, value: string) => void;
|
||||
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
|
||||
onSetVariant: (variant: SignatureVariant) => void;
|
||||
onSetCompany: (company: CompanyId) => void;
|
||||
}
|
||||
|
||||
const COLOR_PALETTE: Record<string, string> = {
|
||||
verde: '#22B5AB',
|
||||
griInchis: '#54504F',
|
||||
griDeschis: '#A7A9AA',
|
||||
negru: '#323232',
|
||||
};
|
||||
|
||||
const COLOR_LABELS: Record<keyof SignatureColors, string> = {
|
||||
prefix: 'Titulatură',
|
||||
name: 'Nume',
|
||||
title: 'Funcție',
|
||||
address: 'Adresă',
|
||||
phone: 'Telefon',
|
||||
website: 'Website',
|
||||
motto: 'Motto',
|
||||
};
|
||||
|
||||
const LAYOUT_CONTROLS: { key: keyof SignatureLayout; label: string; min: number; max: number }[] = [
|
||||
{ key: 'greenLineWidth', label: 'Lungime linie accent', min: 50, max: 300 },
|
||||
{ key: 'sectionSpacing', label: 'Spațiere secțiuni', min: 0, max: 30 },
|
||||
{ key: 'logoSpacing', label: 'Spațiere logo', min: 0, max: 30 },
|
||||
{ key: 'titleSpacing', label: 'Spațiere funcție', min: 0, max: 20 },
|
||||
{ key: 'gutterWidth', label: 'Aliniere contact', min: 0, max: 150 },
|
||||
{ key: 'iconTextSpacing', label: 'Spațiu icon-text', min: -10, max: 30 },
|
||||
{ key: 'iconVerticalOffset', label: 'Aliniere verticală iconițe', min: -10, max: 10 },
|
||||
{ key: 'mottoSpacing', label: 'Spațiere motto', min: 0, max: 20 },
|
||||
];
|
||||
|
||||
export function SignatureConfigurator({
|
||||
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany,
|
||||
}: SignatureConfiguratorProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Company selector */}
|
||||
<div>
|
||||
<Label>Companie</Label>
|
||||
<Select value={config.company} onValueChange={(v) => onSetCompany(v as CompanyId)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(COMPANY_BRANDING).map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Personal data */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Date personale</h3>
|
||||
<div>
|
||||
<Label htmlFor="sig-prefix">Titulatură (prefix)</Label>
|
||||
<Input id="sig-prefix" value={config.prefix} onChange={(e) => onUpdateField('prefix', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sig-name">Nume și Prenume</Label>
|
||||
<Input id="sig-name" value={config.name} onChange={(e) => onUpdateField('name', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sig-title">Funcția</Label>
|
||||
<Input id="sig-title" value={config.title} onChange={(e) => onUpdateField('title', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sig-phone">Telefon (format 07xxxxxxxx)</Label>
|
||||
<Input id="sig-phone" type="tel" value={config.phone} onChange={(e) => onUpdateField('phone', e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Variant */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Variantă</h3>
|
||||
<Select value={config.variant} onValueChange={(v) => onSetVariant(v as SignatureVariant)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">Completă (logo + adresă + motto)</SelectItem>
|
||||
<SelectItem value="reply">Simplă (fără logo/adresă)</SelectItem>
|
||||
<SelectItem value="minimal">Super-simplă (doar nume/telefon)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={config.useSvg} onCheckedChange={(v) => onUpdateField('useSvg', v)} id="svg-toggle" />
|
||||
<Label htmlFor="svg-toggle" className="cursor-pointer text-sm">Imagini SVG (calitate maximă)</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Colors */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Culori text</h3>
|
||||
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
|
||||
<div key={colorKey} className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
|
||||
<div className="flex gap-1.5">
|
||||
{Object.values(COLOR_PALETTE).map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => onUpdateColor(colorKey, color)}
|
||||
className={cn(
|
||||
'h-6 w-6 rounded-full border-2 transition-all',
|
||||
config.colors[colorKey] === color
|
||||
? 'border-primary scale-110 ring-2 ring-primary/30'
|
||||
: 'border-transparent hover:scale-105'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Layout sliders */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Stil & Aranjare</h3>
|
||||
{LAYOUT_CONTROLS.map(({ key, label, min, max }) => (
|
||||
<div key={key}>
|
||||
<div className="flex justify-between text-sm">
|
||||
<Label>{label}</Label>
|
||||
<span className="text-muted-foreground">{config.layout[key]}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={config.layout[key]}
|
||||
onChange={(e) => onUpdateLayout(key, parseInt(e.target.value, 10))}
|
||||
className="mt-1 w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Download, ZoomIn, ZoomOut, Copy } from 'lucide-react';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import type { SignatureConfig } from '../types';
|
||||
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
|
||||
|
||||
interface SignaturePreviewProps {
|
||||
config: SignatureConfig;
|
||||
}
|
||||
|
||||
export function SignaturePreview({ config }: SignaturePreviewProps) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const html = useMemo(() => generateSignatureHtml(config), [config]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const filename = `semnatura-${config.company}-${config.name.toLowerCase().replace(/\s+/g, '-') || 'email'}.html`;
|
||||
downloadSignatureHtml(html, filename);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(html);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
};
|
||||
|
||||
const toggleZoom = () => setZoom((z) => (z === 1 ? 2 : 1));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Previzualizare</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={toggleZoom}>
|
||||
{zoom === 1 ? <ZoomIn className="mr-1 h-4 w-4" /> : <ZoomOut className="mr-1 h-4 w-4" />}
|
||||
{zoom === 1 ? '200%' : '100%'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
{copied ? 'Copiat!' : 'Copiază HTML'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDownload}>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
Descarcă HTML
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto rounded-lg border bg-white p-6">
|
||||
<div
|
||||
ref={previewRef}
|
||||
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
className="transition-transform"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
||||
|
||||
export const emailSignatureConfig: ModuleConfig = {
|
||||
id: 'email-signature',
|
||||
name: 'Generator Semnătură Email',
|
||||
description: 'Generator de semnături email profesionale cu suport multi-firmă și variante de layout',
|
||||
icon: 'mail',
|
||||
route: '/email-signature',
|
||||
category: 'generators',
|
||||
featureFlag: 'module.email-signature',
|
||||
visibility: 'all',
|
||||
version: '0.1.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'email-signature',
|
||||
navOrder: 20,
|
||||
tags: ['email', 'semnătură', 'generator'],
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStorage } from '@/core/storage';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { SignatureConfig, SavedSignature } from '../types';
|
||||
|
||||
export function useSavedSignatures() {
|
||||
const storage = useStorage('email-signature');
|
||||
const [saved, setSaved] = useState<SavedSignature[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const keys = await storage.list();
|
||||
const items: SavedSignature[] = [];
|
||||
for (const key of keys) {
|
||||
if (key.startsWith('sig:')) {
|
||||
const item = await storage.get<SavedSignature>(key);
|
||||
if (item) items.push(item);
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
setSaved(items);
|
||||
setLoading(false);
|
||||
}, [storage]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const save = useCallback(async (label: string, config: SignatureConfig) => {
|
||||
const now = new Date().toISOString();
|
||||
const entry: SavedSignature = {
|
||||
id: uuid(),
|
||||
label,
|
||||
config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await storage.set(`sig:${entry.id}`, entry);
|
||||
await refresh();
|
||||
return entry;
|
||||
}, [storage, refresh]);
|
||||
|
||||
const remove = useCallback(async (id: string) => {
|
||||
await storage.delete(`sig:${id}`);
|
||||
await refresh();
|
||||
}, [storage, refresh]);
|
||||
|
||||
return { saved, loading, save, remove, refresh };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { SignatureConfig, SignatureVariant, SignatureColors, SignatureLayout } from '../types';
|
||||
import { getBranding } from '../services/company-branding';
|
||||
|
||||
const DEFAULT_LAYOUT: SignatureLayout = {
|
||||
greenLineWidth: 97,
|
||||
gutterWidth: 13,
|
||||
iconTextSpacing: 5,
|
||||
iconVerticalOffset: 1,
|
||||
mottoSpacing: 3,
|
||||
sectionSpacing: 10,
|
||||
titleSpacing: 2,
|
||||
logoSpacing: 10,
|
||||
};
|
||||
|
||||
function createDefaultConfig(company: CompanyId = 'beletage'): SignatureConfig {
|
||||
const branding = getBranding(company);
|
||||
return {
|
||||
prefix: 'arh.',
|
||||
name: '',
|
||||
title: '',
|
||||
phone: '',
|
||||
company,
|
||||
colors: { ...branding.defaultColors },
|
||||
layout: { ...DEFAULT_LAYOUT },
|
||||
variant: 'full',
|
||||
useSvg: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
|
||||
const [config, setConfig] = useState<SignatureConfig>(() => createDefaultConfig(initialCompany));
|
||||
|
||||
const updateField = useCallback(<K extends keyof SignatureConfig>(
|
||||
key: K,
|
||||
value: SignatureConfig[K]
|
||||
) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const updateColor = useCallback((key: keyof SignatureColors, value: string) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
colors: { ...prev.colors, [key]: value },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateLayout = useCallback((key: keyof SignatureLayout, value: number) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
layout: { ...prev.layout, [key]: value },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setVariant = useCallback((variant: SignatureVariant) => {
|
||||
setConfig((prev) => ({ ...prev, variant }));
|
||||
}, []);
|
||||
|
||||
const setCompany = useCallback((company: CompanyId) => {
|
||||
const branding = getBranding(company);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
company,
|
||||
colors: { ...branding.defaultColors },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
setConfig(createDefaultConfig(config.company));
|
||||
}, [config.company]);
|
||||
|
||||
const loadConfig = useCallback((loaded: SignatureConfig) => {
|
||||
setConfig(loaded);
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
config,
|
||||
updateField,
|
||||
updateColor,
|
||||
updateLayout,
|
||||
setVariant,
|
||||
setCompany,
|
||||
resetToDefaults,
|
||||
loadConfig,
|
||||
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, resetToDefaults, loadConfig]);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { emailSignatureConfig } from './config';
|
||||
export { EmailSignatureModule } from './components/email-signature-module';
|
||||
export type { SignatureConfig, SignatureVariant, SignatureColors, SignatureLayout, SavedSignature } from './types';
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { CompanyBranding, SignatureColors } from '../types';
|
||||
|
||||
const BELETAGE_COLORS: SignatureColors = {
|
||||
prefix: '#54504F',
|
||||
name: '#54504F',
|
||||
title: '#A7A9AA',
|
||||
address: '#A7A9AA',
|
||||
phone: '#54504F',
|
||||
website: '#54504F',
|
||||
motto: '#22B5AB',
|
||||
};
|
||||
|
||||
const URBAN_SWITCH_COLORS: SignatureColors = {
|
||||
prefix: '#3B3B3B',
|
||||
name: '#3B3B3B',
|
||||
title: '#8B8B8B',
|
||||
address: '#8B8B8B',
|
||||
phone: '#3B3B3B',
|
||||
website: '#3B3B3B',
|
||||
motto: '#6366f1',
|
||||
};
|
||||
|
||||
const STUDII_COLORS: SignatureColors = {
|
||||
prefix: '#3B3B3B',
|
||||
name: '#3B3B3B',
|
||||
title: '#8B8B8B',
|
||||
address: '#8B8B8B',
|
||||
phone: '#3B3B3B',
|
||||
website: '#3B3B3B',
|
||||
motto: '#f59e0b',
|
||||
};
|
||||
|
||||
export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
||||
beletage: {
|
||||
id: 'beletage',
|
||||
name: 'Beletage SRL',
|
||||
accent: '#22B5AB',
|
||||
logo: {
|
||||
png: 'https://beletage.ro/img/Semnatura-Logo.png',
|
||||
svg: 'https://beletage.ro/img/Logo-Beletage.svg',
|
||||
},
|
||||
slashGrey: {
|
||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||
},
|
||||
slashAccent: {
|
||||
png: 'https://beletage.ro/img/Green-slash.png',
|
||||
svg: 'https://beletage.ro/img/Green-slash.svg',
|
||||
},
|
||||
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
|
||||
website: 'www.beletage.ro',
|
||||
motto: 'we make complex simple',
|
||||
defaultColors: BELETAGE_COLORS,
|
||||
},
|
||||
'urban-switch': {
|
||||
id: 'urban-switch',
|
||||
name: 'Urban Switch SRL',
|
||||
accent: '#6366f1',
|
||||
logo: {
|
||||
png: '',
|
||||
svg: '',
|
||||
},
|
||||
slashGrey: {
|
||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||
},
|
||||
slashAccent: {
|
||||
png: '',
|
||||
svg: '',
|
||||
},
|
||||
address: ['Cluj-Napoca', 'România'],
|
||||
website: '',
|
||||
motto: '',
|
||||
defaultColors: URBAN_SWITCH_COLORS,
|
||||
},
|
||||
'studii-de-teren': {
|
||||
id: 'studii-de-teren',
|
||||
name: 'Studii de Teren SRL',
|
||||
accent: '#f59e0b',
|
||||
logo: {
|
||||
png: '',
|
||||
svg: '',
|
||||
},
|
||||
slashGrey: {
|
||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||
},
|
||||
slashAccent: {
|
||||
png: '',
|
||||
svg: '',
|
||||
},
|
||||
address: ['Cluj-Napoca', 'România'],
|
||||
website: '',
|
||||
motto: '',
|
||||
defaultColors: STUDII_COLORS,
|
||||
},
|
||||
group: {
|
||||
id: 'group',
|
||||
name: 'Grup Companii',
|
||||
accent: '#64748b',
|
||||
logo: { png: '', svg: '' },
|
||||
slashGrey: { png: '', svg: '' },
|
||||
slashAccent: { png: '', svg: '' },
|
||||
address: ['Cluj-Napoca', 'România'],
|
||||
website: '',
|
||||
motto: '',
|
||||
defaultColors: BELETAGE_COLORS,
|
||||
},
|
||||
};
|
||||
|
||||
export function getBranding(company: CompanyId): CompanyBranding {
|
||||
return COMPANY_BRANDING[company];
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { SignatureConfig, CompanyBranding } from '../types';
|
||||
import { getBranding } from './company-branding';
|
||||
|
||||
export function formatPhone(raw: string): { display: string; link: string } {
|
||||
const clean = raw.replace(/\s/g, '');
|
||||
if (clean.length === 10 && clean.startsWith('07')) {
|
||||
return {
|
||||
display: `+40 ${clean.substring(1, 4)} ${clean.substring(4, 7)} ${clean.substring(7, 10)}`,
|
||||
link: `tel:+40${clean.substring(1)}`,
|
||||
};
|
||||
}
|
||||
return { display: raw, link: `tel:${clean}` };
|
||||
}
|
||||
|
||||
export function generateSignatureHtml(config: SignatureConfig): string {
|
||||
const branding = getBranding(config.company);
|
||||
const { display: phone, link: phoneLink } = formatPhone(config.phone);
|
||||
const images = config.useSvg
|
||||
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
|
||||
: { logo: branding.logo.png, greySlash: branding.slashGrey.png, accentSlash: branding.slashAccent.png };
|
||||
|
||||
const {
|
||||
greenLineWidth, gutterWidth, iconTextSpacing, iconVerticalOffset,
|
||||
mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
|
||||
} = config.layout;
|
||||
const colors = config.colors;
|
||||
|
||||
const isReply = config.variant === 'reply' || config.variant === 'minimal';
|
||||
const isMinimal = config.variant === 'minimal';
|
||||
|
||||
const hide = 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;';
|
||||
const hideTitle = isReply ? hide : '';
|
||||
const hideLogo = isReply ? hide : '';
|
||||
const hideBottom = isMinimal ? hide : '';
|
||||
const hidePhoneIcon = isMinimal ? hide : '';
|
||||
|
||||
const spacerWidth = Math.max(0, iconTextSpacing);
|
||||
const textPaddingLeft = Math.max(0, -iconTextSpacing);
|
||||
|
||||
const prefixHtml = config.prefix
|
||||
? `<span style="font-size:13px; color:${colors.prefix};">${esc(config.prefix)} </span>`
|
||||
: '';
|
||||
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-family: Arial, Helvetica, sans-serif; color:#333333; font-size:14px; line-height:18px;">
|
||||
<tbody>
|
||||
<tr><td style="padding:0 0 ${titleSpacing}px 0;">${prefixHtml}<span style="font-size:15px; color:${colors.name}; font-weight:700;">${esc(config.name)}</span></td></tr>
|
||||
<tr style="${hideTitle}"><td style="padding:0 0 8px 0;"><span style="font-size:12px; color:${colors.title};">${esc(config.title)}</span></td></tr>
|
||||
<tr style="${hideBottom}">
|
||||
<td style="padding:0; font-size:0; line-height:0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
||||
<tr>
|
||||
<td width="${greenLineWidth}" height="2" bgcolor="${branding.accent}" style="font-size:0; line-height:0; height:2px;"></td>
|
||||
<td width="${540 - greenLineWidth}" height="2" style="font-size:0; line-height:0; height:2px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="${hideLogo}"><td style="padding:${logoSpacing}px 0 ${logoSpacing + 2}px 0;">
|
||||
${images.logo ? `<a href="https://${branding.website}" style="text-decoration:none; border:0;">
|
||||
<img src="${images.logo}" alt="${esc(branding.name)}" style="display:block; border:0; height:24px; width:162px;" height="24" width="162">
|
||||
</a>` : ''}
|
||||
</td></tr>
|
||||
<tr>
|
||||
<td style="padding-top:${hideLogo ? '0' : sectionSpacing}px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
|
||||
<tbody>
|
||||
<tr style="${hideLogo}">
|
||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${4 + iconVerticalOffset}px;">
|
||||
${images.greySlash ? `<img src="${images.greySlash}" alt="" width="11" height="11" style="display:block; border:0;">` : ''}
|
||||
</td>
|
||||
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
|
||||
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
|
||||
<span style="color:${colors.address}; text-decoration:none;">${branding.address.join('<br>')}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${12 + iconVerticalOffset}px; ${hidePhoneIcon}">
|
||||
${images.accentSlash ? `<img src="${images.accentSlash}" alt="" width="11" height="7" style="display:block; border:0;">` : ''}
|
||||
</td>
|
||||
<td width="${isMinimal ? 0 : spacerWidth}" style="width:${isMinimal ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
|
||||
<td style="vertical-align:top; padding:8px 0 0 ${isMinimal ? 0 : textPaddingLeft}px;">
|
||||
<a href="${phoneLink}" style="color:${colors.phone}; text-decoration:none;"><span style="color:${colors.phone}; text-decoration:none;">${phone}</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
${branding.website ? `<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://${branding.website}" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">${branding.website}</span></a></td></tr>` : ''}
|
||||
<tr style="${hideBottom}">
|
||||
<td style="padding:0; font-size:0; line-height:0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
||||
<tr>
|
||||
<td width="${greenLineWidth}" height="1" bgcolor="${branding.accent}" style="font-size:0; line-height:0; height:1px;"></td>
|
||||
<td width="${540 - greenLineWidth}" height="1" style="font-size:0; line-height:0; height:1px;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
${branding.motto ? `<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">${esc(branding.motto)}</span></td></tr>` : ''}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function esc(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function downloadSignatureHtml(html: string, filename: string): void {
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
|
||||
export type SignatureVariant = 'full' | 'reply' | 'minimal';
|
||||
|
||||
export interface SignatureColors {
|
||||
prefix: string;
|
||||
name: string;
|
||||
title: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
website: string;
|
||||
motto: string;
|
||||
}
|
||||
|
||||
export interface SignatureLayout {
|
||||
greenLineWidth: number;
|
||||
gutterWidth: number;
|
||||
iconTextSpacing: number;
|
||||
iconVerticalOffset: number;
|
||||
mottoSpacing: number;
|
||||
sectionSpacing: number;
|
||||
titleSpacing: number;
|
||||
logoSpacing: number;
|
||||
}
|
||||
|
||||
export interface CompanyBranding {
|
||||
id: CompanyId;
|
||||
name: string;
|
||||
accent: string;
|
||||
logo: { png: string; svg: string };
|
||||
slashGrey: { png: string; svg: string };
|
||||
slashAccent: { png: string; svg: string };
|
||||
address: string[];
|
||||
website: string;
|
||||
motto: string;
|
||||
defaultColors: SignatureColors;
|
||||
}
|
||||
|
||||
export interface SignatureConfig {
|
||||
id?: string;
|
||||
label?: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
title: string;
|
||||
phone: string;
|
||||
company: CompanyId;
|
||||
colors: SignatureColors;
|
||||
layout: SignatureLayout;
|
||||
variant: SignatureVariant;
|
||||
useSvg: boolean;
|
||||
}
|
||||
|
||||
export interface SavedSignature {
|
||||
id: string;
|
||||
label: string;
|
||||
config: SignatureConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user