Электронные визитки: таблица + API + публичная страница /card/:slug + раздел в админке с загрузкой фото

This commit is contained in:
Nik
2026-06-08 00:05:26 +03:00
parent 3a2404295d
commit 80f0367e72
9 changed files with 594 additions and 1 deletions
+199
View File
@@ -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)}
`;