mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 01:23:47 +00:00
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:
10
index.html
10
index.html
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
35
src/utils/favicon.ts
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user