mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
fix(favicon): скругляем углы кастомного лого как у плитки в хедере
Кастомное лого квадратное, и в фавиконе показывалось с острыми углами. Теперь лого прогоняется через canvas со скруглённым клипом (radiusRatio 0.3 — как rounded-linear-lg 12px на плитке 40px в хедере) и отдаётся скруглённым PNG. При сбое canvas (taint/нет 2d-контекста) — фолбэк на сырой лого. Применяется и в кабинете (useBranding), и на лендинге (QuickPurchase); буквенная монограмма уже скруглена через rx.
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
|||||||
preloadLogo,
|
preloadLogo,
|
||||||
isLogoPreloaded,
|
isLogoPreloaded,
|
||||||
} from '@/api/branding';
|
} 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_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
|
||||||
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
|
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
|
||||||
@@ -43,10 +43,20 @@ export function useBranding() {
|
|||||||
document.title = appName || 'VPN';
|
document.title = appName || 'VPN';
|
||||||
}, [appName]);
|
}, [appName]);
|
||||||
|
|
||||||
// Update favicon — custom logo when available, else a brand-letter monogram
|
// Update favicon — custom logo (rounded like the header tile) when available,
|
||||||
// so the tab always carries an icon instead of the browser default.
|
// else a brand-letter monogram so the tab always carries an icon.
|
||||||
useEffect(() => {
|
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]);
|
}, [logoUrl, logoLetter]);
|
||||||
|
|
||||||
// Fullscreen setting from server
|
// Fullscreen setting from server
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
|
|||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { getApiErrorMessage } from '../utils/api-error';
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
import { formatPrice } from '../utils/format';
|
import { formatPrice } from '../utils/format';
|
||||||
import { setFavicon, letterFaviconDataUri } from '../utils/favicon';
|
import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '../utils/favicon';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
|
||||||
function detectContactType(value: string): 'email' | 'telegram' {
|
function detectContactType(value: string): 'email' | 'telegram' {
|
||||||
@@ -805,7 +805,18 @@ export default function QuickPurchase() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!branding) return;
|
if (!branding) return;
|
||||||
const logoUrl = branding.has_custom_logo ? getLogoBlobUrl() : null;
|
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]);
|
}, [branding]);
|
||||||
|
|
||||||
const [discountExpired, setDiscountExpired] = useState(false);
|
const [discountExpired, setDiscountExpired] = useState(false);
|
||||||
|
|||||||
@@ -33,3 +33,62 @@ export function letterFaviconDataUri(letter: string): string {
|
|||||||
`</svg>`;
|
`</svg>`;
|
||||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
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<string | null> {
|
||||||
|
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<HTMLImageElement> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user