Электронные визитки: таблица + API + публичная страница /card/:slug + раздел в админке с загрузкой фото
This commit is contained in:
@@ -12,7 +12,7 @@ dotenv.config();
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
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(cookieParser());
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -231,3 +231,106 @@ adminRouter.put('/settings/:key', requireAdmin, async (req, res) => {
|
|||||||
);
|
);
|
||||||
res.json({ ok: true });
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -51,3 +51,23 @@ publicRouter.get('/content', async (_req, res) => {
|
|||||||
res.status(500).json({ error: 'internal_error' });
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -5,6 +5,7 @@ import { Logo } from '../components/Icons';
|
|||||||
import { ProductsAdmin } from './ProductsAdmin';
|
import { ProductsAdmin } from './ProductsAdmin';
|
||||||
import { SectionsAdmin } from './SectionsAdmin';
|
import { SectionsAdmin } from './SectionsAdmin';
|
||||||
import { SettingsAdmin } from './SettingsAdmin';
|
import { SettingsAdmin } from './SettingsAdmin';
|
||||||
|
import { CardsAdmin } from './CardsAdmin';
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -53,6 +54,7 @@ export function AdminLayout() {
|
|||||||
{ to: '/admin/products', label: 'Продукты' },
|
{ to: '/admin/products', label: 'Продукты' },
|
||||||
{ to: '/admin/sections', label: 'Секции' },
|
{ to: '/admin/sections', label: 'Секции' },
|
||||||
{ to: '/admin/settings', label: 'Настройки' },
|
{ to: '/admin/settings', label: 'Настройки' },
|
||||||
|
{ to: '/admin/cards', label: 'Визитки' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
@@ -73,6 +75,7 @@ export function AdminLayout() {
|
|||||||
<Route path="products" element={<ProductsAdmin />} />
|
<Route path="products" element={<ProductsAdmin />} />
|
||||||
<Route path="sections" element={<SectionsAdmin />} />
|
<Route path="sections" element={<SectionsAdmin />} />
|
||||||
<Route path="settings" element={<SettingsAdmin />} />
|
<Route path="settings" element={<SettingsAdmin />} />
|
||||||
|
<Route path="cards" element={<CardsAdmin />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, type BusinessCard } from '../api/client';
|
||||||
|
|
||||||
|
type Draft = Partial<BusinessCard>;
|
||||||
|
|
||||||
|
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<BusinessCard[]>([]);
|
||||||
|
const [editing, setEditing] = useState<Draft | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const { cards } = await api.listCards();
|
||||||
|
setCards(cards);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
function patch(p: Partial<Draft>) { 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 <div className="text-slate-400">загрузка…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-lg font-medium text-brand-900">Визитки</h2>
|
||||||
|
<button onClick={() => { setErr(null); setEditing({ ...EMPTY }); }} className="btn-primary text-sm py-2">
|
||||||
|
+ Создать визитку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Имя</th>
|
||||||
|
<th className="text-left px-4 py-3">Адрес</th>
|
||||||
|
<th className="text-left px-4 py-3">Статус</th>
|
||||||
|
<th className="text-right px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className="px-4 py-3 font-medium text-brand-900">
|
||||||
|
{c.name}
|
||||||
|
<div className="text-xs text-slate-500 font-normal">{c.role}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600">
|
||||||
|
<a href={`/card/${c.slug}`} target="_blank" rel="noopener" className="text-brand-600 hover:text-brand-800">
|
||||||
|
/card/{c.slug} ↗
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600">{c.is_published === false ? 'скрыта' : 'опубликована'}</td>
|
||||||
|
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||||
|
<button onClick={() => { setErr(null); setEditing(c); }} className="text-brand-600 hover:text-brand-800 text-xs font-medium">Редактировать</button>
|
||||||
|
<button onClick={() => handleDelete(c)} className="text-rose-500 hover:text-rose-700 text-xs font-medium ml-3">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{cards.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="px-4 py-6 text-center text-slate-400">Пока нет визиток</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center p-4 z-50" onClick={() => setEditing(null)}>
|
||||||
|
<div className="bg-white rounded-2xl p-6 max-w-lg w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-medium text-brand-900 mb-4">{editing.id ? 'Редактировать визитку' : 'Новая визитка'}</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-20 h-20 rounded-full overflow-hidden bg-slate-100 flex items-center justify-center flex-shrink-0 border border-slate-200">
|
||||||
|
{editing.photo
|
||||||
|
? <img src={editing.photo} alt="" className="w-full h-full object-cover" />
|
||||||
|
: <span className="text-slate-400 text-xs">нет фото</span>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="btn-primary text-xs py-1.5 px-3 cursor-pointer inline-block">
|
||||||
|
Загрузить фото
|
||||||
|
<input type="file" accept="image/*" className="hidden" onChange={(e) => onPhoto(e.target.files?.[0])} />
|
||||||
|
</label>
|
||||||
|
{editing.photo && (
|
||||||
|
<button onClick={() => patch({ photo: '' })} className="block text-xs text-rose-500 hover:text-rose-700">Убрать фото</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Адрес страницы (латиницей)">
|
||||||
|
<input type="text" value={editing.slug || ''} onChange={(e) => patch({ slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') })} placeholder="alexey" className="input" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Имя">
|
||||||
|
<input type="text" value={editing.name || ''} onChange={(e) => patch({ name: e.target.value })} className="input" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Должность">
|
||||||
|
<input type="text" value={editing.role || ''} onChange={(e) => patch({ role: e.target.value })} className="input" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Компания">
|
||||||
|
<input type="text" value={editing.company || ''} onChange={(e) => patch({ company: e.target.value })} className="input" />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Телефон">
|
||||||
|
<input type="text" value={editing.phone || ''} onChange={(e) => patch({ phone: e.target.value })} placeholder="+7 900 000 0000" className="input" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Email">
|
||||||
|
<input type="text" value={editing.email || ''} onChange={(e) => patch({ email: e.target.value })} className="input" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Telegram (@ник или ссылка)">
|
||||||
|
<input type="text" value={editing.telegram || ''} onChange={(e) => patch({ telegram: e.target.value })} placeholder="@username" className="input" />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Продукт — название">
|
||||||
|
<input type="text" value={editing.product_title || ''} onChange={(e) => patch({ product_title: e.target.value })} placeholder="АгроТО" className="input" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Продукт — подпись">
|
||||||
|
<input type="text" value={editing.product_subtitle || ''} onChange={(e) => patch({ product_subtitle: e.target.value })} className="input" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Ссылка на продукт">
|
||||||
|
<input type="text" value={editing.product_url || ''} onChange={(e) => patch({ product_url: e.target.value })} placeholder="https://agroto.ru" className="input" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Сайт">
|
||||||
|
<input type="text" value={editing.site_url || ''} onChange={(e) => patch({ site_url: e.target.value })} className="input" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="text-rose-600 text-sm mt-3">{err}</div>}
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end mt-5">
|
||||||
|
<button onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 rounded-lg">Отмена</button>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="btn-primary text-sm py-2 disabled:opacity-50">{saving ? 'Сохраняю…' : 'Сохранить'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid rgb(226 232 240); border-radius: 0.5rem; font-size: 0.875rem; outline: none; }
|
||||||
|
.input:focus { border-color: rgb(99 102 241); }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 mb-1">{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,23 @@ export interface ApproachItem {
|
|||||||
sort_order: number;
|
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 {
|
export interface LandingContent {
|
||||||
sections: Record<string, Record<string, unknown>>;
|
sections: Record<string, Record<string, unknown>>;
|
||||||
products: Product[];
|
products: Product[];
|
||||||
@@ -97,4 +114,21 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ value }),
|
body: JSON.stringify({ value }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getCard: (slug: string) =>
|
||||||
|
request<{ card: BusinessCard }>(`/api/cards/${slug}`),
|
||||||
|
listCards: () =>
|
||||||
|
request<{ cards: BusinessCard[] }>('/api/admin/cards'),
|
||||||
|
createCard: (data: Partial<BusinessCard>) =>
|
||||||
|
request<{ card: BusinessCard }>('/api/admin/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
updateCard: (id: number, data: Partial<BusinessCard>) =>
|
||||||
|
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' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { Landing } from './pages/Landing';
|
import { Landing } from './pages/Landing';
|
||||||
|
import { CardPage } from './pages/CardPage';
|
||||||
import { AdminLogin } from './admin/AdminLogin';
|
import { AdminLogin } from './admin/AdminLogin';
|
||||||
import { AdminLayout } from './admin/AdminLayout';
|
import { AdminLayout } from './admin/AdminLayout';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
@@ -11,6 +12,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Landing />} />
|
<Route path="/" element={<Landing />} />
|
||||||
|
<Route path="/card/:slug" element={<CardPage />} />
|
||||||
<Route path="/admin/login" element={<AdminLogin />} />
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
<Route path="/admin/*" element={<AdminLayout />} />
|
<Route path="/admin/*" element={<AdminLayout />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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<BusinessCard | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const cardRef = useRef<HTMLElement>(null);
|
||||||
|
const glowRef = useRef<HTMLDivElement>(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 <div className="cardpage-msg">Визитка не найдена</div>;
|
||||||
|
if (!card) return <div className="cardpage-msg">загрузка…</div>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="cardpage">
|
||||||
|
<div className="blob b1" /><div className="blob b2" /><div className="blob b3" />
|
||||||
|
<div className="glow" ref={glowRef} />
|
||||||
|
|
||||||
|
<main className="cp-card" ref={cardRef}>
|
||||||
|
<div className="cp-avatar reveal d1">
|
||||||
|
<div className="ring" />
|
||||||
|
<div className="inner">
|
||||||
|
{card.photo
|
||||||
|
? <img src={card.photo} alt={card.name} />
|
||||||
|
: <span>{initials}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="reveal d2">{card.name}</h1>
|
||||||
|
{card.role && <div className="role reveal d2">{card.role}</div>}
|
||||||
|
{card.company && (
|
||||||
|
<div className="company reveal d3">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 72 72"><rect x="6" y="6" width="60" height="60" rx="14" fill="#312E81" /><circle cx="22" cy="22" r="3.5" fill="#fff" /><circle cx="36" cy="22" r="3.5" fill="#fff" opacity=".4" /><circle cx="50" cy="22" r="3.5" fill="#fff" /><circle cx="22" cy="36" r="3.5" fill="#fff" opacity=".4" /><circle cx="36" cy="36" r="5" fill="#22D3EE" /><circle cx="50" cy="36" r="3.5" fill="#fff" /><circle cx="22" cy="50" r="3.5" fill="#fff" /><circle cx="36" cy="50" r="3.5" fill="#fff" opacity=".4" /><circle cx="50" cy="50" r="3.5" fill="#fff" /></svg>
|
||||||
|
{card.company}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="actions reveal d4">
|
||||||
|
{card.phone && (
|
||||||
|
<button className="act" title="Позвонить" onClick={() => { window.location.href = telHref; }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z" /></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{card.email && (
|
||||||
|
<button className="act" title="Написать" onClick={() => { window.location.href = 'mailto:' + card.email; }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="4" width="20" height="16" rx="2" /><path d="m22 7-10 6L2 7" /></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{card.telegram && (
|
||||||
|
<button className="act" title="Telegram" onClick={() => { window.location.href = card.telegram!.startsWith('http') ? card.telegram! : 'https://t.me/' + card.telegram!.replace('@', ''); }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21.94 4.6 18.6 20.3c-.25 1.1-.9 1.38-1.83.86l-5.05-3.72-2.44 2.35c-.27.27-.5.5-1 .5l.36-5.14L17 6.36c.4-.36-.09-.56-.62-.2L6.9 12.3l-4.97-1.56c-1.08-.34-1.1-1.08.23-1.6L20.5 3.05c.9-.34 1.69.2 1.44 1.55z" /></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{card.site_url && (
|
||||||
|
<button className="act" title="Сайт" onClick={() => { window.location.href = card.site_url!; }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="save reveal d5" onClick={saveContact}>
|
||||||
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>
|
||||||
|
Сохранить контакт
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{card.product_title && (
|
||||||
|
<a className="product reveal d6" href={card.product_url || '#'} target="_blank" rel="noopener">
|
||||||
|
<span className="ic"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" /></svg></span>
|
||||||
|
<span><span className="t">{card.product_title}</span>{card.product_subtitle && <span className="s">{card.product_subtitle}</span>}</span>
|
||||||
|
<span className="go"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="foot reveal d6">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 72 72"><rect x="6" y="6" width="60" height="60" rx="14" fill="#312E81" /><circle cx="36" cy="36" r="5" fill="#22D3EE" /><circle cx="22" cy="22" r="3" fill="#fff" /><circle cx="50" cy="22" r="3" fill="#fff" /><circle cx="22" cy="50" r="3" fill="#fff" /><circle cx="50" cy="50" r="3" fill="#fff" /></svg>
|
||||||
|
<span><b>Умный Байт</b> · umbyte.ru</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="toast" id="cp-toast">Контакт сохранён ✓</div>
|
||||||
|
|
||||||
|
<style>{cardCss}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}
|
||||||
|
`;
|
||||||
Reference in New Issue
Block a user