fix: персистентность тем + кросс-сервисная синхронизация
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Build All Apps (push) Has been cancelled
CI / E2E Tests (Playwright) (push) Has been cancelled
CI / Deploy to Production (push) Has been cancelled

Корневая причина: @unique на themeId в Prisma → P2002 ошибка при
повторной установке ранее использованной темы → .catch() скрывал ошибку →
API fetch на перезагрузке возвращал старую тему и перезаписывал localStorage.

Исправления:
- Убран @unique с themeId в PlatformTheme (разрешает аудит-трейл)
- Добавлен @@index([createdAt]) для быстрого findFirst orderBy desc
- handleApply теперь показывает ошибки пользователю вместо .catch(() => {})
- Лендинг: @theme inline → @theme, добавлен ThemeSync компонент
  (подтягивает primary color из API и применяет к LP)
- Lint-фиксы в competitor-analysis-skk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-20 15:26:25 +00:00
parent 3959db8779
commit 8110651561
6 changed files with 1089 additions and 7 deletions

View File

@@ -1131,9 +1131,11 @@ model EmailLog {
model PlatformTheme {
id String @id @default(uuid())
themeId String @unique
themeId String
setById String?
createdAt DateTime @default(now())
@@index([createdAt])
@@map("platform_themes")
}

View File

@@ -1,6 +1,6 @@
@import "tailwindcss";
@theme inline {
@theme {
--color-primary: #FF6B00;
--color-primary-dark: #E55D00;
--color-primary-light: #FF8A33;

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeSync } from "@/components/ThemeSync";
import "./globals.css";
const inter = Inter({
@@ -36,7 +37,10 @@ export default function RootLayout({
}>) {
return (
<html lang="ru" className={inter.variable}>
<body className="antialiased">{children}</body>
<body className="antialiased">
<ThemeSync />
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { useEffect } from 'react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/v1';
/** Color map: theme ID → primary color values for the landing page */
const THEME_COLORS: Record<string, { primary: string; primaryDark: string; primaryLight: string }> = {
'fitpulse-orange': { primary: '#FF6B00', primaryDark: '#E55D00', primaryLight: '#FF8A33' },
'midnight-gym': { primary: '#00D4FF', primaryDark: '#00B0D4', primaryLight: '#33DDFF' },
'fresh-wellness': { primary: '#2D6A4F', primaryDark: '#1B4332', primaryLight: '#52B788' },
'power-red': { primary: '#E53935', primaryDark: '#C62828', primaryLight: '#EF5350' },
'azure-professional': { primary: '#0D47A1', primaryDark: '#0A3680', primaryLight: '#1565C0' },
'sunrise-gradient': { primary: '#E64A19', primaryDark: '#BF360C', primaryLight: '#FF7043' },
'nordic-minimal': { primary: '#1A1A1A', primaryDark: '#000000', primaryLight: '#424242' },
'vibrant-purple': { primary: '#9C27B0', primaryDark: '#7B1FA2', primaryLight: '#BA68C8' },
'ocean-calm': { primary: '#00695C', primaryDark: '#004D40', primaryLight: '#00897B' },
'royal-gold': { primary: '#FFD54F', primaryDark: '#FFC107', primaryLight: '#FFE082' },
};
/**
* Lightweight theme sync for the landing page.
* Fetches the active theme from the API and applies the primary color.
*/
export function ThemeSync() {
useEffect(() => {
void (async () => {
try {
const res = await fetch(`${API_BASE}/theme/current`, { cache: 'no-store' });
if (!res.ok) return;
const data = (await res.json()) as { themeId?: string; isExplicit?: boolean };
if (!data.isExplicit || !data.themeId) return;
const colors = THEME_COLORS[data.themeId];
if (!colors) return;
const root = document.documentElement;
root.style.setProperty('--color-primary', colors.primary);
root.style.setProperty('--color-primary-dark', colors.primaryDark);
root.style.setProperty('--color-primary-light', colors.primaryLight);
} catch {
// Silently fail — LP works fine with default colors
}
})();
}, []);
return null;
}

View File

@@ -464,22 +464,32 @@ export default function ThemesSettingsPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleApply = (id: string) => {
// Optimistic update: apply theme immediately, then sync to API in background
setTheme(id);
setPreviewTheme(null);
setSaving(true);
// Use direct fetch (not api.put) to avoid logout redirect on 401
setError(null);
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/v1';
const token = typeof window !== 'undefined' ? localStorage.getItem('fitcrm_platform_access_token') : null;
void fetch(`${apiBase}/admin/themes/active`, {
fetch(`${apiBase}/admin/themes/active`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ themeId: id }),
}).catch(() => {}).finally(() => setSaving(false));
})
.then((res) => {
if (!res.ok) {
setError(`Не удалось сохранить тему: ${res.status}`);
}
})
.catch(() => {
setError('Не удалось сохранить тему — проверьте подключение к серверу');
})
.finally(() => setSaving(false));
};
return (
@@ -524,6 +534,16 @@ export default function ThemesSettingsPage() {
</div>
</div>
{/* Error message */}
{error && (
<div className="bg-error/10 border border-error/30 rounded-xl p-4 mb-6 flex items-center justify-between">
<p className="text-sm text-error">{error}</p>
<button onClick={() => setError(null)} className="text-error hover:text-error/80">
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Theme grid */}
<div className="grid grid-cols-2 gap-4">
{allThemes.map((theme) => {