diff --git a/backend/src/index.ts b/backend/src/index.ts index 817e9de..6ac0b89 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,7 +12,7 @@ dotenv.config(); const app = express(); const PORT = parseInt(process.env.PORT || '3000', 10); -app.use(express.json({ limit: '1mb' })); +app.use(express.json({ limit: '6mb' })); app.use(cookieParser()); app.use( diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index c543eb1..a4ad370 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -231,3 +231,106 @@ adminRouter.put('/settings/:key', requireAdmin, async (req, res) => { ); res.json({ ok: true }); }); + +// ============ Электронные визитки ============ +const cardSchema = z.object({ + slug: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/, 'slug_format'), + name: z.string().min(1).max(128), + role: z.string().max(128).nullable().optional(), + company: z.string().max(128).nullable().optional(), + phone: z.string().max(64).nullable().optional(), + email: z.string().max(128).nullable().optional(), + telegram: z.string().max(128).nullable().optional(), + photo: z.string().max(5_000_000).nullable().optional(), + product_title: z.string().max(128).nullable().optional(), + product_subtitle: z.string().max(256).nullable().optional(), + product_url: z.string().max(256).nullable().optional(), + site_url: z.string().max(256).nullable().optional(), + is_published: z.boolean().optional(), +}); + +adminRouter.get('/cards', requireAdmin, async (_req, res) => { + const cards = await query(`SELECT * FROM business_cards ORDER BY created_at ASC`); + res.json({ cards }); +}); + +adminRouter.post('/cards', requireAdmin, async (req, res) => { + const parsed = cardSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'invalid_payload' }); + return; + } + const c = parsed.data; + try { + const created = await queryOne( + `INSERT INTO business_cards + (slug, name, role, company, phone, email, telegram, photo, + product_title, product_subtitle, product_url, site_url, is_published) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING *`, + [ + c.slug, c.name, c.role ?? null, c.company ?? null, c.phone ?? null, + c.email ?? null, c.telegram ?? null, c.photo ?? null, + c.product_title ?? null, c.product_subtitle ?? null, + c.product_url ?? null, c.site_url ?? null, c.is_published ?? true, + ] + ); + res.status(201).json({ card: created }); + } catch (err: any) { + if (err?.code === '23505') { + res.status(409).json({ error: 'slug_exists' }); + return; + } + console.error('[POST /cards]', err); + res.status(500).json({ error: 'internal_error' }); + } +}); + +adminRouter.put('/cards/:id', requireAdmin, async (req, res) => { + const id = parseInt(String(req.params.id), 10); + if (Number.isNaN(id)) { + res.status(400).json({ error: 'invalid_id' }); + return; + } + const parsed = cardSchema.partial().safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'invalid_payload' }); + return; + } + const c = parsed.data; + const updated = await queryOne( + `UPDATE business_cards SET + slug = COALESCE($2, slug), + name = COALESCE($3, name), + role = COALESCE($4, role), + company = COALESCE($5, company), + phone = COALESCE($6, phone), + email = COALESCE($7, email), + telegram = COALESCE($8, telegram), + photo = COALESCE($9, photo), + product_title = COALESCE($10, product_title), + product_subtitle = COALESCE($11, product_subtitle), + product_url = COALESCE($12, product_url), + site_url = COALESCE($13, site_url), + is_published = COALESCE($14, is_published), + updated_at = now() + WHERE id = $1 + RETURNING *`, + [ + id, c.slug, c.name, c.role, c.company, c.phone, c.email, c.telegram, + c.photo, c.product_title, c.product_subtitle, c.product_url, + c.site_url, c.is_published, + ] + ); + if (!updated) { + res.status(404).json({ error: 'not_found' }); + return; + } + res.json({ card: updated }); +}); + +adminRouter.delete('/cards/:id', requireAdmin, async (req, res) => { + const id = parseInt(String(req.params.id), 10); + await query(`DELETE FROM business_cards WHERE id = $1`, [id]); + res.json({ ok: true }); +}); diff --git a/backend/src/routes/public.ts b/backend/src/routes/public.ts index d291b87..2e7ce16 100644 --- a/backend/src/routes/public.ts +++ b/backend/src/routes/public.ts @@ -51,3 +51,23 @@ publicRouter.get('/content', async (_req, res) => { res.status(500).json({ error: 'internal_error' }); } }); + +publicRouter.get('/cards/:slug', async (req, res) => { + try { + const card = await query( + `SELECT slug, name, role, company, phone, email, telegram, photo, + product_title, product_subtitle, product_url, site_url + FROM business_cards + WHERE slug = $1 AND is_published = true`, + [req.params.slug] + ); + if (!card[0]) { + res.status(404).json({ error: 'not_found' }); + return; + } + res.json({ card: card[0] }); + } catch (err) { + console.error('[GET /cards/:slug]', err); + res.status(500).json({ error: 'internal_error' }); + } +}); diff --git a/db/003_business_cards.sql b/db/003_business_cards.sql new file mode 100644 index 0000000..d228156 --- /dev/null +++ b/db/003_business_cards.sql @@ -0,0 +1,27 @@ +-- Электронные визитки (управляются из админки) +CREATE TABLE IF NOT EXISTS business_cards ( + id SERIAL PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + role TEXT, + company TEXT, + phone TEXT, + email TEXT, + telegram TEXT, + photo TEXT, -- data URI (base64), сжимается на клиенте + product_title TEXT, + product_subtitle TEXT, + product_url TEXT, + site_url TEXT, + is_published BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +INSERT INTO business_cards + (slug, name, role, company, phone, email, telegram, product_title, product_subtitle, product_url, site_url) +VALUES + ('alexey', 'Алексей Павлов', 'Основатель · разработчик', 'ООО «Умный Байт»', + '+7 960 011 0007', 'info@umbyte.ru', NULL, + 'АгроТО', 'CMMS для агропромышленности', 'https://agroto.ru', 'https://umbyte.ru') +ON CONFLICT (slug) DO NOTHING; diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index 4e5fb77..528e410 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -5,6 +5,7 @@ import { Logo } from '../components/Icons'; import { ProductsAdmin } from './ProductsAdmin'; import { SectionsAdmin } from './SectionsAdmin'; import { SettingsAdmin } from './SettingsAdmin'; +import { CardsAdmin } from './CardsAdmin'; export function AdminLayout() { const navigate = useNavigate(); @@ -53,6 +54,7 @@ export function AdminLayout() { { to: '/admin/products', label: 'Продукты' }, { to: '/admin/sections', label: 'Секции' }, { to: '/admin/settings', label: 'Настройки' }, + { to: '/admin/cards', label: 'Визитки' }, ].map((item) => ( } /> } /> } /> + } /> diff --git a/frontend/src/admin/CardsAdmin.tsx b/frontend/src/admin/CardsAdmin.tsx new file mode 100644 index 0000000..705a1ce --- /dev/null +++ b/frontend/src/admin/CardsAdmin.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from 'react'; +import { api, type BusinessCard } from '../api/client'; + +type Draft = Partial; + +const EMPTY: Draft = { + slug: '', name: '', role: 'Основатель · разработчик', company: 'ООО «Умный Байт»', + phone: '', email: '', telegram: '', photo: null, + product_title: '', product_subtitle: '', product_url: '', site_url: 'https://umbyte.ru', +}; + +export function CardsAdmin() { + const [cards, setCards] = useState([]); + const [editing, setEditing] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + + async function load() { + const { cards } = await api.listCards(); + setCards(cards); + setLoading(false); + } + useEffect(() => { load(); }, []); + + function patch(p: Partial) { setEditing((e) => ({ ...(e || {}), ...p })); } + + function onPhoto(file: File | undefined) { + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const img = new Image(); + img.onload = () => { + const max = 420; + let { width, height } = img; + if (width > height && width > max) { height = Math.round(height * max / width); width = max; } + else if (height > max) { width = Math.round(width * max / height); height = max; } + const canvas = document.createElement('canvas'); + canvas.width = width; canvas.height = height; + canvas.getContext('2d')!.drawImage(img, 0, 0, width, height); + patch({ photo: canvas.toDataURL('image/jpeg', 0.85) }); + }; + img.src = String(ev.target!.result); + }; + reader.readAsDataURL(file); + } + + async function handleSave() { + if (!editing || !editing.slug || !editing.name) { setErr('Адрес и имя обязательны'); return; } + setSaving(true); setErr(null); + try { + if (editing.id) await api.updateCard(editing.id, editing); + else await api.createCard(editing); + setEditing(null); + await load(); + } catch (e: any) { + setErr(String(e.message).includes('slug_exists') ? 'Такой адрес уже занят' : 'Не удалось сохранить'); + } finally { setSaving(false); } + } + + async function handleDelete(c: BusinessCard) { + if (!confirm(`Удалить визитку «${c.name}»?`)) return; + await api.deleteCard(c.id); + await load(); + } + + if (loading) return
загрузка…
; + + return ( +
+
+

Визитки

+ +
+ +
+ + + + + + + + + + + {cards.map((c) => ( + + + + + + + ))} + {cards.length === 0 && ( + + )} + +
ИмяАдресСтатус
+ {c.name} +
{c.role}
+
+ + /card/{c.slug} ↗ + + {c.is_published === false ? 'скрыта' : 'опубликована'} + + +
Пока нет визиток
+
+ + {editing && ( +
setEditing(null)}> +
e.stopPropagation()}> +

{editing.id ? 'Редактировать визитку' : 'Новая визитка'}

+ +
+
+
+ {editing.photo + ? + : нет фото} +
+
+ + {editing.photo && ( + + )} +
+
+ +
+ + patch({ slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') })} placeholder="alexey" className="input" /> + + + patch({ name: e.target.value })} className="input" /> + +
+ + patch({ role: e.target.value })} className="input" /> + + + patch({ company: e.target.value })} className="input" /> + +
+ + patch({ phone: e.target.value })} placeholder="+7 900 000 0000" className="input" /> + + + patch({ email: e.target.value })} className="input" /> + +
+ + patch({ telegram: e.target.value })} placeholder="@username" className="input" /> + +
+ + patch({ product_title: e.target.value })} placeholder="АгроТО" className="input" /> + + + patch({ product_subtitle: e.target.value })} className="input" /> + +
+
+ + patch({ product_url: e.target.value })} placeholder="https://agroto.ru" className="input" /> + + + patch({ site_url: e.target.value })} className="input" /> + +
+
+ + {err &&
{err}
} + +
+ + +
+
+
+ )} + + +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f01e1ee..7293155 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,23 @@ export interface ApproachItem { sort_order: number; } +export interface BusinessCard { + id: number; + slug: string; + name: string; + role: string | null; + company: string | null; + phone: string | null; + email: string | null; + telegram: string | null; + photo: string | null; + product_title: string | null; + product_subtitle: string | null; + product_url: string | null; + site_url: string | null; + is_published?: boolean; +} + export interface LandingContent { sections: Record>; products: Product[]; @@ -97,4 +114,21 @@ export const api = { method: 'PUT', body: JSON.stringify({ value }), }), + + getCard: (slug: string) => + request<{ card: BusinessCard }>(`/api/cards/${slug}`), + listCards: () => + request<{ cards: BusinessCard[] }>('/api/admin/cards'), + createCard: (data: Partial) => + request<{ card: BusinessCard }>('/api/admin/cards', { + method: 'POST', + body: JSON.stringify(data), + }), + updateCard: (id: number, data: Partial) => + request<{ card: BusinessCard }>(`/api/admin/cards/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + deleteCard: (id: number) => + request<{ ok: true }>(`/api/admin/cards/${id}`, { method: 'DELETE' }), }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3a0d677..42133f9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Landing } from './pages/Landing'; +import { CardPage } from './pages/CardPage'; import { AdminLogin } from './admin/AdminLogin'; import { AdminLayout } from './admin/AdminLayout'; import './styles/index.css'; @@ -11,6 +12,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( } /> + } /> } /> } /> diff --git a/frontend/src/pages/CardPage.tsx b/frontend/src/pages/CardPage.tsx new file mode 100644 index 0000000..fe12c59 --- /dev/null +++ b/frontend/src/pages/CardPage.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { api, type BusinessCard } from '../api/client'; + +export function CardPage() { + const { slug } = useParams<{ slug: string }>(); + const [card, setCard] = useState(null); + const [error, setError] = useState(false); + const cardRef = useRef(null); + const glowRef = useRef(null); + + useEffect(() => { + if (!slug) return; + api.getCard(slug).then(({ card }) => setCard(card)).catch(() => setError(true)); + }, [slug]); + + useEffect(() => { + if (!card) return; + if (!window.matchMedia('(hover:hover)').matches) return; + const el = cardRef.current, glow = glowRef.current; + function onMove(e: MouseEvent) { + if (!el) return; + const r = el.getBoundingClientRect(); + const dx = (e.clientX - (r.left + r.width / 2)) / r.width; + const dy = (e.clientY - (r.top + r.height / 2)) / r.height; + el.style.transform = `rotateY(${(dx * 5).toFixed(2)}deg) rotateX(${(-dy * 5).toFixed(2)}deg)`; + if (glow) { glow.style.left = e.clientX + 'px'; glow.style.top = e.clientY + 'px'; } + } + function onLeave() { if (el) el.style.transform = ''; } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseleave', onLeave); + return () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseleave', onLeave); }; + }, [card]); + + if (error) return
Визитка не найдена
; + if (!card) return
загрузка…
; + + const initials = card.name.split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase(); + const telHref = card.phone ? 'tel:' + card.phone.replace(/[^+\d]/g, '') : ''; + + function saveContact() { + if (!card) return; + const parts = card.name.split(/\s+/); + const lines = ['BEGIN:VCARD', 'VERSION:3.0', + `N:${parts[1] || ''};${parts[0] || ''};;;`, `FN:${card.name}`]; + if (card.company) lines.push(`ORG:${card.company}`); + if (card.role) lines.push(`TITLE:${card.role.replace(/·/g, ',')}`); + if (card.phone) lines.push(`TEL;TYPE=CELL,VOICE:${card.phone.replace(/[^+\d]/g, '')}`); + if (card.email) lines.push(`EMAIL;TYPE=INTERNET:${card.email}`); + if (card.product_url) lines.push(`URL:${card.product_url}`); + if (card.site_url) lines.push(`URL:${card.site_url}`); + if (card.photo && card.photo.startsWith('data:image')) { + const b64 = card.photo.split(',')[1]; + const type = (card.photo.match(/data:image\/(\w+)/) || [])[1]?.toUpperCase() || 'JPEG'; + if (b64 && b64.length < 60000) lines.push(`PHOTO;ENCODING=b;TYPE=${type}:${b64}`); + } + lines.push('END:VCARD'); + try { + const blob = new Blob([lines.join('\r\n')], { type: 'text/vcard;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `${card.slug}.vcf`; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast('Контакт сохранён ✓'); + } catch { toast('Откройте на телефоне для сохранения'); } + } + function toast(msg: string) { + const t = document.getElementById('cp-toast'); + if (!t) return; + t.textContent = msg; t.classList.add('on'); + setTimeout(() => t.classList.remove('on'), 2200); + } + + return ( +
+
+
+ +
+
+
+
+ {card.photo + ? {card.name} + : {initials}} +
+
+

{card.name}

+ {card.role &&
{card.role}
} + {card.company && ( +
+ + {card.company} +
+ )} + +
+ {card.phone && ( + + )} + {card.email && ( + + )} + {card.telegram && ( + + )} + {card.site_url && ( + + )} +
+ + + + {card.product_title && ( + + + {card.product_title}{card.product_subtitle && {card.product_subtitle}} + + + )} + +
+ + Умный Байт · umbyte.ru +
+
+ +
Контакт сохранён ✓
+ + +
+ ); +} + +const cardCss = ` +@import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@400;500;600;700&family=Onest:wght@400;500;600&display=swap'); +.cardpage{--bg:#E9EDF7;--bg2:#DDE3F2;--card:#FFFFFF;--card2:#FBFCFF;--ink:#15132E;--ink2:#5C5984;--line:#E7E9F4;--indigo:#312E81;--indigo2:#4F4BC4;--cyan:#06B6D4;--cyan-glow:#22D3EE;--violet:#7C5CFF;--shadow:0 30px 70px -24px rgba(49,46,129,.32),0 10px 30px -18px rgba(6,182,212,.28); + font-family:'Onest',sans-serif;color:var(--ink);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:30px 18px;position:relative;overflow:hidden;perspective:1200px;background:linear-gradient(165deg,var(--bg),var(--bg2))} +@media (prefers-color-scheme:dark){.cardpage{--bg:#0C0B22;--bg2:#141233;--card:#17163a;--card2:#1c1a44;--ink:#F4F4FF;--ink2:#A6A3D9;--line:rgba(255,255,255,.12);--indigo:#4F4BC4;--indigo2:#6A66E0;--cyan:#22D3EE;--cyan-glow:#67E8F9;--shadow:0 30px 80px -24px rgba(0,0,0,.7),0 0 40px -10px rgba(34,211,238,.25)}} +.cardpage-msg{min-height:100vh;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-family:'Onest',sans-serif;background:#E9EDF7} +@media (prefers-color-scheme:dark){.cardpage-msg{background:#0C0B22}} +.cardpage *{margin:0;padding:0;box-sizing:border-box} +.cardpage .blob{position:fixed;border-radius:50%;filter:blur(70px);opacity:.6;z-index:0;pointer-events:none;will-change:transform} +.cardpage .b1{width:380px;height:380px;background:radial-gradient(circle,rgba(34,211,238,.5),transparent 70%);top:-80px;left:-60px;animation:cpf1 14s ease-in-out infinite} +.cardpage .b2{width:420px;height:420px;background:radial-gradient(circle,rgba(124,92,255,.4),transparent 70%);bottom:-120px;right:-80px;animation:cpf2 18s ease-in-out infinite} +.cardpage .b3{width:300px;height:300px;background:radial-gradient(circle,rgba(79,75,196,.4),transparent 70%);top:40%;left:60%;animation:cpf3 16s ease-in-out infinite} +@keyframes cpf1{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(60px,40px) scale(1.12)}} +@keyframes cpf2{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(-50px,-40px) scale(1.1)}} +@keyframes cpf3{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(-40px,50px) scale(1.15)}} +.cardpage .glow{position:fixed;width:300px;height:300px;border-radius:50%;pointer-events:none;z-index:1;background:radial-gradient(circle,rgba(34,211,238,.18),transparent 65%);transform:translate(-50%,-50%);left:-999px;top:-999px;mix-blend-mode:multiply} +@media (prefers-color-scheme:dark){.cardpage .glow{mix-blend-mode:screen}} +.cp-card{position:relative;z-index:2;width:100%;max-width:404px;background:linear-gradient(180deg,var(--card),var(--card2));border-radius:30px;padding:38px 28px 26px;box-shadow:var(--shadow);display:flex;flex-direction:column;align-items:center;text-align:center;transform-style:preserve-3d;transition:transform .15s ease-out;will-change:transform} +.cp-card::before{content:"";position:absolute;inset:0;border-radius:30px;padding:1.5px;pointer-events:none;background:linear-gradient(135deg,var(--cyan-glow),var(--violet) 35%,var(--indigo2) 60%,transparent 80%);background-size:200% 200%;animation:cpholo 6s linear infinite;-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude} +@keyframes cpholo{0%{background-position:0% 50%}100%{background-position:200% 50%}} +.cardpage .reveal{opacity:0;transform:translateY(14px);animation:cprise .7s cubic-bezier(.2,.7,.2,1) forwards} +@keyframes cprise{to{opacity:1;transform:none}} +.cardpage .d1{animation-delay:.05s}.cardpage .d2{animation-delay:.14s}.cardpage .d3{animation-delay:.22s}.cardpage .d4{animation-delay:.32s}.cardpage .d5{animation-delay:.42s}.cardpage .d6{animation-delay:.52s} +.cp-avatar{width:112px;height:112px;border-radius:50%;position:relative;margin-bottom:22px;display:flex;align-items:center;justify-content:center} +.cp-avatar .ring{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,var(--cyan-glow),var(--violet),var(--indigo),var(--cyan-glow));animation:cpspin 7s linear infinite} +@keyframes cpspin{to{transform:rotate(360deg)}} +.cp-avatar .inner{position:absolute;inset:5px;border-radius:50%;background:linear-gradient(180deg,var(--card),var(--card2));display:flex;align-items:center;justify-content:center;overflow:hidden;z-index:1} +.cp-avatar .inner img{width:100%;height:100%;object-fit:cover} +.cp-avatar .inner span{font-family:'Unbounded';font-weight:600;font-size:38px;letter-spacing:-1px;background:linear-gradient(150deg,var(--indigo2),var(--indigo));-webkit-background-clip:text;background-clip:text;color:transparent} +@media (prefers-color-scheme:dark){.cp-avatar .inner span{background:linear-gradient(150deg,#fff,#cfe9ff);-webkit-background-clip:text;background-clip:text;color:transparent}} +.cp-card h1{font-family:'Unbounded';font-weight:600;font-size:26px;letter-spacing:-.6px;line-height:1.1} +.cp-card .role{color:var(--cyan);font-weight:600;font-size:14.5px;margin-top:9px} +@media (prefers-color-scheme:dark){.cp-card .role{color:var(--cyan-glow)}} +.cp-card .company{color:var(--ink2);font-size:13px;margin-top:7px;display:flex;align-items:center;gap:7px;justify-content:center} +.cp-card .actions{display:flex;gap:12px;margin:26px 0 6px} +.cp-card .act{width:56px;height:56px;border-radius:18px;border:1px solid var(--line);background:var(--card);display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--indigo);box-shadow:0 6px 16px -8px rgba(49,46,129,.4);transition:transform .18s,box-shadow .18s,color .18s,border-color .18s;-webkit-tap-highlight-color:transparent} +@media (prefers-color-scheme:dark){.cp-card .act{color:var(--cyan-glow)}} +.cp-card .act:hover{transform:translateY(-4px);color:var(--cyan);border-color:rgba(6,182,212,.5);box-shadow:0 12px 22px -10px rgba(6,182,212,.5)} +.cp-card .act svg{width:23px;height:23px} +.cp-card .save{margin-top:18px;width:100%;border:none;cursor:pointer;padding:17px 20px;border-radius:18px;font-family:'Onest';font-weight:600;font-size:16px;color:#fff;background:linear-gradient(110deg,var(--indigo),var(--violet) 35%,var(--cyan) 70%,var(--indigo));background-size:220% 100%;animation:cphb 5s linear infinite;box-shadow:0 16px 34px -12px rgba(49,46,129,.6);display:flex;align-items:center;justify-content:center;gap:10px;transition:transform .18s,box-shadow .18s} +@keyframes cphb{0%{background-position:0 0}100%{background-position:220% 0}} +.cp-card .save:hover{transform:translateY(-2px);box-shadow:0 20px 42px -12px rgba(124,92,255,.6)} +.cp-card .product{margin-top:13px;width:100%;text-decoration:none;color:var(--ink);border:1px solid var(--line);background:var(--card);border-radius:18px;padding:14px 16px;display:flex;align-items:center;gap:13px;text-align:left;transition:transform .18s,border-color .18s,box-shadow .18s} +.cp-card .product:hover{transform:translateY(-2px);border-color:rgba(6,182,212,.5);box-shadow:0 12px 24px -14px rgba(6,182,212,.5)} +.cp-card .product .ic{width:42px;height:42px;border-radius:12px;background:linear-gradient(145deg,var(--indigo2),var(--indigo));display:flex;align-items:center;justify-content:center;flex-shrink:0} +.cp-card .product .t{font-weight:600;font-size:15px;display:block} +.cp-card .product .s{color:var(--ink2);font-size:12px;margin-top:2px;display:block} +.cp-card .product .go{margin-left:auto;color:var(--cyan)} +.cp-card .foot{margin-top:24px;display:flex;align-items:center;gap:8px;color:var(--ink2);font-size:12px} +.cp-card .foot b{color:var(--ink);font-weight:600} +.cardpage .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(20px);background:var(--ink);color:var(--bg);padding:11px 18px;border-radius:12px;font-size:14px;opacity:0;pointer-events:none;transition:all .3s;z-index:6;box-shadow:0 12px 30px -8px rgba(0,0,0,.4)} +.cardpage .toast.on{opacity:1;transform:translateX(-50%) translateY(0)} +`;