{t('referral.yourLink')}
diff --git a/src/pages/PublicLegal.tsx b/src/pages/PublicLegal.tsx
new file mode 100644
index 0000000..20b8a46
--- /dev/null
+++ b/src/pages/PublicLegal.tsx
@@ -0,0 +1,94 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router';
+import { infoApi } from '../api/info';
+import { formatContent } from '../utils/legalContent';
+import LanguageSwitcher from '../components/LanguageSwitcher';
+
+export type PublicLegalDoc = 'offer' | 'privacy' | 'recurrent';
+
+interface PublicLegalProps {
+ doc: PublicLegalDoc;
+}
+
+const DOC_CONFIG: Record<
+ PublicLegalDoc,
+ {
+ queryKey: string;
+ titleKey: string;
+ titleFallback: string;
+ fetch: () => Promise<{ content: string; updated_at: string | null }>;
+ }
+> = {
+ offer: {
+ queryKey: 'public-offer',
+ titleKey: 'footer.offer',
+ titleFallback: 'Публичная оферта',
+ fetch: infoApi.getPublicOffer,
+ },
+ privacy: {
+ queryKey: 'privacy-policy',
+ titleKey: 'footer.privacy',
+ 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
+// footer. Reads the same public /cabinet/info endpoints the authenticated Info page
+// uses, so the pages are reachable before login instead of bouncing to /login.
+export default function PublicLegal({ doc }: PublicLegalProps) {
+ const { t } = useTranslation();
+ const config = DOC_CONFIG[doc];
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['public-legal', config.queryKey],
+ queryFn: config.fetch,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const title = t(config.titleKey, config.titleFallback);
+
+ return (
+
+
+
+
+
+
+
{title}
+
+ {isLoading ? (
+
+ ) : isError || !data?.content ? (
+
+ {t('info.documentUnavailable', 'Документ пока недоступен.')}
+
+ ) : (
+
+
+ {data.updated_at && (
+
+ {t('info.updatedAt', 'Обновлено')}: {new Date(data.updated_at).toLocaleDateString()}
+
+ )}
+
+ )}
+
+
+
+ {t('auth.backToLogin', 'Вернуться ко входу')}
+
+
+
+
+ );
+}
diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx
index ef8e5b0..052030b 100644
--- a/src/pages/SavedCards.tsx
+++ b/src/pages/SavedCards.tsx
@@ -73,7 +73,11 @@ export default function SavedCards() {
};
return (
+ // key: remount the container when loading resolves — stagger orchestration
+ // runs once on mount, so cards arriving from the API later would otherwise
+ // stay stuck at their initial variant (opacity 0) after a hard refresh
- {/* Contact support card for "both" mode */}
+ {/* Contact support card for "both" mode — self-animated: mounts after the
+ config query resolves, when the parent stagger orchestration has already
+ finished and would leave it stuck at opacity 0 */}
{supportConfig?.support_type === 'both' && supportConfig.support_username && (
-
+
diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx
index 0070ff1..074ea3a 100644
--- a/src/pages/Wheel.tsx
+++ b/src/pages/Wheel.tsx
@@ -357,7 +357,7 @@ export default function Wheel() {
// Web-only: synchronously pre-open a tab during the user gesture to dodge the
// popup blocker before the async invoice URL resolves. Not reached in Telegram
// (hasInvoice is true there, so the native invoice flow is used instead).
- // eslint-disable-next-line no-restricted-properties
+ // biome-ignore lint: canonical popup-blocker workaround, see comment above
preOpenedWindowRef.current = window.open('about:blank', '_blank') || null;
}
starsInvoiceMutation.mutate();
@@ -774,10 +774,13 @@ export default function Wheel() {
>
{history && history.items.length > 0 ? (
+ // "hidden"/"show" don't exist in staggerContainer/staggerItem
+ // (their keys are initial/animate/exit), so the stagger here
+ // was silently a no-op
{history.items.map((item: SpinHistoryItem) => (
diff --git a/src/styles/globals.css b/src/styles/globals.css
index bb9ae21..2cbdb86 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -8,15 +8,15 @@
of the font stacks fixes every flag everywhere without touching any markup
and without affecting any other text. Root fix, not a per-component wrap. */
@font-face {
- font-family: 'Twemoji Country Flags';
+ font-family: "Twemoji Country Flags";
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E007F;
- src: url('/fonts/TwemojiCountryFlags.woff2') format('woff2');
+ src: url("/fonts/TwemojiCountryFlags.woff2") format("woff2");
font-display: swap;
}
/* Animated gradient border — @property enables CSS-only angle animation */
@property --border-angle {
- syntax: '';
+ syntax: "";
initial-value: 0deg;
inherits: false;
}
@@ -28,13 +28,13 @@
background:
linear-gradient(var(--_bg), var(--_bg)) padding-box,
conic-gradient(
- from var(--border-angle),
- var(--_accent) 0%,
- transparent 25%,
- transparent 75%,
- var(--_accent) 100%
- )
- border-box;
+ from var(--border-angle),
+ var(--_accent) 0%,
+ transparent 25%,
+ transparent 75%,
+ var(--_accent) 100%
+ )
+ border-box;
animation: border-rotate var(--border-duration, 3s) linear infinite;
}
@@ -295,7 +295,7 @@
/* Global Noise Texture — no mix-blend-mode to avoid fullscreen GPU compositing */
body::before {
- content: '';
+ content: "";
position: fixed;
inset: 0;
z-index: -1;
@@ -450,7 +450,7 @@ img.twemoji {
/* CSS Spotlight Effect via pseudo-element */
.bento-card-hover::after {
- content: '';
+ content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
@@ -625,7 +625,7 @@ img.twemoji {
/* Remove tap highlight and focus outline on mobile */
button,
a,
- [role='button'] {
+ [role="button"] {
-webkit-tap-highlight-color: transparent;
outline: none;
}
@@ -633,13 +633,13 @@ img.twemoji {
/* Only show focus ring for keyboard navigation */
button:focus-visible,
a:focus-visible,
- [role='button']:focus-visible {
+ [role="button"]:focus-visible {
@apply ring-2 ring-accent-500/50 ring-offset-2 ring-offset-dark-900;
}
.light button:focus-visible,
.light a:focus-visible,
- .light [role='button']:focus-visible {
+ .light [role="button"]:focus-visible {
@apply ring-offset-champagne-100;
}
@@ -1181,11 +1181,11 @@ img.twemoji {
/* Glow effects */
.glow-accent {
- box-shadow: 0 0 20px theme('colors.accent.500 / 30%');
+ box-shadow: 0 0 20px theme("colors.accent.500 / 30%");
}
.glow-success {
- box-shadow: 0 0 20px theme('colors.success.500 / 30%');
+ box-shadow: 0 0 20px theme("colors.success.500 / 30%");
}
/* Blur backdrop */
@@ -1229,9 +1229,9 @@ img.twemoji {
.shimmer {
background: linear-gradient(
90deg,
- theme('colors.dark.800') 0%,
- theme('colors.dark.700') 50%,
- theme('colors.dark.800') 100%
+ theme("colors.dark.800") 0%,
+ theme("colors.dark.700") 50%,
+ theme("colors.dark.800") 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
@@ -1240,9 +1240,9 @@ img.twemoji {
.light .shimmer {
background: linear-gradient(
90deg,
- theme('colors.champagne.200') 0%,
- theme('colors.champagne.300') 50%,
- theme('colors.champagne.200') 100%
+ theme("colors.champagne.200") 0%,
+ theme("colors.champagne.300") 50%,
+ theme("colors.champagne.200") 100%
);
}
@@ -1256,7 +1256,7 @@ img.twemoji {
}
/* Color picker range input styling */
-input[type='range'] {
+input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 12px;
@@ -1267,13 +1267,13 @@ input[type='range'] {
/* Desktop: smaller track */
@media (min-width: 640px) {
- input[type='range'] {
+ input[type="range"] {
height: 8px;
border-radius: 4px;
}
}
-input[type='range']::-webkit-slider-thumb {
+input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
@@ -1290,26 +1290,26 @@ input[type='range']::-webkit-slider-thumb {
box-shadow 0.15s ease;
}
-input[type='range']::-webkit-slider-thumb:hover {
+input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow:
0 3px 8px rgba(0, 0, 0, 0.4),
0 0 0 2px rgba(255, 255, 255, 0.2);
}
-input[type='range']::-webkit-slider-thumb:active {
+input[type="range"]::-webkit-slider-thumb:active {
transform: scale(0.95);
}
/* Desktop: smaller thumb */
@media (min-width: 640px) {
- input[type='range']::-webkit-slider-thumb {
+ input[type="range"]::-webkit-slider-thumb {
width: 18px;
height: 18px;
}
}
-input[type='range']::-moz-range-thumb {
+input[type="range"]::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
@@ -1324,7 +1324,7 @@ input[type='range']::-moz-range-thumb {
box-shadow 0.15s ease;
}
-input[type='range']::-moz-range-thumb:hover {
+input[type="range"]::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow:
0 3px 8px rgba(0, 0, 0, 0.4),
@@ -1333,25 +1333,25 @@ input[type='range']::-moz-range-thumb:hover {
/* Desktop: smaller thumb */
@media (min-width: 640px) {
- input[type='range']::-moz-range-thumb {
+ input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
}
}
/* Hide number input spinners for cleaner look */
-input[type='number']::-webkit-inner-spin-button,
-input[type='number']::-webkit-outer-spin-button {
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
-input[type='number'] {
+input[type="number"] {
-moz-appearance: textfield;
}
/* Custom checkbox styling - more visible */
-input[type='checkbox'] {
+input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
width: 1.25rem;
@@ -1367,13 +1367,13 @@ input[type='checkbox'] {
flex-shrink: 0;
}
-input[type='checkbox']:checked {
+input[type="checkbox"]:checked {
background-color: rgb(var(--color-accent-500));
border-color: rgb(var(--color-accent-500));
}
-input[type='checkbox']:checked::after {
- content: '';
+input[type="checkbox"]:checked::after {
+ content: "";
position: absolute;
left: 50%;
top: 45%;
@@ -1384,12 +1384,12 @@ input[type='checkbox']:checked::after {
transform: translate(-50%, -50%) rotate(45deg);
}
-input[type='checkbox']:focus {
+input[type="checkbox"]:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(var(--color-accent-500), 0.3);
}
-input[type='checkbox']:hover:not(:checked) {
+input[type="checkbox"]:hover:not(:checked) {
border-color: rgb(var(--color-dark-500));
background-color: rgb(var(--color-dark-600));
}
@@ -1477,17 +1477,17 @@ input[type='checkbox']:hover:not(:checked) {
background-color: rgb(var(--color-dark-700));
}
-.light input[type='checkbox'] {
+.light input[type="checkbox"] {
border-color: rgb(var(--color-champagne-400));
background-color: rgb(var(--color-champagne-100));
}
-.light input[type='checkbox']:checked {
+.light input[type="checkbox"]:checked {
background-color: rgb(var(--color-accent-500));
border-color: rgb(var(--color-accent-500));
}
-.light input[type='checkbox']:hover:not(:checked) {
+.light input[type="checkbox"]:hover:not(:checked) {
border-color: rgb(var(--color-champagne-500));
background-color: rgb(var(--color-champagne-200));
}
@@ -1523,7 +1523,7 @@ input[type='checkbox']:hover:not(:checked) {
}
.onboarding-tooltip::before {
- content: '';
+ content: "";
@apply absolute h-3 w-3 border-l border-t border-accent-500/30 bg-dark-900;
transform: rotate(45deg);
}
diff --git a/src/utils/format.ts b/src/utils/format.ts
index 459f264..796a020 100644
--- a/src/utils/format.ts
+++ b/src/utils/format.ts
@@ -59,3 +59,21 @@ export function formatPrice(kopeks: number, lang?: string): string {
return `${rounded} ${config.symbol}`;
}
}
+
+const SHORT_DATE_LOCALE_MAP: Record = {
+ ru: 'ru-RU',
+ en: 'en-US',
+ zh: 'zh-CN',
+ fa: 'fa-IR',
+};
+
+/** Date-only (dd.mm.yyyy) in the active UI locale; '-' for a null date. */
+export function formatShortDate(date: string | null): string {
+ if (!date) return '-';
+ const locale = SHORT_DATE_LOCALE_MAP[i18next.language] || 'ru-RU';
+ return new Date(date).toLocaleDateString(locale, {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ });
+}
diff --git a/src/utils/legalContent.ts b/src/utils/legalContent.ts
new file mode 100644
index 0000000..b9a767d
--- /dev/null
+++ b/src/utils/legalContent.ts
@@ -0,0 +1,88 @@
+import DOMPurify from 'dompurify';
+
+// Sanitize HTML content to prevent XSS. Shared by the authenticated Info page and
+// the public legal pages (offer / privacy) reachable from the login footer.
+export const sanitizeHtml = (html: string): string => {
+ return DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: [
+ 'p',
+ 'br',
+ 'b',
+ 'i',
+ 'u',
+ 'strong',
+ 'em',
+ 'a',
+ 'ul',
+ 'ol',
+ 'li',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'blockquote',
+ 'code',
+ 'pre',
+ 's',
+ 'del',
+ 'ins',
+ 'span',
+ 'div',
+ 'tg-spoiler',
+ ],
+ ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'start'],
+ ALLOW_DATA_ATTR: false,
+ });
+};
+
+// Render legal/document content that may be either full block-level HTML or plain
+// text with Telegram-style inline tags and newline structure.
+export const formatContent = (content: string): string => {
+ if (!content) return '';
+
+ // Check if content has block-level HTML (full HTML document)
+ const hasBlockHtml = /<(p|div|h[1-6]|ul|ol|blockquote)\b/i.test(content);
+
+ if (hasBlockHtml) {
+ return sanitizeHtml(content);
+ }
+
+ // Content may have inline Telegram HTML (, , , , ) but uses
+ // newlines for structure. Convert newlines to paragraphs while preserving inline tags.
+ const result = content
+ .split(/\n\n+/)
+ .map((paragraph) => {
+ const trimmed = paragraph.trim();
+ if (!trimmed) return '';
+
+ // Check if it's a markdown header
+ if (/^#{1,4}\s/.test(trimmed)) {
+ const level = trimmed.match(/^(#{1,4})/)?.[1].length || 1;
+ const text = trimmed.replace(/^#{1,4}\s*/, '');
+ return `${text}`;
+ }
+
+ // Check for list items
+ if (/^[-•]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
+ const lines = trimmed.split('\n');
+ const isOrdered = /^\d+[.)]\s/.test(lines[0]);
+ const startNum = isOrdered ? parseInt(lines[0].match(/^(\d+)/)?.[1] || '1', 10) : 1;
+ const listItems = lines
+ .map((line) => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
+ .filter((line) => line.trim())
+ .map((line) => `${line}`)
+ .join('');
+ return isOrdered ? `${listItems}
` : ``;
+ }
+
+ // Regular paragraph — single newlines become
+ const formatted = trimmed.split('\n').join('
');
+ return `${formatted}
`;
+ })
+ .filter(Boolean)
+ .join('');
+
+ return sanitizeHtml(result);
+};
diff --git a/src/utils/templateEngine.ts b/src/utils/templateEngine.ts
index d6c3305..cd99798 100644
--- a/src/utils/templateEngine.ts
+++ b/src/utils/templateEngine.ts
@@ -6,6 +6,31 @@ export function hasTemplates(url: string): boolean {
return TEMPLATE_RE.test(url);
}
+/**
+ * Old bot backends resolve prefix-hardcoded Subpage templates
+ * (happ://crypt4/{{HAPP_CRYPT4_LINK}}) into happ://crypt4/happ://cryptN/... —
+ * strip the outer prefix so the deep link stays openable.
+ */
+export function collapseDoubledCryptPrefix(url: string): string {
+ return url.replace(/^happ:\/\/crypt\d+\/(?=happ:\/\/crypt)/i, '');
+}
+
+// jsencrypt's RSA-4096 takes up to tens of ms on weak devices and its padding is
+// random (a new string every call) — cache per subscription URL so render-time
+// resolution is cheap and stable within a session. Any single ciphertext is valid.
+const cryptLinkCache = new Map();
+
+function cachedHappCryptoLink(url: string, version: 'v3' | 'v4'): string | null {
+ const key = `${version}|${url}`;
+ let link = cryptLinkCache.get(key);
+ if (link === undefined) {
+ if (cryptLinkCache.size >= 100) cryptLinkCache.clear();
+ link = createHappCryptoLink(url, version, true);
+ cryptLinkCache.set(key, link);
+ }
+ return link;
+}
+
interface ResolveContext {
subscriptionUrl: string;
username?: string;
@@ -14,6 +39,11 @@ interface ResolveContext {
export function resolveTemplate(template: string, ctx: ResolveContext): string {
let result = template;
+ // {{HAPP_CRYPT*_LINK}} resolves to a FULL happ://crypt.../ deep link; when the
+ // template also hardcodes the prefix (happ://crypt4/{{HAPP_CRYPT4_LINK}}),
+ // collapse it so we don't produce happ://crypt4/happ://crypt5/...
+ result = result.replace(/happ:\/\/crypt\d+\/(?=\{\{HAPP_CRYPT[34]_LINK\}\})/gi, '');
+
result = result.replace(/\{\{SUBSCRIPTION_LINK\}\}/g, ctx.subscriptionUrl);
if (ctx.username) {
@@ -21,11 +51,11 @@ export function resolveTemplate(template: string, ctx: ResolveContext): string {
}
result = result.replace(/\{\{HAPP_CRYPT3_LINK\}\}/g, () => {
- return createHappCryptoLink(ctx.subscriptionUrl, 'v3', true) ?? ctx.subscriptionUrl;
+ return cachedHappCryptoLink(ctx.subscriptionUrl, 'v3') ?? ctx.subscriptionUrl;
});
result = result.replace(/\{\{HAPP_CRYPT4_LINK\}\}/g, () => {
- return createHappCryptoLink(ctx.subscriptionUrl, 'v4', true) ?? ctx.subscriptionUrl;
+ return cachedHappCryptoLink(ctx.subscriptionUrl, 'v4') ?? ctx.subscriptionUrl;
});
return result;