card: кнопка QR-кода визитки (локальная генерация, без внешних сервисов)

This commit is contained in:
admin
2026-07-07 18:44:43 +03:00
parent 6f9752023d
commit 3d4bed91ec
3 changed files with 3088 additions and 0 deletions
+3056
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,11 +9,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"
}, },
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
+30
View File
@@ -1,13 +1,16 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import QRCode from 'qrcode';
import { api, type BusinessCard } from '../api/client'; import { api, type BusinessCard } from '../api/client';
export function CardPage() { export function CardPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const [card, setCard] = useState<BusinessCard | null>(null); const [card, setCard] = useState<BusinessCard | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [qrOpen, setQrOpen] = useState(false);
const cardRef = useRef<HTMLElement>(null); const cardRef = useRef<HTMLElement>(null);
const glowRef = useRef<HTMLDivElement>(null); const glowRef = useRef<HTMLDivElement>(null);
const qrCanvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => { useEffect(() => {
if (!slug) return; if (!slug) return;
@@ -71,6 +74,15 @@ export function CardPage() {
toast('Открываю карточку контакта…'); toast('Открываю карточку контакта…');
} catch { toast('Не удалось открыть'); } } catch { toast('Не удалось открыть'); }
} }
useEffect(() => {
if (!qrOpen || !qrCanvasRef.current) return;
QRCode.toCanvas(qrCanvasRef.current, window.location.href, {
width: 240,
margin: 1,
color: { dark: '#15132E', light: '#FFFFFF' },
}).catch(() => toast('Не удалось построить QR'));
}, [qrOpen]);
function toast(msg: string) { function toast(msg: string) {
const t = document.getElementById('cp-toast'); const t = document.getElementById('cp-toast');
if (!t) return; if (!t) return;
@@ -122,6 +134,9 @@ export function CardPage() {
<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> <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> </button>
)} )}
<button className="act" title="Показать QR" onClick={() => setQrOpen(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><path d="M14 14h3v3h-3zM19 14h2v2h-2zM14 19h2v2h-2zM19 19h2v2h-2z" /></svg>
</button>
</div> </div>
<button className="save" onClick={saveContact}> <button className="save" onClick={saveContact}>
@@ -145,6 +160,16 @@ export function CardPage() {
<div className="toast" id="cp-toast">Контакт сохранён </div> <div className="toast" id="cp-toast">Контакт сохранён </div>
{qrOpen && (
<div className="qr-overlay" onClick={() => setQrOpen(false)}>
<div className="qr-box" onClick={(e) => e.stopPropagation()}>
<canvas ref={qrCanvasRef} />
<div className="qr-hint">Отсканируйте, чтобы открыть визитку</div>
<button className="qr-close" onClick={() => setQrOpen(false)}>Закрыть</button>
</div>
</div>
)}
<style>{cardCss}</style> <style>{cardCss}</style>
</div> </div>
); );
@@ -202,4 +227,9 @@ const cardCss = `
.cp-card .foot b{color:var(--ink);font-weight:600} .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{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)} .cardpage .toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
.qr-overlay{position:fixed;inset:0;z-index:10;background:rgba(21,19,46,.55);backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:20px;animation:cprise .2s ease-out}
.qr-box{background:var(--card);border-radius:24px;padding:28px 26px 22px;display:flex;flex-direction:column;align-items:center;gap:14px;box-shadow:var(--shadow);max-width:300px}
.qr-box canvas{border-radius:14px;display:block}
.qr-hint{font-size:13px;color:var(--ink2);text-align:center}
.qr-close{border:none;cursor:pointer;padding:12px 22px;border-radius:14px;font-family:'Onest';font-weight:600;font-size:14px;color:#fff;background:linear-gradient(110deg,var(--indigo),var(--violet) 60%,var(--cyan));width:100%}
`; `;