mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +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">
|
<html lang="ru" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
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="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
@@ -30,7 +33,8 @@
|
|||||||
<script>
|
<script>
|
||||||
// Load Telegram Web App script only inside Telegram environment
|
// Load Telegram Web App script only inside Telegram environment
|
||||||
(function () {
|
(function () {
|
||||||
var isTelegram = window.TelegramWebviewProxy ||
|
var isTelegram =
|
||||||
|
window.TelegramWebviewProxy ||
|
||||||
location.hash.indexOf('tgWebApp') !== -1 ||
|
location.hash.indexOf('tgWebApp') !== -1 ||
|
||||||
location.search.indexOf('tgWebApp') !== -1;
|
location.search.indexOf('tgWebApp') !== -1;
|
||||||
if (isTelegram) {
|
if (isTelegram) {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface LandingTariff {
|
|||||||
device_limit: number;
|
device_limit: number;
|
||||||
tier_level: number;
|
tier_level: number;
|
||||||
periods: LandingTariffPeriod[];
|
periods: LandingTariffPeriod[];
|
||||||
|
/** Daily tariff: the single purchasable period is 1 day, priced per day. */
|
||||||
|
is_daily?: boolean;
|
||||||
|
daily_price_kopeks?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LandingPaymentMethodSubOption {
|
export interface LandingPaymentMethodSubOption {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
preloadLogo,
|
preloadLogo,
|
||||||
isLogoPreloaded,
|
isLogoPreloaded,
|
||||||
} from '@/api/branding';
|
} from '@/api/branding';
|
||||||
|
import { setFavicon, letterFaviconDataUri } 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';
|
||||||
@@ -42,18 +43,11 @@ export function useBranding() {
|
|||||||
document.title = appName || 'VPN';
|
document.title = appName || 'VPN';
|
||||||
}, [appName]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!logoUrl) return;
|
setFavicon(logoUrl || letterFaviconDataUri(logoLetter));
|
||||||
|
}, [logoUrl, logoLetter]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Fullscreen setting from server
|
// Fullscreen setting from server
|
||||||
const { data: fullscreenSetting } = useQuery({
|
const { data: fullscreenSetting } = useQuery({
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { fireAnalyticsEvent, getYandexCid } from '../hooks/useAnalyticsCounters'
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { landingApi } from '../api/landings';
|
import { landingApi } from '../api/landings';
|
||||||
|
import {
|
||||||
|
brandingApi,
|
||||||
|
getCachedBranding,
|
||||||
|
setCachedBranding,
|
||||||
|
preloadLogo,
|
||||||
|
getLogoBlobUrl,
|
||||||
|
} from '../api/branding';
|
||||||
import type {
|
import type {
|
||||||
LandingConfig,
|
LandingConfig,
|
||||||
LandingTariff,
|
LandingTariff,
|
||||||
@@ -20,6 +27,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 { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
|
||||||
function detectContactType(value: string): 'email' | 'telegram' {
|
function detectContactType(value: string): 'email' | 'telegram' {
|
||||||
@@ -772,6 +780,31 @@ export default function QuickPurchase() {
|
|||||||
retry: 1,
|
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 [discountExpired, setDiscountExpired] = useState(false);
|
||||||
|
|
||||||
const handleDiscountExpired = useCallback(() => {
|
const handleDiscountExpired = useCallback(() => {
|
||||||
@@ -900,15 +933,18 @@ export default function QuickPurchase() {
|
|||||||
}
|
}
|
||||||
}, [visibleTariffs, selectedTariffId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!config?.meta_title) return;
|
const pageTitle = config?.meta_title || config?.title;
|
||||||
|
if (!pageTitle) return;
|
||||||
const prev = document.title;
|
const prev = document.title;
|
||||||
document.title = config.meta_title;
|
document.title = pageTitle;
|
||||||
return () => {
|
return () => {
|
||||||
document.title = prev;
|
document.title = prev;
|
||||||
};
|
};
|
||||||
}, [config?.meta_title]);
|
}, [config?.meta_title, config?.title]);
|
||||||
|
|
||||||
// SEO: set meta description
|
// SEO: set meta description
|
||||||
useEffect(() => {
|
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