3.05 Email Signature Automatizare si Branding

- AD prefill: 'Din cont' button pre-fills name + company from Authentik session
- Logo size slider: 50%-200% scale control in Stil & Aranjare section
- Promotional banner: configurable image+link below signature with preview
- US/SDT custom graphics: dedicated dash (US) and dot (SDT) decorative icons
  replacing Beletage's grey/accent slashes for company-specific branding
This commit is contained in:
AI Assistant
2026-02-28 02:02:04 +02:00
parent 0a939417d8
commit b4338571cc
10 changed files with 298 additions and 50 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="7" viewBox="0 0 11 7">
<circle cx="5.5" cy="3.5" r="2.5" fill="#0182A1"/>
</svg>

After

Width:  |  Height:  |  Size: 142 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 11 11">
<circle cx="5.5" cy="5.5" r="3" fill="#A7A9AA"/>
</svg>

After

Width:  |  Height:  |  Size: 142 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="7" viewBox="0 0 11 7">
<rect x="1" y="2.5" width="9" height="2" rx="1" fill="#345476"/>
</svg>

After

Width:  |  Height:  |  Size: 156 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 11 11">
<rect x="1" y="4.5" width="9" height="2" rx="1" fill="#A7A9AA"/>
</svg>

After

Width:  |  Height:  |  Size: 158 B

