fix(ui): finish design-audit follow-ups

- useFeatureFlags: кэш флагов в localStorage — нижняя навигация больше
  не прыгает на холодном старте (таб «Рефералы»/«Колесо» появлялся после
  загрузки terms/config)
- applyThemeColors: частичный/битый ответ /branding/colors добивается
  дефолтами вместо падения всего приложения в ErrorBoundary
- карточка «Связаться с поддержкой»: кнопка больше не дублирует заголовок
  («Написать», без переноса)
- «Мои подписки»: заголовок и «Купить ещё» в одну строку на 390px
- StatCard: подписи плиток переносятся на две строки вместо обрезки
  многоточием («Бонус новому польз…»)
This commit is contained in:
c0mrade
2026-06-11 16:00:19 +03:00
parent 7f68e2cd5e
commit 9456c7f54e
9 changed files with 70 additions and 20 deletions

View File

@@ -52,7 +52,7 @@ export function StatCard({
return ( return (
<div className="h-full rounded-xl bg-dark-800/30 p-3 transition-colors hover:bg-dark-800/50"> <div className="h-full rounded-xl bg-dark-800/30 p-3 transition-colors hover:bg-dark-800/50">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-dark-500 sm:text-sm">{label}</span> <span className="line-clamp-2 text-xs leading-tight text-dark-500 sm:text-sm">{label}</span>
{trailing} {trailing}
</div> </div>
{/* Chip is centred against the value line only (delta sits below the whole {/* Chip is centred against the value line only (delta sits below the whole

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth'; import { useAuthStore } from '@/store/auth';
import { brandingApi } from '@/api/branding'; import { brandingApi } from '@/api/branding';
@@ -6,8 +7,31 @@ import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests'; import { contestsApi } from '@/api/contests';
import { pollsApi } from '@/api/polls'; import { pollsApi } from '@/api/polls';
// Последние известные значения флагов. Пока запросы в полёте, флаги были
// undefined -> табы «Рефералы»/«Колесо» появлялись с задержкой и нижняя
// навигация прыгала на каждом холодном старте. Кэш убирает layout shift;
// при изменении флага на бэке UI догонит после ответа запроса.
const FLAGS_CACHE_KEY = 'cabinet-feature-flags';
type CachedFlags = {
referralEnabled?: boolean;
wheelEnabled?: boolean;
hasContests?: boolean;
hasPolls?: boolean;
giftEnabled?: boolean;
};
function readFlagsCache(): CachedFlags {
try {
return JSON.parse(localStorage.getItem(FLAGS_CACHE_KEY) || '{}') as CachedFlags;
} catch {
return {};
}
}
export function useFeatureFlags() { export function useFeatureFlags() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const cached = readFlagsCache();
const { data: referralTerms } = useQuery({ const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'], queryKey: ['referral-terms'],
@@ -49,11 +73,29 @@ export function useFeatureFlags() {
retry: false, retry: false,
}); });
return { const flags = {
referralEnabled: referralTerms?.is_enabled, referralEnabled: referralTerms ? referralTerms.is_enabled : cached.referralEnabled,
wheelEnabled: wheelConfig?.is_enabled, wheelEnabled: wheelConfig ? wheelConfig.is_enabled : cached.wheelEnabled,
hasContests: (contestsCount?.count ?? 0) > 0, hasContests: contestsCount ? contestsCount.count > 0 : cached.hasContests,
hasPolls: (pollsCount?.count ?? 0) > 0, hasPolls: pollsCount ? pollsCount.count > 0 : cached.hasPolls,
giftEnabled: giftConfig?.enabled, giftEnabled: giftConfig ? giftConfig.enabled : cached.giftEnabled,
}; };
useEffect(() => {
if (!referralTerms && !wheelConfig && !contestsCount && !pollsCount && !giftConfig) return;
try {
localStorage.setItem(FLAGS_CACHE_KEY, JSON.stringify(flags));
} catch {
// квоты/приватный режим — некритично
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
flags.referralEnabled,
flags.wheelEnabled,
flags.hasContests,
flags.hasPolls,
flags.giftEnabled,
]);
return flags;
} }

View File

@@ -110,7 +110,10 @@ function onColorFor(bgTriplet: string): string {
} }
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support) // Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
export function applyThemeColors(colors: ThemeColors): void { export function applyThemeColors(themeColors: ThemeColors): void {
// Частичный/битый ответ /branding/colors раньше ронял ВСЁ приложение в
// ErrorBoundary (hexToRgb(undefined)). Недостающие поля добиваем дефолтами.
const colors: ThemeColors = { ...DEFAULT_THEME_COLORS, ...themeColors };
const root = document.documentElement; const root = document.documentElement;
// Generate palettes from status colors // Generate palettes from status colors

View File

@@ -1045,7 +1045,8 @@
"openSupport": "Open Support", "openSupport": "Open Support",
"contactSupport": "Please contact {{username}} for support", "contactSupport": "Please contact {{username}} for support",
"contactUs": "Contact Support", "contactUs": "Contact Support",
"create_ticket": "Create ticket" "create_ticket": "Create ticket",
"writeButton": "Message"
}, },
"wheel": { "wheel": {
"title": "Fortune Wheel", "title": "Fortune Wheel",
@@ -5242,7 +5243,7 @@
}, },
"subscriptions": { "subscriptions": {
"buy": "Buy subscription", "buy": "Buy subscription",
"buyAnother": "Buy another subscription", "buyAnother": "Buy another",
"browsePlans": "View plans and subscribe", "browsePlans": "View plans and subscribe",
"empty": "You have no subscriptions yet", "empty": "You have no subscriptions yet",
"emptyDesc": "Choose a tariff to get started", "emptyDesc": "Choose a tariff to get started",

View File

@@ -928,7 +928,8 @@
"openSupport": "باز کردن پشتیبانی", "openSupport": "باز کردن پشتیبانی",
"ticketsDisabled": "تیکت‌ها غیرفعال است", "ticketsDisabled": "تیکت‌ها غیرفعال است",
"useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید", "useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید",
"create_ticket": "ایجاد تیکت" "create_ticket": "ایجاد تیکت",
"writeButton": "پیام"
}, },
"wheel": { "wheel": {
"title": "چرخ شانس", "title": "چرخ شانس",
@@ -4876,7 +4877,7 @@
}, },
"subscriptions": { "subscriptions": {
"buy": "خرید اشتراک", "buy": "خرید اشتراک",
"buyAnother": "خرید اشتراک دیگر", "buyAnother": "خرید دیگر",
"browsePlans": "مشاهده تعرفه‌ها و خرید اشتراک", "browsePlans": "مشاهده تعرفه‌ها و خرید اشتراک",
"empty": "هنوز اشتراکی ندارید", "empty": "هنوز اشتراکی ندارید",
"emptyDesc": "برای شروع تعرفه‌ای انتخاب کنید", "emptyDesc": "برای شروع تعرفه‌ای انتخاب کنید",

View File

@@ -1074,7 +1074,8 @@
"openSupport": "Открыть поддержку", "openSupport": "Открыть поддержку",
"contactSupport": "Для получения поддержки обратитесь к {{username}}", "contactSupport": "Для получения поддержки обратитесь к {{username}}",
"contactUs": "Связаться с поддержкой", "contactUs": "Связаться с поддержкой",
"create_ticket": "Создать тикет" "create_ticket": "Создать тикет",
"writeButton": "Написать"
}, },
"wheel": { "wheel": {
"title": "Колесо удачи", "title": "Колесо удачи",
@@ -5804,7 +5805,7 @@
}, },
"subscriptions": { "subscriptions": {
"buy": "Купить подписку", "buy": "Купить подписку",
"buyAnother": "Купить ещё подписку", "buyAnother": "Купить ещё",
"browsePlans": "Посмотреть тарифы и купить подписку", "browsePlans": "Посмотреть тарифы и купить подписку",
"empty": "У вас пока нет подписок", "empty": "У вас пока нет подписок",
"emptyDesc": "Выберите тариф, чтобы начать пользоваться сервисом", "emptyDesc": "Выберите тариф, чтобы начать пользоваться сервисом",

View File

@@ -928,7 +928,8 @@
"openSupport": "打开支持", "openSupport": "打开支持",
"ticketsDisabled": "工单已禁用", "ticketsDisabled": "工单已禁用",
"useExternalLink": "请使用外部链接获取支持", "useExternalLink": "请使用外部链接获取支持",
"create_ticket": "创建工单" "create_ticket": "创建工单",
"writeButton": "发消息"
}, },
"wheel": { "wheel": {
"title": "幸运转盘", "title": "幸运转盘",
@@ -4875,7 +4876,7 @@
}, },
"subscriptions": { "subscriptions": {
"buy": "购买订阅", "buy": "购买订阅",
"buyAnother": "再购买一个订阅", "buyAnother": "再购买",
"browsePlans": "查看套餐并订阅", "browsePlans": "查看套餐并订阅",
"empty": "您还没有订阅", "empty": "您还没有订阅",
"emptyDesc": "选择套餐以开始使用", "emptyDesc": "选择套餐以开始使用",

View File

@@ -109,15 +109,15 @@ export default function Subscriptions() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-bold" style={{ color: g.text }}> <h1 className="truncate text-xl font-bold" style={{ color: g.text }}>
{t('subscriptions.title', 'Мои подписки')} {t('subscriptions.title', 'Мои подписки')}
</h1> </h1>
{/* «+ Купить ещё» — только если уже есть платная активная подписка */} {/* «+ Купить ещё» — только если уже есть платная активная подписка */}
{!isLoading && hasActivePaid && ( {!isLoading && hasActivePaid && (
<button <button
onClick={() => navigate('/subscription/purchase')} onClick={() => navigate('/subscription/purchase')}
className="flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium transition-colors" className="flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl px-4 py-2 text-sm font-medium transition-colors"
style={{ style={{
background: 'rgba(var(--color-accent-400), 0.1)', background: 'rgba(var(--color-accent-400), 0.1)',
color: 'rgb(var(--color-accent-400))', color: 'rgb(var(--color-accent-400))',

View File

@@ -363,6 +363,7 @@ export default function Support() {
</div> </div>
<Button <Button
variant="secondary" variant="secondary"
className="shrink-0 whitespace-nowrap"
onClick={() => { onClick={() => {
const username = supportConfig.support_username!.startsWith('@') const username = supportConfig.support_username!.startsWith('@')
? supportConfig.support_username!.slice(1) ? supportConfig.support_username!.slice(1)
@@ -370,7 +371,7 @@ export default function Support() {
openTelegramLink(`https://t.me/${username}`); openTelegramLink(`https://t.me/${username}`);
}} }}
> >
{t('support.contactUs')} {t('support.writeButton', 'Написать')}
</Button> </Button>
</Card> </Card>
</motion.div> </motion.div>