feat(cabinet): legal footer on login + admin toggle

Adds a legal footer (Public Offer / Privacy / Recurring Payments) under the
login card, gated by CABINET_FOOTER_ENABLED (backend endpoint added in bot
PR #3011), plus an admin toggle in BrandingTab and ru/en/fa/zh i18n.

Footer-only subset of PR #464 — excludes the unrelated storePartnerClickId
helper and the version/CHANGELOG churn.

Co-authored-by: smediainfo <dev@matrixvpn.top>
This commit is contained in:
Fringg
2026-06-30 07:30:59 +03:00
parent fd5500c85b
commit 984d1b0776
8 changed files with 136 additions and 4 deletions

View File

@@ -241,6 +241,25 @@ export const brandingApi = {
return response.data; return response.data;
}, },
// Legal footer enabled (public read for the login page, admin-only update)
getFooterEnabled: async (): Promise<boolean> => {
try {
const response = await apiClient.get<{ enabled: boolean }>(
'/cabinet/branding/footer-enabled',
);
return response.data.enabled;
} catch {
return false;
}
},
updateFooterEnabled: async (enabled: boolean): Promise<boolean> => {
const response = await apiClient.patch<{ enabled: boolean }>(
'/cabinet/branding/footer-enabled',
{ enabled },
);
return response.data.enabled;
},
// Get email auth enabled (public, no auth required) // Get email auth enabled (public, no auth required)
getEmailAuthEnabled: async (): Promise<EmailAuthEnabled> => { getEmailAuthEnabled: async (): Promise<EmailAuthEnabled> => {
try { try {

View File

@@ -0,0 +1,46 @@
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
interface LegalLink {
href: string;
labelKey: string;
fallback: string;
}
const LINKS: LegalLink[] = [
{ href: '/offer', labelKey: 'footer.offer', fallback: 'Публичная оферта' },
{ href: '/privacy', labelKey: 'footer.privacy', fallback: 'Политика конфиденциальности' },
{ href: '/recurrent-payments', labelKey: 'footer.recurrent', fallback: 'Рекуррентные платежи' },
];
interface LegalFooterProps {
className?: string;
}
export default function LegalFooter({ className = '' }: LegalFooterProps) {
const { t } = useTranslation();
return (
<footer
className={`flex flex-wrap items-center justify-center gap-x-2.5 gap-y-1 text-center text-[11px] leading-relaxed text-dark-500 ${className}`}
>
{LINKS.map((link, index) => (
<Fragment key={link.href}>
{index > 0 && (
<span className="text-dark-700" aria-hidden="true">
·
</span>
)}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-accent-400"
>
{t(link.labelKey, link.fallback)}
</a>
</Fragment>
))}
</footer>
);
}

View File

@@ -40,6 +40,11 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
queryFn: brandingApi.getGiftEnabled, queryFn: brandingApi.getGiftEnabled,
}); });
const { data: footerEnabled } = useQuery({
queryKey: ['footer-enabled'],
queryFn: brandingApi.getFooterEnabled,
});
// Mutations // Mutations
const updateBrandingMutation = useMutation({ const updateBrandingMutation = useMutation({
mutationFn: brandingApi.updateName, mutationFn: brandingApi.updateName,
@@ -88,6 +93,13 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
}, },
}); });
const updateFooterMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateFooterEnabled(enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['footer-enabled'] });
},
});
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
@@ -250,6 +262,25 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
disabled={updateGiftMutation.isPending} disabled={updateGiftMutation.isPending}
/> />
</div> </div>
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
<div>
<span className="font-medium text-dark-100">
{t('admin.settings.legalFooter', 'Юридический футер')}
</span>
<p className="text-sm text-dark-400">
{t(
'admin.settings.legalFooterDesc',
'Ссылки на оферту/политику/рекурренты внизу страницы входа',
)}
</p>
</div>
<Toggle
checked={footerEnabled ?? true}
onChange={() => updateFooterMutation.mutate(!(footerEnabled ?? true))}
disabled={updateFooterMutation.isPending}
/>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2228,7 +2228,9 @@
"telegramOidcClientSecret": "Client Secret" "telegramOidcClientSecret": "Client Secret"
}, },
"offlineConv": "Offline Conversions", "offlineConv": "Offline Conversions",
"apiKey": "API Key" "apiKey": "API Key",
"legalFooter": "Legal Footer",
"legalFooterDesc": "Links to the offer, privacy policy and recurring payments at the bottom of the login page"
}, },
"bulkActions": { "bulkActions": {
"title": "Bulk Actions", "title": "Bulk Actions",
@@ -5265,5 +5267,10 @@
"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",
"title": "My subscriptions" "title": "My subscriptions"
},
"footer": {
"offer": "Public Offer",
"privacy": "Privacy Policy",
"recurrent": "Recurring Payments"
} }
} }

