feat(docs): add animated product website

This commit is contained in:
2026-07-16 16:38:30 +02:00
parent e40a4bfee2
commit d61d6f3c5b
38 changed files with 12046 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { Icon } from "@/components/icons";
type ApprovalState = "pending" | "approved" | "refused";
export function ApprovalDemo() {
const [state, setState] = useState<ApprovalState>("pending");
return (
<div className={`approval-demo state-${state}`}>
<div className="approval-window-bar">
<span />
<span />
<span />
<small>Receiver requests</small>
</div>
<div className="approval-body" aria-live="polite">
<div className="approval-heading">
<div className="device-avatar">
<span className="device-avatar-screen" />
<span className="device-avatar-glow" />
</div>
<div>
<span className="mono-label">
{state === "pending" ? "NEW REQUEST" : state === "approved" ? "IN PROGRESS" : "REQUEST CLOSED"}
</span>
<h3>
{state === "pending" && "Mayas phone wants to receive"}
{state === "approved" && "Sending securely to Maya"}
{state === "refused" && "Request refused"}
</h3>
</div>
</div>
<div className="approval-file">
<span className="approval-file-icon">
<Icon name="folder" />
</span>
<span>
<strong>Summer photos</strong>
<small>42 files · 1.8 GB</small>
</span>
{state === "approved" && <span className="approval-percent">68%</span>}
</div>
{state === "pending" && (
<div className="approval-actions">
<button className="approval-refuse" type="button" onClick={() => setState("refused")}>
Refuse
</button>
<button className="approval-accept" type="button" onClick={() => setState("approved")}>
<Icon name="check" />
Approve
</button>
</div>
)}
{state === "approved" && (
<div className="approval-progress">
<div className="approval-progress-track">
<span />
</div>
<div className="approval-progress-meta">
<span><i className="live-dot" /> Direct connection</span>
<button type="button" onClick={() => setState("pending")}>Reset demo</button>
</div>
</div>
)}
{state === "refused" && (
<div className="approval-refused">
<span><Icon name="x" /></span>
<p>This device was not given access.</p>
<button type="button" onClick={() => setState("pending")}>Try again</button>
</div>
)}
</div>
<div className="approval-footer">
<Icon name="shield" />
Approval is required by default
</div>
</div>
);
}

View File

