diff --git a/src/hooks/useBranding.ts b/src/hooks/useBranding.ts index 2966848..916809f 100644 --- a/src/hooks/useBranding.ts +++ b/src/hooks/useBranding.ts @@ -9,7 +9,7 @@ import { preloadLogo, isLogoPreloaded, } from '@/api/branding'; -import { setFavicon, letterFaviconDataUri } from '@/utils/favicon'; +import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '@/utils/favicon'; const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; @@ -43,10 +43,20 @@ export function useBranding() { document.title = appName || 'VPN'; }, [appName]); - // Update favicon — custom logo when available, else a brand-letter monogram - // so the tab always carries an icon instead of the browser default. + // Update favicon — custom logo (rounded like the header tile) when available, + // else a brand-letter monogram so the tab always carries an icon. useEffect(() => { - setFavicon(logoUrl || letterFaviconDataUri(logoLetter)); + if (!logoUrl) { + setFavicon(letterFaviconDataUri(logoLetter)); + return; + } + let cancelled = false; + roundedFaviconDataUri(logoUrl).then((rounded) => { + if (!cancelled) setFavicon(rounded || logoUrl); + }); + return () => { + cancelled = true; + }; }, [logoUrl, logoLetter]); // Fullscreen setting from server diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index b054eb1..a6fa30f 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -30,7 +30,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher'; import { cn } from '../lib/utils'; import { getApiErrorMessage } from '../utils/api-error'; import { formatPrice } from '../utils/format'; -import { setFavicon, letterFaviconDataUri } from '../utils/favicon'; +import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '../utils/favicon'; import { useCurrency } from '../hooks/useCurrency'; function detectContactType(value: string): 'email' | 'telegram' { @@ -805,7 +805,18 @@ export default function QuickPurchase() { useEffect(() => { if (!branding) return; const logoUrl = branding.has_custom_logo ? getLogoBlobUrl() : null; - setFavicon(logoUrl || letterFaviconDataUri(branding.logo_letter)); + if (!logoUrl) { + setFavicon(letterFaviconDataUri(branding.logo_letter)); + return; + } + let cancelled = false; + // Round the custom logo like the header tile instead of a hard square. + roundedFaviconDataUri(logoUrl).then((rounded) => { + if (!cancelled) setFavicon(rounded || logoUrl); + }); + return () => { + cancelled = true; + }; }, [branding]); const [discountExpired, setDiscountExpired] = useState(false); diff --git a/src/utils/favicon.ts b/src/utils/favicon.ts index dbbdbc7..bdb0f51 100644 --- a/src/utils/favicon.ts +++ b/src/utils/favicon.ts @@ -33,3 +33,62 @@ export function letterFaviconDataUri(letter: string): string { ``; return `data:image/svg+xml,${encodeURIComponent(svg)}`; } + +/** + * Render `src` (e.g. a square custom logo) into a rounded-corner PNG data URI + * so the favicon gets the same rounded tile as the header logo, instead of + * hard square corners. + * + * `radiusRatio` 0.3 mirrors the header tile (rounded-linear-lg = 12px on a 40px + * tile). Returns null if the image can't be loaded or the canvas is tainted — + * the caller should fall back to the raw `src`. + */ +export async function roundedFaviconDataUri( + src: string, + size = 64, + radiusRatio = 0.3, +): Promise { + if (!src) return null; + try { + const img = await loadImage(src); + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + traceRoundedRect(ctx, size, size * radiusRatio); + ctx.clip(); + + // object-fit: cover — fill the rounded tile, center-cropping any overflow. + const scale = Math.max(size / img.width, size / img.height); + const dw = img.width * scale; + const dh = img.height * scale; + ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh); + + return canvas.toDataURL('image/png'); + } catch { + return null; + } +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); +} + +/** Trace a centered square rounded-rect path of side `size` and corner `r`. */ +function traceRoundedRect(ctx: CanvasRenderingContext2D, size: number, r: number): void { + const radius = Math.min(r, size / 2); + ctx.beginPath(); + ctx.moveTo(radius, 0); + ctx.arcTo(size, 0, size, size, radius); + ctx.arcTo(size, size, 0, size, radius); + ctx.arcTo(0, size, 0, 0, radius); + ctx.arcTo(0, 0, size, 0, radius); + ctx.closePath(); +}