feat(email-signature): wire SDT/US branding, address selector, color palettes, improved preview

- Per-company branding for Urban Switch and Studii de Teren (logos, websites, mottos)
- Beletage address selector (Str. Unirii vs Str. G-ral Eremia Grigorescu)
- Company-specific color palettes in configurator
- Scrollable preview with multi-level zoom (0.75x to 2.5x)
- Address override support in signature config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marius Tarau
2026-02-18 06:35:28 +02:00
parent 98eda56035
commit 93cf6feae2
7 changed files with 128 additions and 49 deletions

View File

@@ -12,7 +12,7 @@ import { RotateCcw } from 'lucide-react';
export function EmailSignatureModule() { export function EmailSignatureModule() {
const { const {
config, updateField, updateColor, updateLayout, config, updateField, updateColor, updateLayout,
setVariant, setCompany, resetToDefaults, loadConfig, setVariant, setCompany, setAddress, resetToDefaults, loadConfig,
} = useSignatureConfig(); } = useSignatureConfig();
const { saved, loading, save, remove } = useSavedSignatures(); const { saved, loading, save, remove } = useSavedSignatures();
@@ -28,6 +28,7 @@ export function EmailSignatureModule() {
onUpdateLayout={updateLayout} onUpdateLayout={updateLayout}
onSetVariant={setVariant} onSetVariant={setVariant}
onSetCompany={setCompany} onSetCompany={setCompany}
onSetAddress={setAddress}
/> />
<Separator /> <Separator />
@@ -49,7 +50,7 @@ export function EmailSignatureModule() {
</Button> </Button>
</div> </div>
{/* Right panel — preview */} {/* Right panel — preview (scrollable, resizable) */}
<div> <div>
<SignaturePreview config={config} /> <SignaturePreview config={config} />
</div> </div>

View File

@@ -2,7 +2,7 @@
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from '@/core/auth/types';
import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types'; import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types';
import { COMPANY_BRANDING } from '../services/company-branding'; import { COMPANY_BRANDING, BELETAGE_ADDRESSES } from '../services/company-branding';
import { Input } from '@/shared/components/ui/input'; import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label'; import { Label } from '@/shared/components/ui/label';
import { Switch } from '@/shared/components/ui/switch'; import { Switch } from '@/shared/components/ui/switch';
@@ -17,13 +17,39 @@ interface SignatureConfiguratorProps {
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void; onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
onSetVariant: (variant: SignatureVariant) => void; onSetVariant: (variant: SignatureVariant) => void;
onSetCompany: (company: CompanyId) => void; onSetCompany: (company: CompanyId) => void;
onSetAddress?: (address: string[]) => void;
} }
const COLOR_PALETTE: Record<string, string> = { /** Color palette per company */
const COMPANY_PALETTES: Record<CompanyId, Record<string, string>> = {
beletage: {
verde: '#22B5AB', verde: '#22B5AB',
griInchis: '#54504F', griInchis: '#54504F',
griDeschis: '#A7A9AA', griDeschis: '#A7A9AA',
negru: '#323232', negru: '#323232',
},
'urban-switch': {
indigo: '#6366f1',
violet: '#4F46E5',
griInchis: '#2D2D2D',
griDeschis: '#6B7280',
albastru: '#3B82F6',
negru: '#1F2937',
},
'studii-de-teren': {
amber: '#f59e0b',
portocaliu: '#D97706',
griInchis: '#2D2D2D',
griDeschis: '#6B7280',
maro: '#92400E',
negru: '#1F2937',
},
group: {
gri: '#64748b',
griInchis: '#334155',
griDeschis: '#94a3b8',
negru: '#1e293b',
},
}; };
const COLOR_LABELS: Record<keyof SignatureColors, string> = { const COLOR_LABELS: Record<keyof SignatureColors, string> = {
@@ -48,8 +74,10 @@ const LAYOUT_CONTROLS: { key: keyof SignatureLayout; label: string; min: number;
]; ];
export function SignatureConfigurator({ export function SignatureConfigurator({
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany, config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany, onSetAddress,
}: SignatureConfiguratorProps) { }: SignatureConfiguratorProps) {
const palette = COMPANY_PALETTES[config.company];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Company selector */} {/* Company selector */}
@@ -67,6 +95,26 @@ export function SignatureConfigurator({
</Select> </Select>
</div> </div>
{/* Address selector (for Beletage) */}
{config.company === 'beletage' && onSetAddress && (
<div>
<Label>Adresă birou</Label>
<Select
value={!config.addressOverride || BELETAGE_ADDRESSES.unirii.join('|') === config.addressOverride.join('|') ? 'unirii' : 'christescu'}
onValueChange={(v) => {
const key = v as keyof typeof BELETAGE_ADDRESSES;
onSetAddress(BELETAGE_ADDRESSES[key]);
}}
>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="unirii">Str. Unirii, nr. 3</SelectItem>
<SelectItem value="christescu">Str. G-ral Eremia Grigorescu, nr. 21</SelectItem>
</SelectContent>
</Select>
</div>
)}
<Separator /> <Separator />
{/* Personal data */} {/* Personal data */}
@@ -113,14 +161,14 @@ export function SignatureConfigurator({
<Separator /> <Separator />
{/* Colors */} {/* Colors — company-specific palette */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-semibold">Culori text</h3> <h3 className="text-sm font-semibold">Culori text</h3>
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => ( {(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
<div key={colorKey} className="flex items-center justify-between"> <div key={colorKey} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span> <span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{Object.values(COLOR_PALETTE).map((color) => ( {Object.values(palette).map((color) => (
<button <button
key={color} key={color}
type="button" type="button"

View File

@@ -6,15 +6,19 @@ import { Button } from '@/shared/components/ui/button';
import type { SignatureConfig } from '../types'; import type { SignatureConfig } from '../types';
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder'; import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
const ZOOM_LEVELS = [0.75, 1, 1.5, 2, 2.5];
interface SignaturePreviewProps { interface SignaturePreviewProps {
config: SignatureConfig; config: SignatureConfig;
} }
export function SignaturePreview({ config }: SignaturePreviewProps) { export function SignaturePreview({ config }: SignaturePreviewProps) {
const [zoom, setZoom] = useState(1); const [zoomIndex, setZoomIndex] = useState(1); // start at 100%
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const zoom = ZOOM_LEVELS[zoomIndex] ?? 1;
const html = useMemo(() => generateSignatureHtml(config), [config]); const html = useMemo(() => generateSignatureHtml(config), [config]);
const handleDownload = () => { const handleDownload = () => {
@@ -32,17 +36,23 @@ export function SignaturePreview({ config }: SignaturePreviewProps) {
} }
}; };
const toggleZoom = () => setZoom((z) => (z === 1 ? 2 : 1)); const zoomIn = () => setZoomIndex((i) => Math.min(i + 1, ZOOM_LEVELS.length - 1));
const zoomOut = () => setZoomIndex((i) => Math.max(i - 1, 0));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Previzualizare</h2> <h2 className="text-lg font-semibold">Previzualizare</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={toggleZoom}> <div className="flex items-center rounded-md border">
{zoom === 1 ? <ZoomIn className="mr-1 h-4 w-4" /> : <ZoomOut className="mr-1 h-4 w-4" />} <Button variant="ghost" size="icon" className="h-8 w-8 rounded-r-none" onClick={zoomOut} disabled={zoomIndex <= 0}>
{zoom === 1 ? '200%' : '100%'} <ZoomOut className="h-4 w-4" />
</Button> </Button>
<span className="w-14 text-center text-sm font-medium">{Math.round(zoom * 100)}%</span>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-l-none" onClick={zoomIn} disabled={zoomIndex >= ZOOM_LEVELS.length - 1}>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={handleCopy}> <Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="mr-1 h-4 w-4" /> <Copy className="mr-1 h-4 w-4" />
{copied ? 'Copiat!' : 'Copiază HTML'} {copied ? 'Copiat!' : 'Copiază HTML'}
@@ -54,7 +64,7 @@ export function SignaturePreview({ config }: SignaturePreviewProps) {
</div> </div>
</div> </div>
<div className="overflow-auto rounded-lg border bg-white p-6"> <div className="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg border bg-white p-6">
<div <div
ref={previewRef} ref={previewRef}
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }} style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}

View File

@@ -68,6 +68,10 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
})); }));
}, []); }, []);
const setAddress = useCallback((address: string[]) => {
setConfig((prev) => ({ ...prev, addressOverride: address }));
}, []);
const resetToDefaults = useCallback(() => { const resetToDefaults = useCallback(() => {
setConfig(createDefaultConfig(config.company)); setConfig(createDefaultConfig(config.company));
}, [config.company]); }, [config.company]);
@@ -83,7 +87,8 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
updateLayout, updateLayout,
setVariant, setVariant,
setCompany, setCompany,
setAddress,
resetToDefaults, resetToDefaults,
loadConfig, loadConfig,
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, resetToDefaults, loadConfig]); }), [config, updateField, updateColor, updateLayout, setVariant, setCompany, setAddress, resetToDefaults, loadConfig]);
} }

View File

@@ -12,25 +12,34 @@ const BELETAGE_COLORS: SignatureColors = {
}; };
const URBAN_SWITCH_COLORS: SignatureColors = { const URBAN_SWITCH_COLORS: SignatureColors = {
prefix: '#3B3B3B', prefix: '#2D2D2D',
name: '#3B3B3B', name: '#2D2D2D',
title: '#8B8B8B', title: '#6B7280',
address: '#8B8B8B', address: '#6B7280',
phone: '#3B3B3B', phone: '#2D2D2D',
website: '#3B3B3B', website: '#4F46E5',
motto: '#6366f1', motto: '#6366f1',
}; };
const STUDII_COLORS: SignatureColors = { const STUDII_COLORS: SignatureColors = {
prefix: '#3B3B3B', prefix: '#2D2D2D',
name: '#3B3B3B', name: '#2D2D2D',
title: '#8B8B8B', title: '#6B7280',
address: '#8B8B8B', address: '#6B7280',
phone: '#3B3B3B', phone: '#2D2D2D',
website: '#3B3B3B', website: '#D97706',
motto: '#f59e0b', motto: '#f59e0b',
}; };
const ADDR_UNIRII = ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'] as const;
const ADDR_CHRISTESCU = ['str. G-ral Eremia Grigorescu, nr. 21', 'Cluj-Napoca, Cluj 400304', 'România'] as const;
/** Available address options for Beletage (toggle between offices) */
export const BELETAGE_ADDRESSES: { unirii: string[]; christescu: string[] } = {
unirii: [...ADDR_UNIRII],
christescu: [...ADDR_CHRISTESCU],
};
export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = { export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
beletage: { beletage: {
id: 'beletage', id: 'beletage',
@@ -48,7 +57,7 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
png: 'https://beletage.ro/img/Green-slash.png', png: 'https://beletage.ro/img/Green-slash.png',
svg: 'https://beletage.ro/img/Green-slash.svg', svg: 'https://beletage.ro/img/Green-slash.svg',
}, },
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'], address: [...ADDR_UNIRII],
website: 'www.beletage.ro', website: 'www.beletage.ro',
motto: 'we make complex simple', motto: 'we make complex simple',
defaultColors: BELETAGE_COLORS, defaultColors: BELETAGE_COLORS,
@@ -58,20 +67,20 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
name: 'Urban Switch SRL', name: 'Urban Switch SRL',
accent: '#6366f1', accent: '#6366f1',
logo: { logo: {
png: '', png: '/logos/logo-us-dark.svg',
svg: '', svg: '/logos/logo-us-dark.svg',
}, },
slashGrey: { slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png', png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg', svg: 'https://beletage.ro/img/Grey-slash.svg',
}, },
slashAccent: { slashAccent: {
png: '', png: '/logos/logo-us-light.svg',
svg: '', svg: '/logos/logo-us-light.svg',
}, },
address: ['Cluj-Napoca', 'România'], address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
website: '', website: 'www.urbanswitch.ro',
motto: '', motto: 'shaping urban futures',
defaultColors: URBAN_SWITCH_COLORS, defaultColors: URBAN_SWITCH_COLORS,
}, },
'studii-de-teren': { 'studii-de-teren': {
@@ -79,20 +88,20 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
name: 'Studii de Teren SRL', name: 'Studii de Teren SRL',
accent: '#f59e0b', accent: '#f59e0b',
logo: { logo: {
png: '', png: '/logos/logo-sdt-dark.svg',
svg: '', svg: '/logos/logo-sdt-dark.svg',
}, },
slashGrey: { slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png', png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg', svg: 'https://beletage.ro/img/Grey-slash.svg',
}, },
slashAccent: { slashAccent: {
png: '', png: '/logos/logo-sdt-light.svg',
svg: '', svg: '/logos/logo-sdt-light.svg',
}, },
address: ['Cluj-Napoca', 'România'], address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
website: '', website: 'www.studiideteren.ro',
motto: '', motto: 'ground truth, measured right',
defaultColors: STUDII_COLORS, defaultColors: STUDII_COLORS,
}, },
group: { group: {
@@ -100,9 +109,12 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
name: 'Grup Companii', name: 'Grup Companii',
accent: '#64748b', accent: '#64748b',
logo: { png: '', svg: '' }, logo: { png: '', svg: '' },
slashGrey: { png: '', svg: '' }, slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg',
},
slashAccent: { png: '', svg: '' }, slashAccent: { png: '', svg: '' },
address: ['Cluj-Napoca', 'România'], address: ['Cluj-Napoca, Cluj', 'România'],
website: '', website: '',
motto: '', motto: '',
defaultColors: BELETAGE_COLORS, defaultColors: BELETAGE_COLORS,

View File

@@ -14,6 +14,7 @@ export function formatPhone(raw: string): { display: string; link: string } {
export function generateSignatureHtml(config: SignatureConfig): string { export function generateSignatureHtml(config: SignatureConfig): string {
const branding = getBranding(config.company); const branding = getBranding(config.company);
const address = config.addressOverride ?? branding.address;
const { display: phone, link: phoneLink } = formatPhone(config.phone); const { display: phone, link: phoneLink } = formatPhone(config.phone);
const images = config.useSvg const images = config.useSvg
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg } ? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
@@ -71,7 +72,7 @@ export function generateSignatureHtml(config: SignatureConfig): string {
</td> </td>
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height: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;"> <td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
<span style="color:${colors.address}; text-decoration:none;">${branding.address.join('<br>')}</span> <span style="color:${colors.address}; text-decoration:none;">${address.join('<br>')}</span>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@@ -48,6 +48,8 @@ export interface SignatureConfig {
layout: SignatureLayout; layout: SignatureLayout;
variant: SignatureVariant; variant: SignatureVariant;
useSvg: boolean; useSvg: boolean;
/** Override the default company address */
addressOverride?: string[];
} }
export interface SavedSignature { export interface SavedSignature {