fix(landing): заголовок «Loading...» и пустая иконка вкладки на лендингах

Публичный лендинг (QuickPurchase) рендерится вне AppShell, а useBranding
с заголовком/иконкой завязан на авторизацию — поэтому на лендинге вкладка
оставалась с дефолтным «Loading...» из index.html (тайтл обновлялся только при
заданном meta_title) и без favicon (href="data:,").

- index.html: нейтральная дефолтная иконка-монограмма вместо пустого data-URI
  и тайтл «VPN» вместо «Loading...».
- QuickPurchase: тайтл берётся из meta_title || title лендинга; favicon
  ставится из брендинга (кастомный лого-блоб или буквенная монограмма), брендинг
  тянется из публичного эндпоинта.
- favicon-хелперы вынесены в utils/favicon.ts; useBranding переведён на них
  (DRY) + добавлен буквенный фолбэк, чтобы иконка была всегда.
- Тип LandingTariff дополнен is_daily/daily_price_kopeks (синхронно с бэком).
This commit is contained in:
c0mrade
2026-06-03 11:54:23 +03:00
parent cecfe7ec03
commit 5a6e4589c8
5 changed files with 92 additions and 20 deletions

View File

@@ -2,7 +2,10 @@
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:," />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3E%3Crect%20width='64'%20height='64'%20rx='14'%20fill='%230a0f1a'/%3E%3Ctext%20x='50%25'%20y='50%25'%20font-family='Manrope,Arial,sans-serif'%20font-size='38'%20font-weight='700'%20fill='%23ffffff'%20text-anchor='middle'%20dominant-baseline='central'%3EV%3C/text%3E%3C/svg%3E"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
@@ -12,7 +15,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<title>Loading...</title>
<title>VPN</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
@@ -30,7 +33,8 @@
<script>
// Load Telegram Web App script only inside Telegram environment
(function () {
var isTelegram = window.TelegramWebviewProxy ||
var isTelegram =
window.TelegramWebviewProxy ||
location.hash.indexOf('tgWebApp') !== -1 ||
location.search.indexOf('tgWebApp') !== -1;
if (isTelegram) {

View File

@@ -25,6 +25,9 @@ export interface LandingTariff {
device_limit: number;
tier_level: number;
periods: LandingTariffPeriod[];
/** Daily tariff: the single purchasable period is 1 day, priced per day. */
is_daily?: boolean;
daily_price_kopeks?: number;
}
export interface LandingPaymentMethodSubOption {

View File

@@ -9,6 +9,7 @@ import {
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
import { setFavicon, letterFaviconDataUri } from '@/utils/favicon';
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
@@ -42,18 +43,11 @@ export function useBranding() {
document.title = appName || 'VPN';
}, [appName]);
// Update favicon
// Update favicon — custom logo when available, else a brand-letter monogram
// so the tab always carries an icon instead of the browser default.
useEffect(() => {
if (!logoUrl) return;
const link =
document.querySelector<HTMLLinkElement>("link[rel*='icon']") ||
document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = logoUrl;
document.head.appendChild(link);
}, [logoUrl]);
setFavicon(logoUrl || letterFaviconDataUri(logoLetter));
}, [logoUrl, logoLetter]);
// Fullscreen setting from server
const { data: fullscreenSetting } = useQuery({

View File

@@ -7,6 +7,13 @@ import { fireAnalyticsEvent, getYandexCid } from '../hooks/useAnalyticsCounters'
import { motion, AnimatePresence } from 'framer-motion';
import DOMPurify from 'dompurify';
import { landingApi } from '../api/landings';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
getLogoBlobUrl,
} from '../api/branding';
import type {
LandingConfig,
LandingTariff,
@@ -20,6 +27,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 { useCurrency } from '../hooks/useCurrency';
function detectContactType(value: string): 'email' | 'telegram' {
@@ -772,6 +780,31 @@ export default function QuickPurchase() {
retry: 1,
});
// Public branding — drives the favicon on this standalone landing page.
// The cabinet's useBranding hook is auth-gated and AppShell-only, so a public
// landing would otherwise keep the empty index.html favicon. The branding
// endpoint is public; logo is preloaded as a blob to keep the backend URL out
// of the DOM (same pattern as the authenticated app).
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
await preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 60_000,
retry: 1,
});
useEffect(() => {
if (!branding) return;
const logoUrl = branding.has_custom_logo ? getLogoBlobUrl() : null;
setFavicon(logoUrl || letterFaviconDataUri(branding.logo_letter));
}, [branding]);
const [discountExpired, setDiscountExpired] = useState(false);
const handleDiscountExpired = useCallback(() => {
@@ -900,15 +933,18 @@ export default function QuickPurchase() {
}
}, [visibleTariffs, selectedTariffId]);
// SEO: set document title
// SEO: set document title. Fall back to the landing's own title when no
// dedicated meta_title is set — otherwise the tab keeps the static
// index.html "VPN" placeholder and never reflects the landing.
useEffect(() => {
if (!config?.meta_title) return;
const pageTitle = config?.meta_title || config?.title;
if (!pageTitle) return;
const prev = document.title;
document.title = config.meta_title;
document.title = pageTitle;
return () => {
document.title = prev;
};
}, [config?.meta_title]);
}, [config?.meta_title, config?.title]);
// SEO: set meta description
useEffect(() => {

35
src/utils/favicon.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Favicon helpers.
*
* The static fallback in index.html is a neutral monogram so the tab is never
* left without an icon. Once branding is known we override it with the custom
* logo (or a brand-letter monogram) via {@link setFavicon}.
*/
/** Point the page favicon at `href`, creating the <link> if needed. */
export function setFavicon(href: string): void {
if (!href) return;
let link = document.querySelector<HTMLLinkElement>("link[rel*='icon']");
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = href;
}
/**
* Generate a square monogram favicon (SVG data URI) from a brand letter.
* Used when the deployment has no custom logo, so every page still gets an
* icon that matches the brand letter instead of the browser's blank default.
*/
export function letterFaviconDataUri(letter: string): string {
const ch = (letter || 'V').trim().charAt(0).toUpperCase() || 'V';
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
`<rect width="64" height="64" rx="14" fill="#0a0f1a"/>` +
`<text x="50%" y="50%" font-family="Manrope,Arial,sans-serif" font-size="38" ` +
`font-weight="700" fill="#ffffff" text-anchor="middle" dominant-baseline="central">${ch}</text>` +
`</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}