feat(legal): wire recurrent-payments document + restore footer link

Backend now serves a dedicated recurring-payments legal document, so:
- Add adminLegalPagesApi.get/updateRecurrentPayments and infoApi.getRecurrentPayments
  (+ InfoVisibility.recurrent).
- AdminLegalPages: new 'Рекуррентные платежи' editor tab (DocumentEditor now driven
  by a DOCUMENT_API dispatch map instead of privacy/offer ternaries).
- PublicLegal: add 'recurrent' doc; add public /recurrent-payments route.
- Re-add the recurrent link to the login LegalFooter (was removed pending backend).
- i18n: admin.legalPages.tabs.recurrent for ru/en/fa/zh (footer.recurrent already existed).

type-check, eslint, prettier and build all pass.
This commit is contained in:
Fringg
2026-07-09 18:47:24 +03:00
parent 37a53e40ea
commit 20565b8f59
10 changed files with 77 additions and 14 deletions

View File

@@ -269,6 +269,7 @@ function App() {
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/offer" element={<PublicLegal doc="offer" />} />
<Route path="/privacy" element={<PublicLegal doc="privacy" />} />
<Route path="/recurrent-payments" element={<PublicLegal doc="recurrent" />} />
<Route
path="/merge/:mergeToken"
element={

View File

@@ -111,6 +111,23 @@ export const adminLegalPagesApi = {
return response.data;
},
getRecurrentPayments: async (): Promise<LegalDocumentResponse> => {
const response = await apiClient.get<LegalDocumentResponse>(
'/cabinet/admin/legal-pages/recurrent-payments',
);
return response.data;
},
updateRecurrentPayments: async (
data: LegalDocumentUpdateRequest,
): Promise<LegalDocumentResponse> => {
const response = await apiClient.put<LegalDocumentResponse>(
'/cabinet/admin/legal-pages/recurrent-payments',
data,
);
return response.data;
},
getRules: async (): Promise<AdminRulesResponse> => {
const response = await apiClient.get<AdminRulesResponse>('/cabinet/admin/legal-pages/rules');
return response.data;

View File

@@ -23,6 +23,11 @@ export interface PublicOfferResponse {
updated_at: string | null;
}
export interface RecurrentPaymentsResponse {
content: string;
updated_at: string | null;
}
export interface ServiceInfo {
name: string;
description: string | null;
@@ -42,6 +47,7 @@ export interface InfoVisibility {
rules: boolean;
privacy: boolean;
offer: boolean;
recurrent: boolean;
}
export const infoApi = {
@@ -75,6 +81,14 @@ export const infoApi = {
return response.data;
},
// Get recurring-payments document
getRecurrentPayments: async (): Promise<RecurrentPaymentsResponse> => {
const response = await apiClient.get<RecurrentPaymentsResponse>(
'/cabinet/info/recurrent-payments',
);
return response.data;
},
// Get service info
getServiceInfo: async (): Promise<ServiceInfo> => {
const response = await apiClient.get<ServiceInfo>('/cabinet/info/service');

View File

@@ -7,12 +7,10 @@ interface LegalLink {
fallback: string;
}
// Recurring-payments has no dedicated legal document in the backend yet, so it is
// intentionally omitted rather than pointed at unrelated content. Re-add once a
// `recurrent-payments` document + public endpoint exist.
const LINKS: LegalLink[] = [
{ href: '/offer', labelKey: 'footer.offer', fallback: 'Публичная оферта' },
{ href: '/privacy', labelKey: 'footer.privacy', fallback: 'Политика конфиденциальности' },
{ href: '/recurrent-payments', labelKey: 'footer.recurrent', fallback: 'Рекуррентные платежи' },
];
interface LegalFooterProps {

View File

@@ -4181,6 +4181,7 @@
"tabs": {
"privacy": "Privacy",
"offer": "Offer",
"recurrent": "Recurring Payments",
"rules": "Rules",
"faq": "FAQ"
},

View File

@@ -4006,6 +4006,7 @@
"tabs": {
"privacy": "حریم خصوصی",
"offer": "پیشنهاد",
"recurrent": "پرداخت‌های تکراری",
"rules": "قوانین",
"faq": "FAQ"
},

View File

@@ -4726,6 +4726,7 @@
"tabs": {
"privacy": "Политика конф.",
"offer": "Оферта",
"recurrent": "Рекуррентные платежи",
"rules": "Правила",
"faq": "FAQ"
},

View File

@@ -4005,6 +4005,7 @@
"tabs": {
"privacy": "隐私",
"offer": "条款",
"recurrent": "定期付款",
"rules": "规则",
"faq": "FAQ"
},

View File

@@ -14,10 +14,35 @@ import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import { cn } from '../lib/utils';
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon } from '@/components/icons';
type LegalTab = 'privacy' | 'offer' | 'rules' | 'faq';
type LegalTab = 'privacy' | 'offer' | 'recurrent' | 'rules' | 'faq';
const DISPLAY_MODES: LegalDisplayMode[] = ['bot', 'web', 'both'];
type DocumentKind = 'privacy-policy' | 'public-offer' | 'recurrent-payments';
const DOCUMENT_API: Record<
DocumentKind,
{
get: () => ReturnType<typeof adminLegalPagesApi.getPrivacyPolicy>;
update: (
data: Parameters<typeof adminLegalPagesApi.updatePrivacyPolicy>[0],
) => ReturnType<typeof adminLegalPagesApi.updatePrivacyPolicy>;
}
> = {
'privacy-policy': {
get: adminLegalPagesApi.getPrivacyPolicy,
update: adminLegalPagesApi.updatePrivacyPolicy,
},
'public-offer': {
get: adminLegalPagesApi.getPublicOffer,
update: adminLegalPagesApi.updatePublicOffer,
},
'recurrent-payments': {
get: adminLegalPagesApi.getRecurrentPayments,
update: adminLegalPagesApi.updateRecurrentPayments,
},
};
function extractErrorDetail(err: unknown): string | null {
const error = err as { response?: { data?: { detail?: unknown } } };
const detail = error.response?.data?.detail;
@@ -103,7 +128,7 @@ function DocumentEditor({
kind,
onDirtyChange,
}: {
kind: 'privacy-policy' | 'public-offer';
kind: DocumentKind;
onDirtyChange: (dirty: boolean) => void;
}) {
const { t } = useTranslation();
@@ -118,10 +143,7 @@ function DocumentEditor({
const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', kind],
queryFn: () =>
kind === 'privacy-policy'
? adminLegalPagesApi.getPrivacyPolicy()
: adminLegalPagesApi.getPublicOffer(),
queryFn: () => DOCUMENT_API[kind].get(),
staleTime: 0,
gcTime: 0,
});
@@ -167,9 +189,7 @@ function DocumentEditor({
is_enabled: enabled[language] ?? false,
})),
};
return kind === 'privacy-policy'
? adminLegalPagesApi.updatePrivacyPolicy(payload)
: adminLegalPagesApi.updatePublicOffer(payload);
return DOCUMENT_API[kind].update(payload);
},
onSuccess: () => {
haptic.success();
@@ -661,7 +681,7 @@ export default function AdminLegalPages() {
</div>
<div className="flex flex-wrap gap-1">
{(['privacy', 'offer', 'rules', 'faq'] as const).map((tab) => (
{(['privacy', 'offer', 'recurrent', 'rules', 'faq'] as const).map((tab) => (
<button
key={tab}
type="button"
@@ -684,6 +704,9 @@ export default function AdminLegalPages() {
{activeTab === 'offer' && (
<DocumentEditor key="offer" kind="public-offer" onDirtyChange={setDirty} />
)}
{activeTab === 'recurrent' && (
<DocumentEditor key="recurrent" kind="recurrent-payments" onDirtyChange={setDirty} />
)}
{activeTab === 'rules' && <RulesEditor onDirtyChange={setDirty} />}
{activeTab === 'faq' && <FaqEditor onDirtyChange={setDirty} />}
</div>

View File

@@ -5,7 +5,7 @@ import { infoApi } from '../api/info';
import { formatContent } from '../utils/legalContent';
import LanguageSwitcher from '../components/LanguageSwitcher';
export type PublicLegalDoc = 'offer' | 'privacy';
export type PublicLegalDoc = 'offer' | 'privacy' | 'recurrent';
interface PublicLegalProps {
doc: PublicLegalDoc;
@@ -32,6 +32,12 @@ const DOC_CONFIG: Record<
titleFallback: 'Политика конфиденциальности',
fetch: infoApi.getPrivacyPolicy,
},
recurrent: {
queryKey: 'recurrent-payments',
titleKey: 'footer.recurrent',
titleFallback: 'Рекуррентные платежи',
fetch: infoApi.getRecurrentPayments,
},
};
// Public, unauthenticated viewer for the legal documents linked from the login