View File

@@ -1908,7 +1908,9 @@
"telegramOidcClientSecret": "Client Secret" "telegramOidcClientSecret": "Client Secret"
}, },
"offlineConv": "تبدیل‌های آفلاین", "offlineConv": "تبدیل‌های آفلاین",
"apiKey": "کلید API" "apiKey": "کلید API",
"legalFooter": "پاورقی حقوقی",
"legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداخت‌های مکرر در پایین صفحه ورود"
}, },
"buttons": { "buttons": {
"color": "رنگ دکمه", "color": "رنگ دکمه",
@@ -4899,5 +4901,10 @@
"empty": "هنوز اشتراکی ندارید", "empty": "هنوز اشتراکی ندارید",
"emptyDesc": "برای شروع تعرفه‌ای انتخاب کنید", "emptyDesc": "برای شروع تعرفه‌ای انتخاب کنید",
"title": "اشتراک‌های من" "title": "اشتراک‌های من"
},
"footer": {
"offer": "پیشنهاد عمومی",
"privacy": "سیاست حفظ حریم خصوصی",
"recurrent": "پرداخت‌های مکرر"
} }
} }

View File

@@ -2742,7 +2742,9 @@
"MODERATION": "Модерация" "MODERATION": "Модерация"
}, },
"offlineConv": "Офлайн-конверсии", "offlineConv": "Офлайн-конверсии",
"apiKey": "API-ключ" "apiKey": "API-ключ",
"legalFooter": "Юридический футер",
"legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа"
}, },
"buttons": { "buttons": {
"color": "Цвет кнопки", "color": "Цвет кнопки",
@@ -5827,5 +5829,10 @@
"empty": "У вас пока нет подписок", "empty": "У вас пока нет подписок",
"emptyDesc": "Выберите тариф, чтобы начать пользоваться сервисом", "emptyDesc": "Выберите тариф, чтобы начать пользоваться сервисом",
"title": "Мои подписки" "title": "Мои подписки"
},
"footer": {
"offer": "Публичная оферта",
"privacy": "Политика конфиденциальности",
"recurrent": "Рекуррентные платежи"
} }
} }

View File

@@ -1973,7 +1973,9 @@
"telegramOidcClientSecret": "Client Secret" "telegramOidcClientSecret": "Client Secret"
}, },
"offlineConv": "离线转化", "offlineConv": "离线转化",
"apiKey": "API 密钥" "apiKey": "API 密钥",
"legalFooter": "法律页脚",
"legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接"
}, },
"buttons": { "buttons": {
"color": "按钮颜色", "color": "按钮颜色",
@@ -4898,5 +4900,10 @@
"empty": "您还没有订阅", "empty": "您还没有订阅",
"emptyDesc": "选择套餐以开始使用", "emptyDesc": "选择套餐以开始使用",
"title": "我的订阅" "title": "我的订阅"
},
"footer": {
"offer": "公开要约",
"privacy": "隐私政策",
"recurrent": "定期付款"
} }
} }

View File

@@ -24,6 +24,7 @@ import OAuthProviderIcon from '../components/OAuthProviderIcon';
import { saveOAuthState } from '../utils/oauth'; import { saveOAuthState } from '../utils/oauth';
import { getPendingReferralCode } from '../utils/referral'; import { getPendingReferralCode } from '../utils/referral';
import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons'; import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons';
import LegalFooter from '../components/LegalFooter';
export default function Login() { export default function Login() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -112,6 +113,12 @@ export default function Login() {
}); });
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true; const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
const { data: footerEnabled } = useQuery({
queryKey: ['footer-enabled'],
queryFn: brandingApi.getFooterEnabled,
staleTime: 60000,
});
// Fetch enabled OAuth providers // Fetch enabled OAuth providers
const { data: oauthData } = useQuery({ const { data: oauthData } = useQuery({
queryKey: ['oauth-providers'], queryKey: ['oauth-providers'],
@@ -737,6 +744,7 @@ export default function Login() {
)} )}
</div> </div>
)} )}
{footerEnabled && <LegalFooter className="pt-1" />}
</div> </div>
</div> </div>
); );