Files
umbyte-landing/frontend/src/pages/CardPage.tsx
T

206 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
const vcard = lines.join('\r\n');
try {
const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent);
if (isIOS) {
window.location.href = 'data:text/vcard;charset=utf-8,' + encodeURIComponent(vcard);
} else {
const blob = new Blob([vcard], { 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" 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%;opacity:0;animation:cpfade .7s ease .42s forwards,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)}
`;