@@ -0,0 +1,267 @@
/* Interactive receiver approval */
.approval-demo {
overflow: hidden;
border: 1px solid #ded6e2;
border-radius: 24px;
background: #fbf9fc;
box-shadow: 0 35px 90px rgba(49, 29, 58, 0.16);
transform: rotate(1deg);
transition: transform 280ms ease, border-color 280ms ease;
}
.approval-demo:hover { transform: rotate(0deg) translateY(-3px); }
.approval-demo.state-approved { border-color: rgba(45, 185, 117, 0.4); }
.approval-demo.state-refused { border-color: rgba(221, 92, 75, 0.35); }
.approval-window-bar {
display: flex;
height: 48px;
align-items: center;
gap: 6px;
padding: 0 16px;
border-bottom: 1px solid #ded6e2;
background: #f0ecf2;
}
.approval-window-bar > span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d1c7d5;
}
.approval-window-bar > span:nth-child(1) { background: #f1a191; }
.approval-window-bar > span:nth-child(2) { background: #f1ca72; }
.approval-window-bar > span:nth-child(3) { background: #72cf9b; }
.approval-window-bar small {
margin-left: auto;
color: #8a7f8f;
font-family: var(--font-mono);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.06em;
}
.approval-body {
min-height: 385px;
padding: 35px;
background:
radial-gradient(circle at 90% 0%, rgba(192, 132, 252, 0.16), transparent 27%),
white;
}
.approval-heading {
display: flex;
align-items: center;
gap: 17px;
margin-bottom: 30px;
}
.device-avatar {
position: relative;
width: 52px;
height: 60px;
flex: 0 0 auto;
border: 4px solid #352e39;
border-radius: 13px;
background: #211d24;
box-shadow: 0 10px 25px rgba(42, 30, 48, 0.16);
}
.device-avatar::before {
position: absolute;
top: 4px;
left: 50%;
width: 16px;
height: 3px;
border-radius: 999px;
background: #0c0a0d;
content: "";
transform: translateX(-50%);
}
.device-avatar-screen {
position: absolute;
inset: 14px 8px 8px;
border-radius: 7px;
background: linear-gradient(145deg, #b55efa, #6f27e9);
}
.device-avatar-glow {
position: absolute;
inset: -5px;
z-index: -1;
border-radius: 16px;
box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.25);
animation: avatar-pulse 2.7s ease-out infinite;
}
.approval-heading h3 {
max-width: 360px;
margin: 5px 0 0;
font-size: 21px;
letter-spacing: -0.035em;
line-height: 1.22;
}
.approval-file {
display: flex;
align-items: center;
gap: 13px;
padding: 16px;
border: 1px solid #e5dde8;
border-radius: 14px;
background: #f8f5f9;
}
.approval-file-icon {
display: grid;
width: 42px;
height: 42px;
flex: 0 0 auto;
place-items: center;
border-radius: 11px;
background: var(--brand-100);
color: var(--brand-600);
}
.approval-file-icon svg { width: 21px; }
.approval-file strong,
.approval-file small { display: block; }
.approval-file strong { font-size: 12px; }
.approval-file small { color: var(--ink-muted); font-size: 9px; }
.approval-percent {
margin-left: auto;
color: var(--brand-600);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 800;
}
.approval-actions {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 10px;
margin-top: 22px;
}
.approval-actions button,
.approval-refused button {
min-height: 47px;
border: 1px solid #ded4e2;
border-radius: 12px;
font-size: 12px;
font-weight: 750;
cursor: pointer;
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.approval-actions button:hover,
.approval-refused button:hover { transform: translateY(-2px); }
.approval-refuse { background: white; color: #625769; }
.approval-accept {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-color: transparent !important;
background: linear-gradient(135deg, var(--brand-500), var(--brand-700));
box-shadow: 0 10px 24px rgba(132, 47, 238, 0.22);
color: white;
}
.approval-accept svg { width: 17px; }
.approval-progress {
margin-top: 25px;
animation: approval-state-enter 350ms ease;
}
.approval-progress-track {
overflow: hidden;
height: 8px;
border-radius: 999px;
background: #eee8f1;
}
.approval-progress-track span {
display: block;
width: 68%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--brand-500), var(--brand-700));
box-shadow: 0 0 15px rgba(168, 85, 247, 0.38);
animation: progress-fill 900ms cubic-bezier(0.25, 0.8, 0.25, 1) both;
}
.approval-progress-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 13px;
color: var(--ink-muted);
font-size: 9px;
}
.approval-progress-meta > span {
display: flex;
align-items: center;
gap: 9px;
}
.approval-progress-meta button,
.approval-refused button {
border: 0;
background: transparent;
color: var(--brand-600);
font-size: 9px;
font-weight: 750;
cursor: pointer;
}
.approval-refused {
display: flex;
align-items: center;
gap: 12px;
margin-top: 23px;
padding: 14px;
border: 1px solid #f0d5d0;
border-radius: 12px;
background: #fff8f7;
animation: approval-state-enter 350ms ease;
}
.approval-refused > span {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 auto;
place-items: center;
border-radius: 50%;
background: #fde7e3;
color: #d85848;
}
.approval-refused svg { width: 15px; }
.approval-refused p { margin: 0; color: #705c59; font-size: 10px; }
.approval-refused button { margin-left: auto; }
.approval-footer {
display: flex;
height: 50px;
align-items: center;
justify-content: center;
gap: 8px;
border-top: 1px solid #ded6e2;
background: #f2eef4;
color: #766b7b;
font-family: var(--font-mono);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.04em;
}
.approval-footer svg { width: 15px; color: var(--brand-600); }

65
docs/components/brand.tsx Normal file
View File

@@ -0,0 +1,65 @@
import Link from "next/link";
type BrandMarkProps = {
className?: string;
title?: string;
};
export function BrandMark({ className, title }: BrandMarkProps) {
return (
<svg
className={className}
viewBox="0 0 64 64"
fill="none"
role={title ? "img" : undefined}
aria-hidden={title ? undefined : true}
aria-label={title}
>
<path
d="M11 8h10.6a4.8 4.8 0 0 1 4.8 4.8v25.4c0 8.7 6.1 15.2 14.5 15.2 8.3 0 14.1-6.5 14.1-15.2V12.8A4.8 4.8 0 0 1 59.8 8H62v30.4C62 52.5 53.3 61 40.9 61 28.3 61 19 52.5 19 38.4V23h-3.5v-6.4H11V8Z"
fill="url(#brand-mark-gradient)"
/>
<path
d="M40.8 25.9c-3.1 4.1-8.2 10.8-8.2 15.5 0 5.1 3.6 8.9 8.2 8.9 4.8 0 8.3-3.8 8.3-8.9 0-4.7-5.2-11.4-8.3-15.5Z"
fill="url(#brand-drop-gradient)"
/>
<path d="M54.9 8h7v7l-7-7Z" fill="#E8CDFF" />
<defs>
<linearGradient
id="brand-mark-gradient"
x1="11"
y1="8"
x2="64"
y2="55"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#B45CFA" />
<stop offset="0.5" stopColor="#9D4DF4" />
<stop offset="1" stopColor="#7828ED" />
</linearGradient>
<linearGradient
id="brand-drop-gradient"
x1="32"
y1="26"
x2="51"
y2="47"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#C06CFF" />
<stop offset="1" stopColor="#7828ED" />
</linearGradient>
</defs>
</svg>
);
}
export function Brand({ compact = false }: { compact?: boolean }) {
return (
<Link className="brand" href="/" aria-label="VniDrop home">
<span className="brand-mark-shell">
<BrandMark />
</span>
{!compact && <span className="brand-name">VniDrop</span>}
</Link>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import type { CSSProperties, PointerEvent } from "react";
import { useRef } from "react";
import { Icon } from "@/components/icons";
type StageStyle = CSSProperties & {
"--pointer-x": string;
"--pointer-y": string;
};
export function HeroIllustration() {
const stageRef = useRef<HTMLDivElement>(null);
const updateParallax = (event: PointerEvent<HTMLDivElement>) => {
const stage = stageRef.current;
if (!stage || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const bounds = stage.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 2;
const y = ((event.clientY - bounds.top) / bounds.height - 0.5) * 2;
stage.style.setProperty("--pointer-x", x.toFixed(3));
stage.style.setProperty("--pointer-y", y.toFixed(3));
};
const resetParallax = () => {
stageRef.current?.style.setProperty("--pointer-x", "0");
stageRef.current?.style.setProperty("--pointer-y", "0");
};
return (
<figure className="hero-visual">
<div
ref={stageRef}
className="hero-stage"
style={{ "--pointer-x": "0", "--pointer-y": "0" } as StageStyle}
onPointerMove={updateParallax}
onPointerLeave={resetParallax}
aria-hidden="true"
>
<div className="hero-grid" />
<div className="hero-glow hero-glow-one" />
<div className="hero-glow hero-glow-two" />
<div className="hero-status hero-status-secure">
<span className="status-icon">
<Icon name="lock" />
</span>
<span>
<strong>End-to-end encrypted</strong>
<small>Authenticated connection</small>
</span>
</div>
<div className="hero-status hero-status-route">
<span className="status-pulse" />
Direct route found
</div>
<svg className="hero-scene" viewBox="0 0 760 620">
<defs>
<linearGradient id="hero-purple" x1="240" y1="90" x2="620" y2="520">
<stop stopColor="#C16BFF" />
<stop offset="0.48" stopColor="#A855F7" />
<stop offset="1" stopColor="#6F27E9" />
</linearGradient>
<linearGradient id="hero-screen" x1="88" y1="118" x2="342" y2="345">
<stop stopColor="#25202C" />
<stop offset="1" stopColor="#151319" />
</linearGradient>
<linearGradient id="hero-phone" x1="540" y1="160" x2="692" y2="465">
<stop stopColor="#2A2530" />
<stop offset="1" stopColor="#141217" />
</linearGradient>
<radialGradient id="hero-orb">
<stop stopColor="#E3BDFF" stopOpacity="0.9" />
<stop offset="0.45" stopColor="#A855F7" stopOpacity="0.45" />
<stop offset="1" stopColor="#A855F7" stopOpacity="0" />
</radialGradient>
<filter id="hero-shadow" x="-30%" y="-30%" width="160%" height="180%">
<feDropShadow dx="0" dy="20" stdDeviation="18" floodColor="#3F2454" floodOpacity="0.16" />
</filter>
<filter id="hero-glow-filter" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="12" />
</filter>
<clipPath id="laptop-screen-clip">
<rect x="82" y="132" width="264" height="176" rx="12" />
</clipPath>
<clipPath id="phone-screen-clip">
<rect x="548" y="171" width="136" height="279" rx="25" />
</clipPath>
</defs>
<g className="scene-orbits">
<ellipse cx="385" cy="325" rx="315" ry="216" fill="none" stroke="#B969F8" strokeOpacity="0.14" />
<ellipse cx="385" cy="325" rx="245" ry="169" fill="none" stroke="#B969F8" strokeOpacity="0.12" strokeDasharray="8 14" />
<circle cx="670" cy="215" r="5" fill="#A855F7" />
<circle cx="117" cy="455" r="4" fill="#C98AFD" />
<path d="m704 345 4 10 10 4-10 4-4 10-4-10-10-4 10-4Z" fill="#D9AEFF" />
</g>
<g className="scene-laptop" filter="url(#hero-shadow)">
<rect x="69" y="117" width="290" height="207" rx="19" fill="#E8E4EB" stroke="#D6CFDA" strokeWidth="2" />
<rect x="80" y="129" width="268" height="183" rx="13" fill="url(#hero-screen)" />
<g clipPath="url(#laptop-screen-clip)">
<circle cx="208" cy="235" r="108" fill="#A855F7" opacity="0.07" />
<path d="M80 278c62-45 99-24 141-60 39-34 70-39 127-31v125H80Z" fill="#A855F7" opacity="0.09" />
<rect x="101" y="151" width="101" height="13" rx="6.5" fill="#3E3745" />
<rect x="101" y="175" width="67" height="7" rx="3.5" fill="#5E5366" />
<g className="laptop-files">
<rect x="118" y="205" width="76" height="79" rx="10" fill="#F8F2FC" stroke="#C98AFD" />
<path d="M173 205h21v21Z" fill="#E3C3FA" />
<rect x="129" y="219" width="24" height="24" rx="6" fill="#E8D0FB" />
<path d="m135 238 7-8 5 5 4-4 6 7Z" fill="#A855F7" opacity="0.75" />
<rect x="129" y="253" width="46" height="6" rx="3" fill="#C3B6CB" />
<rect x="129" y="265" width="34" height="5" rx="2.5" fill="#DDD4E2" />
<rect x="181" y="223" width="82" height="68" rx="10" fill="#2F2935" stroke="#574D5E" />
<path d="M242 223h21v21Z" fill="#4F4557" />
<circle cx="205" cy="245" r="9" fill="#A855F7" opacity="0.75" />
<rect x="195" y="264" width="49" height="6" rx="3" fill="#63586A" />
<rect x="195" y="276" width="34" height="5" rx="2.5" fill="#4D4453" />
</g>
</g>
<path d="M46 324h336l-20 23a20 20 0 0 1-15 6H81a20 20 0 0 1-15-7Z" fill="#D9D3DD" stroke="#CAC1CE" strokeWidth="2" />
<path d="M183 324h62l-7 12h-48Z" fill="#C1B8C6" />
</g>
<g className="transfer-conduit">
<path
className="conduit-glow"
d="M284 358c23 134 225 151 279 35"
fill="none"
stroke="#B65DF9"
strokeOpacity="0.2"
strokeWidth="34"
strokeLinecap="round"
filter="url(#hero-glow-filter)"
/>
<path
d="M284 358c23 134 225 151 279 35"
fill="none"
stroke="#ECE3F1"
strokeWidth="19"
strokeLinecap="round"
/>
<path
className="conduit-energy"
d="M284 358c23 134 225 151 279 35"
fill="none"
stroke="url(#hero-purple)"
strokeWidth="7"
strokeLinecap="round"
strokeDasharray="2 17"
/>
<circle cx="284" cy="358" r="25" fill="#F8F2FC" stroke="#C98AFD" strokeWidth="2" />
<circle cx="284" cy="358" r="13" fill="#A855F7" opacity="0.15" />
<path d="m276 358 7 7 11-14" fill="none" stroke="#9333EA" strokeWidth="3" strokeLinecap="round" />
</g>
<g className="approval-gate" filter="url(#hero-shadow)">
<rect x="365" y="414" width="151" height="58" rx="18" fill="#FEFCFF" stroke="#DFCDEB" />
<circle cx="393" cy="443" r="15" fill="#F0DDFE" />
<path d="M393 433.5 385.5 437v5.3c0 4.5 3.2 7.6 7.5 8.9 4.3-1.3 7.5-4.4 7.5-8.9V437Z" fill="#A855F7" />
<path d="m389.5 442 2.2 2.2 4.5-4.6" fill="none" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
<text x="418" y="439" fill="#201B24" fontSize="12" fontWeight="700">Approved</text>
<text x="418" y="455" fill="#74697B" fontSize="10">Mayas phone</text>
</g>
<g className="scene-phone" filter="url(#hero-shadow)">
<rect x="536" y="157" width="160" height="309" rx="36" fill="#E4DFE7" stroke="#CFC6D3" strokeWidth="2" />
<rect x="546" y="168" width="140" height="285" rx="28" fill="url(#hero-phone)" />
<rect x="587" y="177" width="58" height="8" rx="4" fill="#0B090D" />
<g clipPath="url(#phone-screen-clip)">
<circle cx="616" cy="271" r="76" fill="#A855F7" opacity="0.1" />
<circle cx="616" cy="275" r="49" fill="url(#hero-orb)" opacity="0.55" />
<g className="received-file">
<rect x="579" y="224" width="74" height="92" rx="12" fill="#FCF9FD" stroke="#C98AFD" strokeWidth="1.5" />
<path d="M630 224h23v23Z" fill="#DEC0F6" />
<rect x="590" y="239" width="26" height="26" rx="7" fill="#E9D2FA" />
<path d="m595 259 8-9 5 5 5-5 6 9Z" fill="#9E47EF" />
<rect x="590" y="278" width="46" height="6" rx="3" fill="#C6BACD" />
<rect x="590" y="291" width="34" height="5" rx="2.5" fill="#DFD6E4" />
</g>
<rect x="564" y="337" width="104" height="42" rx="12" fill="#2F2935" stroke="#4B4251" />
<circle cx="581" cy="358" r="8" fill="#A855F7" />
<path d="m577.5 358 2.2 2.2 4.5-4.6" fill="none" stroke="white" strokeWidth="1.5" />
<text x="595" y="355" fill="#FCF9FD" fontSize="10" fontWeight="700">Received</text>
<text x="595" y="368" fill="#A89EAF" fontSize="8">Verified safely</text>
<rect x="564" y="393" width="104" height="5" rx="2.5" fill="#3F3745" />
<rect x="564" y="393" width="104" height="5" rx="2.5" fill="#A855F7" />
</g>
</g>
<g className="transfer-packets">
<g className="packet packet-one">
<rect x="0" y="0" width="28" height="34" rx="6" fill="#FCF9FE" stroke="#A855F7" />
<path d="M19 0h9v9Z" fill="#DDBCF7" />
</g>
<g className="packet packet-two">
<rect x="0" y="0" width="22" height="27" rx="5" fill="#EEE0F9" stroke="#B869F4" />
<path d="M14 0h8v8Z" fill="#CAA0E9" />
</g>
<circle className="packet-spark packet-spark-one" cx="0" cy="0" r="5" fill="#D795FF" />
<circle className="packet-spark packet-spark-two" cx="0" cy="0" r="3" fill="#A855F7" />
</g>
</svg>
<div className="hero-file-label">
<span className="file-type-icon">JPG</span>
<span>
<strong>summer-photos</strong>
<small>42 files · structure preserved</small>
</span>
</div>
</div>
<figcaption className="sr-only">
An illustration of encrypted files moving from a laptop to a phone after receiver approval.
</figcaption>
</figure>
);
}

View File

@@ -0,0 +1,247 @@
/* Hero transfer scene */
.hero-visual {
position: relative;
width: min(100%, 730px);
margin: 0;
}
.hero-stage {
--pointer-x: 0;
--pointer-y: 0;
position: relative;
width: 100%;
aspect-ratio: 1.13;
isolation: isolate;
perspective: 1200px;
}
.hero-grid {
position: absolute;
z-index: -3;
inset: 9% 2% 4% 7%;
border: 1px solid rgba(168, 85, 247, 0.14);
border-radius: 50%;
background-image:
linear-gradient(rgba(121, 82, 138, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(121, 82, 138, 0.07) 1px, transparent 1px);
background-size: 32px 32px;
transform: rotateX(64deg) rotateZ(-8deg) translateZ(-70px);
mask-image: radial-gradient(circle, black 10%, transparent 70%);
}
.hero-glow {
position: absolute;
z-index: -2;
border-radius: 50%;
filter: blur(60px);
pointer-events: none;
}
.hero-glow-one {
top: 22%;
right: 8%;
width: 37%;
aspect-ratio: 1;
background: rgba(168, 85, 247, 0.23);
transform: translate(calc(var(--pointer-x) * 12px), calc(var(--pointer-y) * 12px));
}
.hero-glow-two {
bottom: 8%;
left: 28%;
width: 30%;
aspect-ratio: 1;
background: rgba(126, 58, 236, 0.16);
transform: translate(calc(var(--pointer-x) * -9px), calc(var(--pointer-y) * -9px));
}
.hero-scene {
position: absolute;
z-index: 1;
inset: 1% -3% auto -1%;
width: 105%;
overflow: visible;
transform: rotateY(calc(var(--pointer-x) * 1.7deg)) rotateX(calc(var(--pointer-y) * -1.7deg));
transform-origin: center;
transition: transform 180ms ease-out;
}
.scene-laptop {
transform: translate(calc(var(--pointer-x) * -4px), calc(var(--pointer-y) * -4px));
transition: transform 200ms ease-out;
}
.scene-phone {
transform: translate(calc(var(--pointer-x) * 7px), calc(var(--pointer-y) * 5px));
transition: transform 200ms ease-out;
}
.scene-orbits {
transform-origin: 385px 325px;
animation: scene-orbits 30s linear infinite;
}
.laptop-files {
transform-origin: 190px 245px;
animation: laptop-files 6s ease-in-out infinite;
}
.conduit-energy {
animation: conduit-energy 2s linear infinite;
}
.approval-gate {
transform-origin: 440px 443px;
animation: gate-hover 5s ease-in-out infinite;
}
.received-file {
transform-origin: 616px 270px;
animation: received-file 7s cubic-bezier(0.34, 1.2, 0.55, 1) infinite;
}
.packet,
.packet-spark {
opacity: 0;
transform-box: fill-box;
transform-origin: center;
}
.packet-one {
animation: hero-packet-one 7s cubic-bezier(0.45, 0.05, 0.35, 1) 0.6s infinite;
}
.packet-two {
animation: hero-packet-two 7s cubic-bezier(0.45, 0.05, 0.35, 1) 3s infinite;
}
.packet-spark-one {
animation: hero-spark-one 7s ease 0.6s infinite;
}
.packet-spark-two {
animation: hero-spark-two 7s ease 3s infinite;
}
.hero-status {
position: absolute;
z-index: 5;
display: flex;
align-items: center;
border: 1px solid rgba(222, 211, 228, 0.95);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 15px 45px rgba(49, 27, 61, 0.11);
backdrop-filter: blur(12px);
transition: transform 180ms ease-out;
}
.hero-status-secure {
top: 2%;
right: 5%;
gap: 10px;
padding: 10px 13px 10px 10px;
border-radius: 14px;
transform: translate(calc(var(--pointer-x) * 12px), calc(var(--pointer-y) * 8px)) rotate(2deg);
animation: status-hover 6s ease-in-out infinite;
}
.status-icon {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 10px;
background: var(--brand-100);
color: var(--brand-600);
}
.status-icon svg {
width: 17px;
}
.hero-status strong,
.hero-status small {
display: block;
}
.hero-status strong {
color: #2b2530;
font-size: 10px;
line-height: 1.4;
}
.hero-status small {
color: #897f8e;
font-size: 8px;
}
.hero-status-route {
top: 18%;
left: 9%;
gap: 8px;
padding: 7px 11px;
border-radius: 999px;
color: #534b57;
font-family: var(--font-mono);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.04em;
transform: translate(calc(var(--pointer-x) * -8px), calc(var(--pointer-y) * -6px)) rotate(-3deg);
}
.status-pulse,
.live-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: #2bc477;
box-shadow: 0 0 0 4px rgba(43, 196, 119, 0.13);
animation: status-pulse 2.4s ease-in-out infinite;
}
.hero-file-label {
position: absolute;
z-index: 4;
bottom: 7%;
left: 7%;
display: flex;
align-items: center;
gap: 11px;
padding: 10px 13px;
border: 1px solid rgba(222, 211, 228, 0.95);
border-radius: 14px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 15px 45px rgba(49, 27, 61, 0.11);
backdrop-filter: blur(12px);
transform: translate(calc(var(--pointer-x) * -10px), calc(var(--pointer-y) * -7px)) rotate(-2deg);
transition: transform 180ms ease-out;
}
.file-type-icon {
display: grid;
width: 37px;
height: 37px;
place-items: center;
border-radius: 10px;
background: linear-gradient(135deg, var(--brand-400), var(--brand-600));
color: white;
font-family: var(--font-mono);
font-size: 8px;
font-weight: 800;
}
.hero-file-label strong,
.hero-file-label small {
display: block;
}
.hero-file-label strong {
font-size: 10px;
}
.hero-file-label small {
color: var(--ink-muted);
font-size: 8px;
}

140
docs/components/icons.tsx Normal file
View File

@@ -0,0 +1,140 @@
import type { SVGProps } from "react";
export type IconName =
| "arrow"
| "check"
| "chevron"
| "close"
| "code"
| "devices"
| "file"
| "folder"
| "github"
| "globe"
| "lock"
| "menu"
| "nfc"
| "qr"
| "shield"
| "spark"
| "stop"
| "verified"
| "x";
type IconProps = SVGProps<SVGSVGElement> & {
name: IconName;
};
export function Icon({ name, ...props }: IconProps) {
const paths: Record<IconName, React.ReactNode> = {
arrow: (
<>
<path d="M5 12h14" />
<path d="m14 7 5 5-5 5" />
</>
),
check: <path d="m5 12 4 4L19 6" />,
chevron: <path d="m9 18 6-6-6-6" />,
close: (
<>
<path d="M6 6l12 12" />
<path d="M18 6 6 18" />
</>
),
code: (
<>
<path d="m8 9-4 3 4 3" />
<path d="m16 9 4 3-4 3" />
<path d="m14 5-4 14" />
</>
),
devices: (
<>
<rect x="3" y="5" width="13" height="10" rx="2" />
<path d="M7 19h5M9.5 15v4" />
<rect x="17" y="8" width="4" height="10" rx="1" />
</>
),
file: (
<>
<path d="M6 3h8l4 4v14H6z" />
<path d="M14 3v5h4M9 13h6M9 17h4" />
</>
),
folder: (
<path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H10l2 2h6.5A2.5 2.5 0 0 1 21 9.5v7a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 16.5z" />
),
github: (
<path d="M12 2a10 10 0 0 0-3.2 19.5c.5.1.7-.2.7-.5v-1.9c-2.8.6-3.4-1.2-3.4-1.2-.5-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 0 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.2-.3-4.6-1.1-4.6-5a3.9 3.9 0 0 1 1-2.7c-.1-.3-.4-1.3.1-2.7 0 0 .9-.3 2.8 1a9.6 9.6 0 0 1 5.1 0c2-1.3 2.8-1 2.8-1 .6 1.4.2 2.4.1 2.7a3.9 3.9 0 0 1 1 2.7c0 3.9-2.4 4.7-4.6 5 .4.3.7.9.7 1.8V21c0 .3.2.6.7.5A10 10 0 0 0 12 2Z" />
),
globe: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" />
</>
),
lock: (
<>
<rect x="5" y="10" width="14" height="11" rx="3" />
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3" />
</>
),
menu: (
<>
<path d="M4 7h16M4 12h16M4 17h16" />
</>
),
nfc: (
<>
<path d="M8 7a7 7 0 0 1 0 10M5 4a11 11 0 0 1 0 16" />
<path d="M16 7a7 7 0 0 0 0 10M19 4a11 11 0 0 0 0 16" />
<circle cx="12" cy="12" r="1" />
</>
),
qr: (
<>
<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-3zM18 18h3v3h-3zM18 13h3M13 19h3v2" />
</>
),
shield: (
<>
<path d="M12 3 4.5 6v5.5c0 4.8 3.2 8.1 7.5 9.5 4.3-1.4 7.5-4.7 7.5-9.5V6z" />
<path d="m8.5 12 2.2 2.2 4.8-5" />
</>
),
spark: (
<path d="m12 2 1.6 6.4L20 10l-6.4 1.6L12 18l-1.6-6.4L4 10l6.4-1.6z" />
),
stop: <rect x="5" y="5" width="14" height="14" rx="3" />,
verified: (
<>
<path d="m12 3 2 2.1 2.9-.1.1 2.9 2 2.1-2 2.1-.1 2.9-2.9-.1-2 2.1-2-2.1-2.9.1-.1-2.9-2-2.1 2-2.1.1-2.9 2.9.1z" />
<path d="m9 10 2 2 4-4" />
</>
),
x: (
<>
<path d="M6 6l12 12" />
<path d="M18 6 6 18" />
</>
),
};
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{paths[name]}
</svg>
);
}

View File

@@ -0,0 +1,276 @@
@keyframes scene-orbits { to { transform: rotate(360deg); } }
@keyframes laptop-files {
0%, 100% { transform: translateY(0) rotate(0); }
50% { transform: translateY(-5px) rotate(1deg); }
}
@keyframes conduit-energy { to { stroke-dashoffset: -38; } }
@keyframes gate-hover {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes received-file {
0%, 9% { opacity: 0.55; transform: translateY(12px) scale(0.92); }
17%, 70% { opacity: 1; transform: translateY(0) scale(1); }
83%, 100% { opacity: 0.55; transform: translateY(12px) scale(0.92); }
}
@keyframes hero-packet-one {
0%, 6% { opacity: 0; transform: translate(245px, 320px) scale(0.55) rotate(-8deg); }
14% { opacity: 1; }
43% { opacity: 1; transform: translate(380px, 427px) scale(0.82) rotate(5deg); }
54% { opacity: 1; transform: translate(415px, 430px) scale(0.82) rotate(2deg); }
84% { opacity: 1; transform: translate(556px, 296px) scale(0.65) rotate(10deg); }
92%, 100% { opacity: 0; transform: translate(585px, 270px) scale(0.35) rotate(14deg); }
}
@keyframes hero-packet-two {
0%, 6% { opacity: 0; transform: translate(255px, 327px) scale(0.5) rotate(8deg); }
15% { opacity: 0.85; }
45% { opacity: 0.9; transform: translate(382px, 440px) scale(0.75) rotate(-4deg); }
56% { opacity: 0.9; transform: translate(414px, 436px) scale(0.75) rotate(-2deg); }
85% { opacity: 0.8; transform: translate(560px, 308px) scale(0.55) rotate(-10deg); }
93%, 100% { opacity: 0; transform: translate(588px, 278px) scale(0.25) rotate(-15deg); }
}
@keyframes hero-spark-one {
0%, 12% { opacity: 0; transform: translate(266px, 346px) scale(0); }
19% { opacity: 1; transform: translate(285px, 360px) scale(1); }
29%, 100% { opacity: 0; transform: translate(310px, 380px) scale(0); }
}
@keyframes hero-spark-two {
0%, 77% { opacity: 0; transform: translate(545px, 307px) scale(0); }
87% { opacity: 1; transform: translate(575px, 279px) scale(1); }
97%, 100% { opacity: 0; transform: translate(603px, 260px) scale(0); }
}
@keyframes status-hover {
0%, 100% { margin-top: 0; }
50% { margin-top: -7px; }
}
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(43, 196, 119, 0.1); }
50% { box-shadow: 0 0 0 7px rgba(43, 196, 119, 0); }
}
@keyframes flow-glow {
0%, 100% { transform: scale(0.8); opacity: 0.6; }
50% { transform: scale(1.2); opacity: 1; }
}
@keyframes flow-drop {
0%, 7% { offset-distance: 0%; opacity: 0; }
13% { opacity: 1; }
47% { offset-distance: 51%; opacity: 1; }
57% { offset-distance: 51%; opacity: 1; }
91% { offset-distance: 100%; opacity: 1; }
97%, 100% { offset-distance: 100%; opacity: 0; }
}
@keyframes gate-breathe {
0%, 100% { transform: scale(0.98); }
50% { transform: scale(1.04); }
}
@keyframes complete-ring {
0%, 100% { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.18); }
50% { box-shadow: 0 0 0 13px rgba(168, 85, 247, 0); }
}
@keyframes invite-panel-enter {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
@keyframes qr-card-float {
0%, 100% { transform: rotate(2deg) translateY(0); }
50% { transform: rotate(-1deg) translateY(-8px); }
}
@keyframes qr-cell {
from { opacity: 0; transform: scale(0.2); }
to { opacity: 1; transform: scale(1); }
}
@keyframes qr-scan {
0%, 100% { top: 40px; opacity: 0; }
12%, 88% { opacity: 1; }
50% { top: 222px; }
}
@keyframes nfc-dot {
0%, 100% { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.2); }
50% { box-shadow: 0 0 0 19px rgba(168, 85, 247, 0); }
}
@keyframes nfc-wave {
0%, 100% { opacity: 0.25; transform: translateY(-50%) scale(0.88); }
50% { opacity: 1; transform: translateY(-50%) scale(1.04); }
}
@keyframes vnd-drop {
0%, 8% { transform: translateX(-50%) translateY(-16px) rotate(-5deg); }
20%, 78% { transform: translateX(-50%) translateY(0) rotate(-2deg); }
88%, 100% { transform: translateX(-50%) translateY(-16px) rotate(-5deg); }
}
@keyframes avatar-pulse {
0% { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.24); }
75%, 100% { box-shadow: 0 0 0 14px rgba(168, 85, 247, 0); }
}
@keyframes approval-state-enter {
from { opacity: 0; transform: translateY(7px); }
to { opacity: 1; transform: none; }
}
@keyframes progress-fill {
from { width: 0; }
to { width: 68%; }
}
@keyframes network-dash { to { stroke-dashoffset: -44; } }
@keyframes relay-ring {
0% { opacity: 0.6; transform: translateX(-50%) scale(0.72); }
85%, 100% { opacity: 0; transform: translateX(-50%) scale(1.2); }
}
@keyframes network-packet-top {
0%, 8% { left: 15%; top: 40%; opacity: 0; transform: rotate(-7deg) scale(0.7); }
15% { opacity: 1; }
48% { left: 47%; top: 20%; opacity: 1; transform: rotate(3deg) scale(0.9); }
85% { left: 82%; top: 40%; opacity: 1; transform: rotate(8deg) scale(0.72); }
94%, 100% { left: 85%; top: 42%; opacity: 0; transform: rotate(10deg) scale(0.4); }
}
@keyframes network-packet-bottom {
0%, 8% { left: 16%; top: 50%; opacity: 0; transform: rotate(8deg) scale(0.65); }
15% { opacity: 0.8; }
48% { left: 48%; top: 69%; opacity: 0.8; transform: rotate(-3deg) scale(0.8); }
84% { left: 82%; top: 50%; opacity: 0.8; transform: rotate(-8deg) scale(0.7); }
94%, 100% { left: 85%; top: 48%; opacity: 0; transform: rotate(-10deg) scale(0.4); }
}
@keyframes orbit-spin {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes platform-glow {
0%, 100% { opacity: 0.45; transform: scale(0.82); }
50% { opacity: 1; transform: scale(1.15); }
}
@keyframes platform-chip-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@media (max-width: 1120px) {
.flow-layout {
grid-template-columns: minmax(450px, 1fr) minmax(350px, 0.8fr);
gap: 45px;
}
.flow-device { transform: scale(0.88); }
.flow-device-sender { transform: scale(0.88) rotate(-6deg); transform-origin: top left; }
.flow-device-receiver { transform: scale(0.88) rotate(5deg); transform-origin: bottom right; }
.invite-panel {
grid-template-columns: minmax(0, 0.85fr) minmax(420px, 1.15fr);
}
.invite-copy { padding: 45px; }
.network-visual { min-height: 500px; }
}
@media (max-width: 920px) {
.hero-visual {
max-width: 720px;
margin-inline: auto;
}
.flow-layout {
grid-template-columns: 1fr;
}
.flow-visual {
position: relative;
top: auto;
}
.invite-panel {
grid-template-columns: 1fr;
}
.invite-art {
min-height: 450px;
border-top: 1px solid var(--line);
border-left: 0;
}
.network-visual {
min-height: 520px;
}
.platform-orbit {
max-width: 620px;
}
}
@media (max-width: 680px) {
.hero-stage {
width: 112%;
margin-left: -6%;
}
.hero-status-secure {
top: 0;
right: 2%;
transform: scale(0.88) rotate(2deg);
transform-origin: top right;
}
.hero-status-route { left: 4%; transform: scale(0.88) rotate(-3deg); transform-origin: top left; }
.hero-file-label { bottom: 3%; left: 4%; transform: scale(0.88) rotate(-2deg); transform-origin: bottom left; }
.flow-visual {
height: 540px;
border-radius: 21px;
}
.flow-device {
width: 137px;
height: 215px;
}
.flow-device-sender { top: 24px; left: 16px; }
.flow-device-receiver { right: 14px; bottom: 20px; }
.flow-track { inset: 0 -2%; width: 104%; height: 100%; }
.flow-annotation { display: none; }
.flow-step {
grid-template-columns: auto 1fr;
padding: 20px;
}
.invite-tabs button {
min-height: 59px;
flex-direction: column;
gap: 3px;
font-size: 10px;
}
.invite-tabs svg { width: 17px; }
.invite-copy { padding: 42px 24px; }
.invite-art { min-height: 390px; }
.invite-qr-art { transform: scale(0.86) rotate(2deg); }
.invite-nfc-art { transform: scale(0.82); }
.invite-file-art { transform: scale(0.85); }
.approval-body { min-height: 370px; padding: 28px 20px; }
.approval-heading h3 { font-size: 18px; }
.approval-actions { grid-template-columns: 1fr 1.2fr; }
.network-visual {
min-height: 430px;
border-radius: 21px;
}
.network-device {
top: 150px;
width: 77px;
height: 112px;
}
.network-device-left { left: 13px; }
.network-device-right { right: 13px; }
.network-device svg { width: 24px; }
.network-relay-node { bottom: 29px; }
.network-relay-node small { max-width: 160px; text-align: center; }
.network-direct-label { top: 43px; }
.platform-orbit { width: 112%; margin-left: -6%; }
.platform-center { width: 112px; height: 112px; border-radius: 30px; }
.platform-center svg { width: 68px; height: 68px; }
.platform-chip { padding: 8px 10px; font-size: 9px; }
.platform-chip-1 { left: 10%; }
.platform-chip-2 { right: 0; }
.platform-chip-3 { right: -2%; }
.platform-chip-4 { left: 17%; }
}

View File

@@ -0,0 +1,405 @@
/* Invitation methods */
.invite-showcase {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 25px;
background: white;
box-shadow: var(--shadow-lg);
}
.invite-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-bottom: 1px solid var(--line);
background: #f7f4f8;
}
.invite-tabs button {
display: inline-flex;
min-height: 66px;
align-items: center;
justify-content: center;
gap: 9px;
border: 0;
border-right: 1px solid var(--line);
background: transparent;
color: var(--ink-muted);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: color 180ms ease, background 180ms ease;
}
.invite-tabs button:last-child { border-right: 0; }
.invite-tabs button:hover { color: var(--ink); }
.invite-tabs button.is-active {
position: relative;
background: white;
color: var(--brand-600);
}
.invite-tabs button.is-active::after {
position: absolute;
right: 20%;
bottom: -1px;
left: 20%;
height: 2px;
border-radius: 999px 999px 0 0;
background: var(--brand-500);
content: "";
}
.invite-tabs svg { width: 19px; }
.invite-panel {
display: grid;
min-height: 490px;
grid-template-columns: minmax(0, 0.83fr) minmax(480px, 1.17fr);
animation: invite-panel-enter 420ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.invite-copy {
display: flex;
align-items: flex-start;
flex-direction: column;
justify-content: center;
padding: 60px;
}
.mono-label {
color: var(--brand-600);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.11em;
}
.invite-copy h3 {
max-width: 430px;
margin: 12px 0 18px;
font-size: clamp(2rem, 3vw, 3.15rem);
letter-spacing: -0.055em;
line-height: 1.06;
}
.invite-copy > p {
max-width: 440px;
margin-bottom: 30px;
color: var(--ink-soft);
font-size: 15px;
line-height: 1.7;
}
.invite-detail {
display: flex;
gap: 11px;
padding-top: 20px;
border-top: 1px solid var(--line);
}
.invite-detail > svg {
width: 19px;
flex: 0 0 auto;
color: var(--brand-600);
}
.invite-detail strong,
.invite-detail small {
display: block;
}
.invite-detail strong {
margin-bottom: 3px;
color: #3c3440;
font-size: 11px;
}
.invite-detail small {
color: var(--ink-muted);
font-size: 9px;
line-height: 1.5;
}
.invite-art {
position: relative;
display: grid;
overflow: hidden;
place-items: center;
border-left: 1px solid var(--line);
background:
linear-gradient(rgba(116, 86, 130, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(116, 86, 130, 0.05) 1px, transparent 1px),
radial-gradient(circle at center, #fbf7fd, #f2edf5);
background-size: 24px 24px, 24px 24px, auto;
}
.invite-art::before {
position: absolute;
width: 330px;
aspect-ratio: 1;
border-radius: 50%;
background: rgba(168, 85, 247, 0.14);
content: "";
filter: blur(55px);
}
.invite-qr-art {
position: relative;
display: grid;
width: 265px;
height: 265px;
place-items: center;
border: 1px solid #e0d5e5;
border-radius: 25px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 30px 80px rgba(59, 35, 70, 0.18);
transform: rotate(2deg);
animation: qr-card-float 6s ease-in-out infinite;
}
.invite-qr-grid {
display: grid;
width: 178px;
height: 178px;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(9, 1fr);
gap: 4px;
}
.invite-qr-grid span {
border-radius: 2px;
background: transparent;
}
.invite-qr-grid span.is-filled {
background: #2b2230;
animation: qr-cell 600ms ease backwards;
}
.invite-qr-grid span:nth-child(3n) { animation-delay: 80ms; }
.invite-qr-grid span:nth-child(4n) { animation-delay: 160ms; }
.invite-qr-grid span:nth-child(7n) { animation-delay: 230ms; }
.qr-scan-line {
position: absolute;
right: 34px;
left: 34px;
height: 2px;
background: linear-gradient(90deg, transparent, var(--brand-500), transparent);
box-shadow: 0 0 13px var(--brand-400);
animation: qr-scan 3.2s ease-in-out infinite;
}
.qr-corner {
position: absolute;
width: 25px;
height: 25px;
border-color: var(--brand-500);
border-style: solid;
}
.qr-corner-one { top: 21px; left: 21px; border-width: 2px 0 0 2px; border-radius: 7px 0 0; }
.qr-corner-two { top: 21px; right: 21px; border-width: 2px 2px 0 0; border-radius: 0 7px 0 0; }
.qr-corner-three { right: 21px; bottom: 21px; border-width: 0 2px 2px 0; border-radius: 0 0 7px; }
.qr-corner-four { bottom: 21px; left: 21px; border-width: 0 0 2px 2px; border-radius: 0 0 0 7px; }
.invite-nfc-art {
position: relative;
width: 440px;
height: 330px;
}
.nfc-device {
position: absolute;
top: 10px;
left: 10px;
width: 155px;
height: 300px;
border: 7px solid #d7cfdc;
border-radius: 30px;
background: #211c25;
box-shadow: var(--shadow-lg);
transform: rotate(-8deg);
}
.nfc-device::after {
position: absolute;
top: 11px;
left: 50%;
width: 50px;
height: 7px;
border-radius: 999px;
background: #09070a;
content: "";
transform: translateX(-50%);
}
.nfc-screen-dot {
position: absolute;
top: 50%;
left: 50%;
width: 54px;
height: 54px;
border: 1px solid rgba(192, 132, 252, 0.55);
border-radius: 50%;
background: rgba(168, 85, 247, 0.2);
transform: translate(-50%, -50%);
animation: nfc-dot 2.5s ease-in-out infinite;
}
.nfc-tag {
position: absolute;
right: 25px;
bottom: 30px;
display: grid;
width: 138px;
height: 138px;
place-items: center;
border: 1px solid #d9cfe0;
border-radius: 28px;
background: white;
box-shadow: 0 25px 70px rgba(52, 31, 62, 0.18);
color: var(--brand-600);
transform: rotate(8deg);
}
.nfc-tag svg { width: 46px; }
.nfc-tag small {
position: absolute;
bottom: 18px;
color: var(--ink-muted);
font-family: var(--font-mono);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.08em;
}
.nfc-waves {
position: absolute;
top: 75px;
left: 145px;
width: 185px;
height: 160px;
}
.nfc-waves span {
position: absolute;
top: 50%;
left: 0;
border: 2px solid var(--brand-500);
border-top-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
border-radius: 50%;
transform: translateY(-50%);
animation: nfc-wave 2.4s ease-in-out infinite;
}
.nfc-waves span:nth-child(1) { width: 55px; height: 70px; }
.nfc-waves span:nth-child(2) { width: 98px; height: 112px; animation-delay: 180ms; }
.nfc-waves span:nth-child(3) { width: 142px; height: 154px; animation-delay: 360ms; }
.nfc-waves i {
position: absolute;
top: 50%;
left: 15px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--brand-500);
box-shadow: 0 0 0 8px rgba(168, 85, 247, 0.13);
transform: translateY(-50%);
}
.invite-file-art {
position: relative;
width: 410px;
height: 350px;
}
.vnd-file {
position: absolute;
z-index: 2;
top: 13px;
left: 50%;
display: flex;
width: 218px;
height: 270px;
align-items: center;
flex-direction: column;
justify-content: center;
border: 1px solid #d8cbe0;
border-radius: 23px;
background: linear-gradient(145deg, #fff, #f7f0fb);
box-shadow: 0 30px 80px rgba(59, 35, 70, 0.2);
transform: translateX(-50%) rotate(-2deg);
animation: vnd-drop 4.8s cubic-bezier(0.3, 0.75, 0.3, 1) infinite;
}
.vnd-fold {
position: absolute;
top: -1px;
right: -1px;
width: 58px;
height: 58px;
border-bottom: 1px solid #d1c1dc;
border-left: 1px solid #d1c1dc;
border-radius: 0 23px 0 16px;
background: #e7cdf9;
clip-path: polygon(0 0, 100% 100%, 0 100%);
}
.vnd-file > svg {
width: 58px;
margin-bottom: 20px;
color: var(--brand-600);
}
.vnd-file strong {
font-family: var(--font-display);
font-size: 16px;
}
.vnd-file small {
color: var(--ink-muted);
font-size: 10px;
}
.vnd-file code {
margin-top: 18px;
padding: 5px 10px;
border-radius: 7px;
background: var(--brand-100);
color: var(--brand-600);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 800;
}
.file-tray {
position: absolute;
right: 37px;
bottom: 7px;
left: 37px;
height: 73px;
border: 2px solid #cabbd2;
border-radius: 19px;
background: rgba(237, 228, 242, 0.85);
transform: perspective(250px) rotateX(55deg);
}
.file-tray span {
position: absolute;
inset: 14px;
border: 1px dashed #aa8cbc;
border-radius: 10px;
}
.file-art-orbit {
position: absolute;
border: 1px solid rgba(168, 85, 247, 0.2);
border-radius: 50%;
}
.file-art-orbit-one { inset: 14px 32px 44px; animation: scene-orbits 24s linear infinite; }
.file-art-orbit-two { inset: 52px 70px 80px; border-style: dashed; animation: scene-orbits 18s linear infinite reverse; }

View File

@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import { Icon, type IconName } from "@/components/icons";
type Method = "qr" | "nfc" | "file";
const methods: Array<{
id: Method;
icon: IconName;
label: string;
eyebrow: string;
title: string;
text: string;
}> = [
{
id: "qr",
icon: "qr",
label: "QR code",
eyebrow: "IN THE SAME ROOM",
title: "Point. Scan. Connected.",
text: "Show the invitation on one screen and scan it from the other. No contact exchange required.",
},
{
id: "nfc",
icon: "nfc",
label: "NFC",
eyebrow: "THE QUICKEST TAP",
title: "Bring devices together.",
text: "Write or open an invitation with an NFC tag for a physical, close-range handoff.",
},
{
id: "file",
icon: "file",
label: ".vnd file",
eyebrow: "SEND IT ANYWHERE",
title: "An invitation that travels.",
text: "Save the small invitation as a .vnd file, then pass it through the channel you already use.",
},
];
function QrArt() {
const filled = new Set([
0, 1, 2, 3, 4, 6, 8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 28, 29, 30, 31, 32,
36, 38, 40, 42, 44, 48, 49, 50, 51, 52, 54, 56, 58, 60, 62, 64, 66, 67, 69,
72, 73, 74, 76, 78, 79, 80,
]);
return (
<div className="invite-qr-art">
<div className="invite-qr-grid">
{Array.from({ length: 81 }, (_, index) => (
<span key={index} className={filled.has(index) ? "is-filled" : ""} />
))}
</div>
<span className="qr-scan-line" />
<span className="qr-corner qr-corner-one" />
<span className="qr-corner qr-corner-two" />
<span className="qr-corner qr-corner-three" />
<span className="qr-corner qr-corner-four" />
</div>
);
}
function NfcArt() {
return (
<div className="invite-nfc-art">
<div className="nfc-device nfc-device-left">
<span className="nfc-screen-dot" />
</div>
<div className="nfc-waves">
<span />
<span />
<span />
<i />
</div>
<div className="nfc-tag">
<Icon name="nfc" />
<small>VniDrop</small>
</div>
</div>
);
}
function FileArt() {
return (
<div className="invite-file-art">
<span className="file-art-orbit file-art-orbit-one" />
<span className="file-art-orbit file-art-orbit-two" />
<div className="vnd-file">
<span className="vnd-fold" />
<Icon name="file" />
<strong>Weekend trip</strong>
<small>VniDrop invitation</small>
<code>.vnd</code>
</div>
<div className="file-tray">
<span />
</div>
</div>
);
}
export function InvitationShowcase() {
const [active, setActive] = useState<Method>("qr");
const current = methods.find((method) => method.id === active) ?? methods[0];
return (
<div className="invite-showcase">
<div className="invite-tabs" role="tablist" aria-label="Invitation methods">
{methods.map((method) => (
<button
key={method.id}
id={`invite-tab-${method.id}`}
type="button"
role="tab"
aria-selected={active === method.id}
aria-controls="invite-panel"
className={active === method.id ? "is-active" : ""}
onClick={() => setActive(method.id)}
>
<Icon name={method.icon} />
{method.label}
</button>
))}
</div>
<div
id="invite-panel"
className="invite-panel"
role="tabpanel"
aria-labelledby={`invite-tab-${active}`}
key={active}
>
<div className="invite-copy">
<span className="mono-label">{current.eyebrow}</span>
<h3>{current.title}</h3>
<p>{current.text}</p>
<div className="invite-detail">
<Icon name="lock" />
<span>
<strong>The invitation is a private access link.</strong>
<small>Only share it with people you intend to receive the transfer.</small>
</span>
</div>
</div>
<div className="invite-art" aria-hidden="true">
{active === "qr" && <QrArt />}
{active === "nfc" && <NfcArt />}
{active === "file" && <FileArt />}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { Icon } from "@/components/icons";
export function NetworkIllustration() {
return (
<div className="network-visual" aria-hidden="true">
<div className="network-grid" />
<div className="network-device network-device-left">
<span className="network-device-top" />
<Icon name="file" />
<small>SENDER</small>
</div>
<div className="network-device network-device-right">
<span className="network-device-top" />
<Icon name="check" />
<small>RECEIVER</small>
</div>
<svg className="network-routes" viewBox="0 0 760 430">
<defs>
<linearGradient id="network-route-gradient" x1="115" y1="210" x2="650" y2="210" gradientUnits="userSpaceOnUse">
<stop stopColor="#D08BFF" />
<stop offset="0.5" stopColor="#A855F7" />
<stop offset="1" stopColor="#7C2AEF" />
</linearGradient>
<filter id="network-route-glow" x="-30%" y="-100%" width="160%" height="300%">
<feGaussianBlur stdDeviation="8" />
</filter>
</defs>
<path className="network-direct-glow" d="M134 227C279 92 491 92 626 227" />
<path className="network-direct" d="M134 227C279 92 491 92 626 227" />
<path className="network-direct-pulse" d="M134 227C279 92 491 92 626 227" />
<path className="network-relay-base" d="M134 247c126 87 371 87 492 0" />
<path className="network-relay-line" d="M134 247c126 87 371 87 492 0" />
</svg>
<div className="network-direct-label">
<span className="live-dot" />
DIRECT WHEN POSSIBLE
</div>
<div className="network-relay-node">
<span className="relay-ring relay-ring-one" />
<span className="relay-ring relay-ring-two" />
<div className="relay-core">
<Icon name="lock" />
</div>
<strong>Encrypted relay</strong>
<small>Forwards packets · stores no transfer copy</small>
</div>
<div className="network-packet network-packet-one"><Icon name="file" /></div>
<div className="network-packet network-packet-two"><Icon name="file" /></div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
/* Direct / relay network scene */
.network-visual {
position: relative;
width: 100%;
min-height: 520px;
overflow: hidden;
border: 1px solid #302737;
border-radius: 27px;
background: radial-gradient(circle at center, rgba(168, 85, 247, 0.1), transparent 47%), #151117;
}
.network-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
background-size: 30px 30px;
mask-image: radial-gradient(circle at center, black, transparent 79%);
}
.network-device {
position: absolute;
z-index: 4;
top: 178px;
display: grid;
width: 105px;
height: 145px;
place-items: center;
border: 5px solid #3b3340;
border-radius: 20px;
background: linear-gradient(145deg, #29222d, #171419);
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.38);
color: #d89bff;
}
.network-device-left { left: 30px; transform: rotate(-5deg); }
.network-device-right { right: 30px; transform: rotate(5deg); }
.network-device svg { width: 31px; }
.network-device small {
position: absolute;
bottom: 14px;
color: #756b79;
font-family: var(--font-mono);
font-size: 7px;
font-weight: 800;
letter-spacing: 0.09em;
}
.network-device-top {
position: absolute;
top: 8px;
left: 50%;
width: 35px;
height: 5px;
border-radius: 999px;
background: #080609;
transform: translateX(-50%);
}
.network-routes {
position: absolute;
z-index: 2;
inset: 34px 0 auto;
width: 100%;
height: calc(100% - 40px);
overflow: visible;
}
.network-direct-glow,
.network-direct,
.network-direct-pulse,
.network-relay-base,
.network-relay-line {
fill: none;
stroke-linecap: round;
}
.network-direct-glow {
stroke: rgba(168, 85, 247, 0.26);
stroke-width: 20;
filter: url(#network-route-glow);
}
.network-direct {
stroke: #3d3343;
stroke-width: 8;
}
.network-direct-pulse {
stroke: url(#network-route-gradient);
stroke-width: 3;
stroke-dasharray: 4 18;
animation: network-dash 2.2s linear infinite;
}
.network-relay-base {
stroke: #2e2732;
stroke-width: 5;
}
.network-relay-line {
stroke: #776681;
stroke-width: 2;
stroke-dasharray: 4 11;
animation: network-dash 3.4s linear infinite reverse;
}
.network-direct-label {
position: absolute;
z-index: 5;
top: 62px;
left: 50%;
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border: 1px solid #372c3c;
border-radius: 999px;
background: rgba(25, 20, 28, 0.82);
color: #9a8fa0;
font-family: var(--font-mono);
font-size: 7px;
font-weight: 800;
letter-spacing: 0.08em;
transform: translateX(-50%);
}
.network-relay-node {
position: absolute;
z-index: 5;
bottom: 39px;
left: 50%;
display: flex;
align-items: center;
flex-direction: column;
transform: translateX(-50%);
}
.relay-core {
position: relative;
z-index: 2;
display: grid;
width: 66px;
height: 66px;
margin-bottom: 11px;
place-items: center;
border: 1px solid #5c426a;
border-radius: 20px;
background: linear-gradient(145deg, #312139, #1d1721);
box-shadow: 0 0 45px rgba(168, 85, 247, 0.18);
color: #d294fc;
transform: rotate(45deg);
}
.relay-core svg { width: 24px; transform: rotate(-45deg); }
.network-relay-node strong { color: #d9d1dc; font-size: 10px; }
.network-relay-node small { color: #756b79; font-size: 7px; }
.relay-ring {
position: absolute;
top: -18px;
left: 50%;
border: 1px solid rgba(168, 85, 247, 0.3);
border-radius: 50%;
transform: translateX(-50%);
animation: relay-ring 3s ease-out infinite;
}
.relay-ring-one { width: 100px; height: 100px; }
.relay-ring-two { width: 136px; height: 136px; animation-delay: 1.2s; }
.network-packet {
position: absolute;
z-index: 3;
display: grid;
width: 26px;
height: 32px;
place-items: center;
border: 1px solid #aa65d2;
border-radius: 6px;
background: #2c2231;
color: #d69bfa;
opacity: 0;
}
.network-packet svg { width: 13px; }
.network-packet-one { animation: network-packet-top 6s ease-in-out infinite; }
.network-packet-two { animation: network-packet-bottom 7s ease-in-out 2.2s infinite; }

View File

@@ -0,0 +1,25 @@
import { BrandMark } from "@/components/brand";
const platforms = ["Android", "iOS", "macOS", "Windows", "Linux"];
export function PlatformOrbit() {
return (
<div className="platform-orbit" aria-label="VniDrop supports Android, iOS, macOS, Windows, and Linux">
<div className="platform-ring platform-ring-outer" aria-hidden="true" />
<div className="platform-ring platform-ring-inner" aria-hidden="true" />
<div className="platform-center" aria-hidden="true">
<span className="platform-center-glow" />
<BrandMark />
</div>
{platforms.map((platform, index) => (
<span
key={platform}
className={`platform-chip platform-chip-${index + 1}`}
>
<i />
{platform}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,103 @@
/* Cross-platform orbit */
.platform-orbit {
position: relative;
width: min(100%, 610px);
aspect-ratio: 1;
margin: 0 auto;
}
.platform-ring {
position: absolute;
top: 50%;
left: 50%;
border: 1px solid #e2d8e7;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.platform-ring::before,
.platform-ring::after {
position: absolute;
border-radius: 50%;
background: var(--brand-400);
box-shadow: 0 0 0 7px rgba(168, 85, 247, 0.08);
content: "";
}
.platform-ring-outer {
width: 88%;
height: 88%;
animation: orbit-spin 35s linear infinite;
}
.platform-ring-outer::before { top: 13%; left: 10%; width: 7px; height: 7px; }
.platform-ring-outer::after { right: 7%; bottom: 23%; width: 5px; height: 5px; }
.platform-ring-inner {
width: 58%;
height: 58%;
border-style: dashed;
animation: orbit-spin 25s linear infinite reverse;
}
.platform-ring-inner::before { top: 48%; left: -4px; width: 7px; height: 7px; }
.platform-ring-inner::after { top: 4%; right: 23%; width: 5px; height: 5px; }
.platform-center {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
display: grid;
width: 145px;
height: 145px;
place-items: center;
border: 1px solid #e5d8eb;
border-radius: 38px;
background: white;
box-shadow: 0 30px 80px rgba(55, 29, 67, 0.16);
transform: translate(-50%, -50%) rotate(-3deg);
}
.platform-center svg { position: relative; z-index: 2; width: 87px; height: 87px; }
.platform-center-glow {
position: absolute;
inset: -45px;
border-radius: 50%;
background: rgba(168, 85, 247, 0.18);
filter: blur(42px);
animation: platform-glow 5s ease-in-out infinite;
}
.platform-chip {
position: absolute;
z-index: 4;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid #e2d9e6;
border-radius: 999px;
background: rgba(255, 255, 255, 0.93);
box-shadow: 0 12px 35px rgba(53, 34, 61, 0.1);
color: #4b4350;
font-size: 11px;
font-weight: 700;
animation: platform-chip-float 5s ease-in-out infinite;
}
.platform-chip i {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--brand-500);
box-shadow: 0 0 0 4px var(--brand-100);
}
.platform-chip-1 { top: 8%; left: 17%; animation-delay: -1s; }
.platform-chip-2 { top: 19%; right: 5%; animation-delay: -2.7s; }
.platform-chip-3 { right: 1%; bottom: 22%; animation-delay: -4s; }
.platform-chip-4 { bottom: 7%; left: 24%; animation-delay: -0.5s; }
.platform-chip-5 { top: 45%; left: 0; animation-delay: -3.2s; }

View File

@@ -0,0 +1,52 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { useEffect, useRef } from "react";
type RevealProps = {
children: ReactNode;
className?: string;
delay?: number;
};
export function Reveal({ children, className = "", delay = 0 }: RevealProps) {
const targetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = document.documentElement;
root.classList.add("motion-ready");
const target = targetRef.current;
if (!target) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion || !("IntersectionObserver" in window)) {
target.classList.add("is-visible");
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
});
},
{ rootMargin: "0px 0px -10%", threshold: 0.08 },
);
observer.observe(target);
return () => observer.disconnect();
}, []);
return (
<div
ref={targetRef}
className={`reveal ${className}`.trim()}
data-reveal
style={{ "--reveal-delay": `${delay}ms` } as CSSProperties}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import Link from "next/link";
import { BrandMark } from "@/components/brand";
import { Icon } from "@/components/icons";
export function SiteFooter() {
return (
<footer className="site-footer">
<div className="footer-main page-shell">
<div className="footer-brand">
<span className="footer-mark">
<BrandMark />
</span>
<div>
<p className="footer-name">VniDrop</p>
<p>Send files directly. Stay in control.</p>
</div>
</div>
<div className="footer-links">
<div>
<p className="footer-label">Explore</p>
<Link href="/#how-it-works">How it works</Link>
<Link href="/#privacy">Privacy by design</Link>
<Link href="/#platforms">Platforms</Link>
</div>
<div>
<p className="footer-label">Project</p>
<a href="https://github.com/vnidrop/vnidrop" target="_blank" rel="noreferrer">
GitHub <Icon name="arrow" />
</a>
<a
href="https://github.com/vnidrop/vnidrop/blob/master/LICENSE"
target="_blank"
rel="noreferrer"
>
Apache 2.0 <Icon name="arrow" />
</a>
<Link href="/privacy/">Privacy policy</Link>
</div>
</div>
</div>
<div className="footer-bottom page-shell">
<p>© 2026 VniDrop contributors.</p>
<p>Open source · Early development</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Brand } from "@/components/brand";
import { Icon } from "@/components/icons";
const navigation = [
{ href: "/#how-it-works", label: "How it works" },
{ href: "/#privacy", label: "Privacy" },
{ href: "/#platforms", label: "Platforms" },
];
export function SiteHeader() {
const [open, setOpen] = useState(false);
return (
<header className="site-header">
<div className="header-inner">
<Brand />
<nav className="desktop-nav" aria-label="Main navigation">
{navigation.map((item) => (
<Link key={item.href} href={item.href}>
{item.label}
</Link>
))}
</nav>
<a
className="button button-small button-dark header-github"
href="https://github.com/vnidrop/vnidrop"
target="_blank"
rel="noreferrer"
>
<Icon name="github" />
GitHub
</a>
<button
className="menu-button"
type="button"
aria-expanded={open}
aria-controls="mobile-navigation"
aria-label={open ? "Close navigation" : "Open navigation"}
onClick={() => setOpen((value) => !value)}
>
<Icon name={open ? "close" : "menu"} />
</button>
</div>
<nav
id="mobile-navigation"
className={`mobile-nav${open ? " is-open" : ""}`}
aria-label="Mobile navigation"
>
{navigation.map((item) => (
<Link key={item.href} href={item.href} onClick={() => setOpen(false)}>
{item.label}
<Icon name="arrow" />
</Link>
))}
<a
href="https://github.com/vnidrop/vnidrop"
target="_blank"
rel="noreferrer"
onClick={() => setOpen(false)}
>
Explore on GitHub
<Icon name="github" />
</a>
</nav>
</header>
);
}

View File

@@ -0,0 +1,278 @@
/* Five-step transfer flow */
.flow-layout {
display: grid;
grid-template-columns: minmax(520px, 1.1fr) minmax(390px, 0.9fr);
align-items: start;
gap: 80px;
}
.flow-visual {
position: sticky;
top: 115px;
height: 660px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 27px;
background:
linear-gradient(rgba(112, 85, 124, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(112, 85, 124, 0.06) 1px, transparent 1px),
linear-gradient(145deg, #fdfbfe, #f4eff6);
background-size: 28px 28px, 28px 28px, auto;
box-shadow: inset 0 1px white, var(--shadow-sm);
}
.flow-visual::after {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, transparent 20%, rgba(248, 244, 250, 0.65) 78%);
content: "";
pointer-events: none;
}
.flow-visual-glow {
position: absolute;
top: 30%;
left: 32%;
width: 40%;
aspect-ratio: 1;
border-radius: 50%;
background: rgba(168, 85, 247, 0.18);
filter: blur(55px);
animation: flow-glow 7s ease-in-out infinite;
}
.flow-track {
position: absolute;
z-index: 2;
inset: 4% 5% auto;
width: 90%;
height: 92%;
overflow: visible;
}
.flow-track-shadow,
.flow-track-base,
.flow-track-energy {
fill: none;
stroke-linecap: round;
}
.flow-track-shadow {
stroke: rgba(168, 85, 247, 0.22);
stroke-width: 34;
filter: url(#flow-blur);
}
.flow-track-base {
stroke: #e4dae9;
stroke-width: 18;
}
.flow-track-energy {
stroke: url(#flow-gradient);
stroke-width: 6;
stroke-dasharray: 3 17;
animation: conduit-energy 2.1s linear infinite;
}
.flow-moving-drop,
.flow-moving-halo {
offset-path: path("M132 141c216 14 240 88 153 159-89 73-43 157 81 160");
offset-rotate: 0deg;
animation: flow-drop 8s cubic-bezier(0.45, 0.08, 0.35, 0.98) infinite;
}
.flow-moving-halo {
animation-delay: -0.08s;
}
.flow-gate {
transform-origin: 241px 299px;
animation: gate-breathe 3.5s ease-in-out infinite;
}
.flow-device {
position: absolute;
z-index: 4;
display: grid;
width: 168px;
height: 258px;
place-items: center;
border: 7px solid #d7cfdc;
border-radius: 28px;
background: #211d24;
box-shadow: 0 24px 55px rgba(48, 31, 56, 0.18), inset 0 0 0 1px #ada2b3;
}
.flow-device-sender {
top: 46px;
left: 31px;
transform: rotate(-6deg);
}
.flow-device-receiver {
right: 25px;
bottom: 32px;
transform: rotate(5deg);
}
.flow-device-camera {
position: absolute;
top: 8px;
left: 50%;
width: 54px;
height: 7px;
border-radius: 999px;
background: #0c0a0e;
transform: translateX(-50%);
}
.flow-device-content {
display: flex;
width: 100%;
height: 100%;
align-items: center;
flex-direction: column;
justify-content: center;
padding: 25px 15px 15px;
color: white;
text-align: center;
}
.flow-mini-label {
margin-bottom: 20px;
color: #8c7e93;
font-family: var(--font-mono);
font-size: 7px;
font-weight: 800;
letter-spacing: 0.12em;
}
.flow-file-stack {
position: relative;
width: 74px;
height: 82px;
margin-bottom: 16px;
}
.flow-file-stack span {
position: absolute;
display: grid;
width: 50px;
height: 66px;
place-items: center;
border: 1px solid rgba(216, 180, 254, 0.5);
border-radius: 9px;
background: #392e40;
color: #d9a8fb;
}
.flow-file-stack span:nth-child(1) { left: 0; transform: rotate(-10deg); }
.flow-file-stack span:nth-child(2) { right: 0; transform: rotate(10deg); }
.flow-file-stack span:nth-child(3) { left: 12px; background: #f7effc; color: var(--brand-600); }
.flow-file-stack svg { width: 22px; }
.flow-device-content strong {
font-family: var(--font-display);
font-size: 13px;
}
.flow-device-content small {
color: #928798;
font-size: 9px;
}
.flow-complete-ring {
display: grid;
width: 70px;
height: 70px;
margin-bottom: 18px;
place-items: center;
border: 1px solid rgba(216, 180, 254, 0.4);
border-radius: 50%;
background: radial-gradient(circle, rgba(168, 85, 247, 0.26), rgba(168, 85, 247, 0.05));
color: #d59aff;
animation: complete-ring 3.5s ease-in-out infinite;
}
.flow-complete-ring svg {
width: 30px;
stroke-width: 2.4;
}
.flow-annotation {
position: absolute;
z-index: 5;
padding: 5px 8px;
border: 1px solid #ded4e3;
border-radius: 7px;
background: rgba(255, 255, 255, 0.85);
color: var(--brand-600);
font-family: var(--font-mono);
font-size: 7px;
font-weight: 800;
letter-spacing: 0.08em;
}
.flow-annotation-one { top: 35%; right: 12%; transform: rotate(4deg); }
.flow-annotation-two { bottom: 11%; left: 25%; transform: rotate(-4deg); }
.flow-steps {
display: grid;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.flow-step {
display: grid;
grid-template-columns: auto 1fr;
gap: 18px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.8);
transition: transform 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
}
.flow-step:hover {
transform: translateX(6px);
border-color: var(--brand-300);
box-shadow: var(--shadow-sm);
}
.flow-step-icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 12px;
background: var(--brand-100);
color: var(--brand-600);
}
.flow-step-icon svg { width: 21px; }
.flow-step-number {
display: inline-block;
margin-bottom: 2px;
color: var(--brand-500);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 800;
}
.flow-step h3 {
margin-bottom: 7px;
font-size: 18px;
letter-spacing: -0.025em;
}
.flow-step p {
margin-bottom: 0;
color: var(--ink-muted);
font-size: 13px;
line-height: 1.58;
}

View File

@@ -0,0 +1,108 @@
import { Icon, type IconName } from "@/components/icons";
const steps: Array<{
number: string;
icon: IconName;
title: string;
text: string;
}> = [
{
number: "01",
icon: "folder",
title: "Pick anything",
text: "Choose one file, a whole batch, or a complete folder. Its structure stays intact.",
},
{
number: "02",
icon: "qr",
title: "Pass a tiny invitation",
text: "Share a QR code, tap an NFC tag, or send a portable .vnd invitation file.",
},
{
number: "03",
icon: "shield",
title: "Let them knock",
text: "Every new receiver asks first by default. You choose approve or refuse.",
},
{
number: "04",
icon: "verified",
title: "Stream and verify",
text: "Files move over an authenticated encrypted connection and are verified as they arrive.",
},
{
number: "05",
icon: "stop",
title: "Close the door",
text: "Follow each receiver, cancel in progress, or stop sharing to revoke the invitation.",
},
];
export function TransferFlow() {
return (
<div className="flow-layout">
<div className="flow-visual" aria-hidden="true">
<div className="flow-visual-glow" />
<div className="flow-device flow-device-sender">
<span className="flow-device-camera" />
<div className="flow-device-content">
<span className="flow-mini-label">SELECTED</span>
<div className="flow-file-stack">
<span><Icon name="file" /></span>
<span><Icon name="file" /></span>
<span><Icon name="file" /></span>
</div>
<strong>Project files</strong>
<small>18 items · 248 MB</small>
</div>
</div>
<svg className="flow-track" viewBox="0 0 480 600">
<defs>
<linearGradient id="flow-gradient" x1="90" y1="80" x2="401" y2="520" gradientUnits="userSpaceOnUse">
<stop stopColor="#C778FF" />
<stop offset="0.5" stopColor="#A855F7" />
<stop offset="1" stopColor="#7628EA" />
</linearGradient>
<filter id="flow-blur" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="18" />
</filter>
</defs>
<path className="flow-track-shadow" d="M132 141c216 14 240 88 153 159-89 73-43 157 81 160" />
<path className="flow-track-base" d="M132 141c216 14 240 88 153 159-89 73-43 157 81 160" />
<path className="flow-track-energy" d="M132 141c216 14 240 88 153 159-89 73-43 157 81 160" />
<circle className="flow-moving-drop" cx="0" cy="0" r="11" fill="url(#flow-gradient)" />
<circle className="flow-moving-halo" cx="0" cy="0" r="24" fill="#A855F7" opacity="0.18" filter="url(#flow-blur)" />
<g className="flow-gate">
<rect x="197" y="274" width="88" height="50" rx="17" fill="#FFFCFF" stroke="#D8B4FE" />
<path d="m241 286 12 5v8.5c0 7.2-5.1 12.1-12 14.2-6.9-2.1-12-7-12-14.2V291Z" fill="url(#flow-gradient)" />
<path d="m235.5 299 3.5 3.5 7.5-8" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" />
</g>
</svg>
<div className="flow-device flow-device-receiver">
<span className="flow-device-camera" />
<div className="flow-device-content flow-received-content">
<span className="flow-complete-ring"><Icon name="check" /></span>
<strong>All received</strong>
<small>18 items verified</small>
</div>
</div>
<span className="flow-annotation flow-annotation-one">E2E</span>
<span className="flow-annotation flow-annotation-two">VERIFIED</span>
</div>
<ol className="flow-steps">
{steps.map((step) => (
<li key={step.number} className="flow-step">
<div className="flow-step-icon">
<Icon name={step.icon} />
</div>
<div>
<span className="flow-step-number">{step.number}</span>
<h3>{step.title}</h3>
<p>{step.text}</p>
</div>
</li>
))}
</ol>
</div>
);
}