fix: персистентность тем + кросс-сервисная синхронизация
Some checks failed
Some checks failed
Корневая причина: @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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
@theme {
|
||||
--color-primary: #FF6B00;
|
||||
--color-primary-dark: #E55D00;
|
||||
--color-primary-light: #FF8A33;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
48
apps/lp/src/components/ThemeSync.tsx
Normal file
48
apps/lp/src/components/ThemeSync.tsx
Normal 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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user