@@ -8,6 +8,7 @@ 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';
import type { SignatureBanner } from '../types';
export function EmailSignatureModule() {
const {
@@ -17,6 +18,10 @@ export function EmailSignatureModule() {
const { saved, loading, save, remove } = useSavedSignatures();
const setBanner = (banner: SignatureBanner | undefined) => {
updateField('banner', banner);
};
return (
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
{/* Left panel — configurator */}
@@ -29,6 +34,7 @@ export function EmailSignatureModule() {
onSetVariant={setVariant}
onSetCompany={setCompany}
onSetAddress={setAddress}
onSetBanner={setBanner}
/>
<Separator />
@@ -6,6 +6,7 @@ import type {
SignatureColors,
SignatureLayout,
SignatureVariant,
SignatureBanner,
} from "../types";
import {
COMPANY_BRANDING,
@@ -14,9 +15,11 @@ import {
SDT_ADDRESSES,
} from "../services/company-branding";
import type { AddressKey } from "../services/company-branding";
import { useAuth } from "@/core/auth";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Switch } from "@/shared/components/ui/switch";
import { Button } from "@/shared/components/ui/button";
import {
Select,
SelectContent,
@@ -26,6 +29,7 @@ import {
} from "@/shared/components/ui/select";
import { Separator } from "@/shared/components/ui/separator";
import { cn } from "@/shared/lib/utils";
import { UserCheck, ImagePlus, Trash2 } from "lucide-react";
interface SignatureConfiguratorProps {
config: SignatureConfig;
@@ -38,6 +42,7 @@ interface SignatureConfiguratorProps {
onSetVariant: (variant: SignatureVariant) => void;
onSetCompany: (company: CompanyId) => void;
onSetAddress?: (address: string[]) => void;
onSetBanner?: (banner: SignatureBanner | undefined) => void;
}
/** Color palette per company */
@@ -108,8 +113,10 @@ export function SignatureConfigurator({
onSetVariant,
onSetCompany,
onSetAddress,
onSetBanner,
}: SignatureConfiguratorProps) {
const palette = COMPANY_PALETTES[config.company];
const { user } = useAuth();
return (
<div className="space-y-6">
@@ -248,7 +255,25 @@ export function SignatureConfigurator({
{/* Personal data */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Date personale</h3>
{user && user.name && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => {
onUpdateField("name", user.name);
if (user.company && user.company !== "group") {
onSetCompany(user.company);
}
}}
>
<UserCheck className="mr-1 h-3 w-3" />
Din cont
</Button>
)}
</div>
<div>
<Label htmlFor="sig-prefix">Titulatură (prefix)</Label>
<Input
@@ -359,6 +384,28 @@ export function SignatureConfigurator({
{/* Layout sliders */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Stil & Aranjare</h3>
{/* Logo scale slider */}
<div>
<div className="flex justify-between text-sm">
<Label>Dimensiune logo</Label>
<span className="text-muted-foreground">
{Math.round((config.layout.logoScale ?? 1) * 100)}%
</span>
</div>
<input
type="range"
min={50}
max={200}
step={5}
value={Math.round((config.layout.logoScale ?? 1) * 100)}
onChange={(e) =>
onUpdateLayout("logoScale", parseInt(e.target.value, 10) / 100)
}
className="mt-1 w-full accent-primary"
/>
</div>
{LAYOUT_CONTROLS.map(({ key, label, min, max }) => (
<div key={key}>
<div className="flex justify-between text-sm">
@@ -380,6 +427,137 @@ export function SignatureConfigurator({
</div>
))}
</div>
<Separator />
{/* Promotional banner */}
{onSetBanner && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Banner promoțional</h3>
{config.banner ? (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => onSetBanner(undefined)}
>
<Trash2 className="mr-1 h-3 w-3" />
Șterge
</Button>
) : (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
onSetBanner({
imageUrl: "",
linkUrl: "",
alt: "Banner",
width: 540,
height: 80,
})
}
>
<ImagePlus className="mr-1 h-3 w-3" />
Adaugă
</Button>
)}
</div>
{config.banner && (
<div className="space-y-2">
<div>
<Label htmlFor="banner-img">URL Imagine</Label>
<Input
id="banner-img"
placeholder="https://example.com/banner.png"
value={config.banner.imageUrl}
onChange={(e) =>
onSetBanner({
...config.banner!,
imageUrl: e.target.value,
})
}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="banner-link">URL Link (click)</Label>
<Input
id="banner-link"
placeholder="https://example.com"
value={config.banner.linkUrl}
onChange={(e) =>
onSetBanner({
...config.banner!,
linkUrl: e.target.value,
})
}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="banner-alt">Text alternativ</Label>
<Input
id="banner-alt"
placeholder="Banner promoțional"
value={config.banner.alt}
onChange={(e) =>
onSetBanner({ ...config.banner!, alt: e.target.value })
}
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor="banner-w">Lățime (px)</Label>
<Input
id="banner-w"
type="number"
value={config.banner.width}
onChange={(e) =>
onSetBanner({
...config.banner!,
width: parseInt(e.target.value, 10) || 540,
})
}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="banner-h">Înălțime (px)</Label>
<Input
id="banner-h"
type="number"
value={config.banner.height}
onChange={(e) =>
onSetBanner({
...config.banner!,
height: parseInt(e.target.value, 10) || 80,
})
}
className="mt-1"
/>
</div>
</div>
{config.banner.imageUrl && (
<div className="rounded-md border p-2">
<p className="mb-1 text-xs text-muted-foreground">
Previzualizare:
</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={config.banner.imageUrl}
alt={config.banner.alt || "Banner"}
className="max-h-24 w-full rounded object-contain"
/>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
@@ -1,9 +1,14 @@
'use client';
"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';
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,
@@ -14,46 +19,55 @@ const DEFAULT_LAYOUT: SignatureLayout = {
sectionSpacing: 10,
titleSpacing: 2,
logoSpacing: 10,
logoScale: 1,
};
function createDefaultConfig(company: CompanyId = 'beletage'): SignatureConfig {
function createDefaultConfig(company: CompanyId = "beletage"): SignatureConfig {
const branding = getBranding(company);
return {
prefix: 'arh.',
name: '',
title: '',
phone: '',
prefix: "arh.",
name: "",
title: "",
phone: "",
company,
colors: { ...branding.defaultColors },
layout: { ...DEFAULT_LAYOUT },
variant: 'full',
variant: "full",
useSvg: false,
};
}
export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
const [config, setConfig] = useState<SignatureConfig>(() => createDefaultConfig(initialCompany));
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]
) => {
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) => {
const updateColor = useCallback(
(key: keyof SignatureColors, value: string) => {
setConfig((prev) => ({
...prev,
colors: { ...prev.colors, [key]: value },
}));
}, []);
},
[],
);
const updateLayout = useCallback((key: keyof SignatureLayout, value: number) => {
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 }));
@@ -80,7 +94,8 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
setConfig(loaded);
}, []);
return useMemo(() => ({
return useMemo(
() => ({
config,
updateField,
updateColor,
@@ -90,5 +105,17 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
setAddress,
resetToDefaults,
loadConfig,
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, setAddress, resetToDefaults, loadConfig]);
}),
[
config,
updateField,
updateColor,
updateLayout,
setVariant,
setCompany,
setAddress,
resetToDefaults,
loadConfig,
],
);
}
@@ -103,10 +103,13 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
svg: "/logos/logo-us-light.svg",
},
slashGrey: {
png: "https://beletage.ro/img/Grey-slash.png",
svg: "https://beletage.ro/img/Grey-slash.svg",
png: "/logos/us-dash-grey.svg",
svg: "/logos/us-dash-grey.svg",
},
slashAccent: {
png: "/logos/us-dash-accent.svg",
svg: "/logos/us-dash-accent.svg",
},
slashAccent: { png: "", svg: "" },
logoDimensions: { width: 140, height: 24 },
address: [...ADDR_CHRISTESCU],
website: "www.urbanswitch.ro",
@@ -122,10 +125,13 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
svg: "/logos/logo-sdt-light.svg",
},
slashGrey: {
png: "https://beletage.ro/img/Grey-slash.png",
svg: "https://beletage.ro/img/Grey-slash.svg",
png: "/logos/sdt-dot-grey.svg",
svg: "/logos/sdt-dot-grey.svg",
},
slashAccent: {
png: "/logos/sdt-dot-accent.svg",
svg: "/logos/sdt-dot-accent.svg",
},
slashAccent: { png: "", svg: "" },
logoDimensions: { width: 71, height: 24 },
address: [...ADDR_CHRISTESCU],
website: "www.studiideteren.ro",
@@ -37,13 +37,19 @@ export function generateSignatureHtml(config: SignatureConfig): string {
sectionSpacing,
titleSpacing,
logoSpacing,
logoScale,
} = config.layout;
const colors = config.colors;
const isReply = config.variant === "reply" || config.variant === "minimal";
const isMinimal = config.variant === "minimal";
const logoDim = branding.logoDimensions ?? { width: 162, height: 24 };
const rawDim = branding.logoDimensions ?? { width: 162, height: 24 };
const scale = logoScale ?? 1;
const logoDim = {
width: Math.round(rawDim.width * scale),
height: Math.round(rawDim.height * scale),
};
const hide =
"mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;";
@@ -122,6 +128,7 @@ export function generateSignatureHtml(config: SignatureConfig): string {
</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>` : ""}
${config.banner?.imageUrl ? `<tr style="${hideBottom}"><td style="padding:12px 0 0 0;"><a href="${esc(config.banner.linkUrl || "#")}" style="text-decoration:none; border:0;"><img src="${esc(config.banner.imageUrl)}" alt="${esc(config.banner.alt || "Banner")}" width="${config.banner.width || 540}" height="${config.banner.height || 80}" style="display:block; border:0; max-width:540px; height:auto;"></a></td></tr>` : ""}
</tbody>
</table>`;
}
+12
View File
@@ -21,6 +21,16 @@ export interface SignatureLayout {
sectionSpacing: number;
titleSpacing: number;
logoSpacing: number;
/** Logo scale factor (0.52.0, default 1.0) */
logoScale: number;
}
export interface SignatureBanner {
imageUrl: string;
linkUrl: string;
alt: string;
width: number;
height: number;
}
export interface CompanyBranding {
@@ -52,6 +62,8 @@ export interface SignatureConfig {
useSvg: boolean;
/** Override the default company address */
addressOverride?: string[];
/** Optional promotional banner below signature */
banner?: SignatureBanner;
}
export interface SavedSignature {