diff --git a/src/components/TryNowModal.tsx b/src/components/TryNowModal.tsx new file mode 100644 index 0000000..ee2d36b --- /dev/null +++ b/src/components/TryNowModal.tsx @@ -0,0 +1,603 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + X, + CreditCard, + KeyRound, + Loader2, + AlertCircle, + CheckCircle2, + Lock, + Zap, + Copy, + Check, +} from "lucide-react"; +import { CardTokenizer, type CardData } from "@/lib/tychee-client"; + +/* ───────────────────────────────────────────────────────── + Types +───────────────────────────────────────────────────────── */ + +interface CardFormData { + cardNumber: string; + expiry: string; + cvv: string; + cardholderName: string; +} + +interface TokenResult { + tokenId: string; + maskedPan: string; + network: string; + last4: string; + expiry: string; + demoTxHash: string; + encryptedSize: number; +} + +interface TryNowModalProps { + onClose: () => void; +} + +/* ───────────────────────────────────────────────────────── + Helpers +───────────────────────────────────────────────────────── */ + +/** Generate a pseudo-random 32-byte demo encryption key (no wallet needed) */ +async function getDemoKey(): Promise { + const seed = new TextEncoder().encode("tychee:demo:v1:no-wallet-required"); + const buf = await crypto.subtle.digest("SHA-256", seed); + return new Uint8Array(buf); +} + +/** Simulate a short on-chain write with a fake tx hash */ +async function simulateOnChainStore(tokenHash: string): Promise { + // Artificial latency to mimic a real blockchain round-trip + await new Promise((r) => setTimeout(r, 1_400)); + // Deterministic but visually plausible dummy hash + const raw = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`demo:${tokenHash}:${Date.now()}`) + ); + const hex = Array.from(new Uint8Array(raw)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return `0x${hex}`; +} + +function formatCardNumber(value: string): string { + const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, ""); + const parts: string[] = []; + for (let i = 0; i < v.length; i += 4) parts.push(v.substring(i, i + 4)); + return parts.length ? parts.join(" ") : value; +} + +function formatExpiry(value: string): string { + const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, ""); + return v.length >= 2 ? v.substring(0, 2) + "/" + v.substring(2, 4) : v; +} + +const NETWORK_GRADIENT: Record = { + visa: "from-blue-600 to-blue-800", + mastercard: "from-orange-500 to-red-600", + rupay: "from-green-500 to-teal-600", + amex: "from-gray-600 to-gray-800", + unknown: "from-primary to-accent", +}; + +const STEPS = [ + "Validating card details…", + "Detecting card network…", + "Encrypting with AES-256-GCM…", + "Submitting token on-chain…", + "Token confirmed ✓", +]; + +/* ───────────────────────────────────────────────────────── + Component +───────────────────────────────────────────────────────── */ + +export function TryNowModal({ onClose }: TryNowModalProps) { + const [form, setForm] = useState({ + cardNumber: "", + expiry: "", + cvv: "", + cardholderName: "", + }); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [currentStep, setCurrentStep] = useState(0); + const [result, setResult] = useState(null); + const [copied, setCopied] = useState(false); + + /* Close on Escape */ + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" && !isLoading) onClose(); + }, + [isLoading, onClose] + ); + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + /* Field helpers */ + const handleChange = (field: keyof CardFormData, raw: string) => { + let v = raw; + if (field === "cardNumber") v = formatCardNumber(raw); + else if (field === "expiry") v = formatExpiry(raw); + else if (field === "cvv") v = raw.replace(/[^0-9]/g, "").substring(0, 4); + else if (field === "cardholderName") v = raw.toUpperCase(); + setForm((p) => ({ ...p, [field]: v })); + }; + + /* Live network detection */ + const cleaned = form.cardNumber.replace(/\s/g, ""); + const liveNetwork = cleaned.length > 0 ? CardTokenizer.detectCardNetwork(cleaned) : null; + const isLuhnValid = cleaned.length >= 13 ? CardTokenizer.validateCardNumber(cleaned) : null; + + /* ── Main tokenization handler ── */ + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + const pan = form.cardNumber.replace(/\s/g, ""); + + // ── Web2 validation (real logic, unchanged) ── + if (!CardTokenizer.validateCardNumber(pan)) { + setError("Invalid card number — failed Luhn check."); + return; + } + const [month, year] = form.expiry.split("/"); + if (!month || !year || +month < 1 || +month > 12) { + setError("Invalid expiry date."); + return; + } + // Check card is not expired + const expYear = 2000 + parseInt(year, 10); + const expMonth = parseInt(month, 10); + const now = new Date(); + if (expYear < now.getFullYear() || (expYear === now.getFullYear() && expMonth < now.getMonth() + 1)) { + setError("This card has expired."); + return; + } + if (form.cvv.length < 3) { + setError("CVV must be at least 3 digits."); + return; + } + if (form.cardholderName.trim().length < 2) { + setError("Please enter the cardholder name."); + return; + } + + setIsLoading(true); + + try { + // Step 1 + setCurrentStep(0); + await new Promise((r) => setTimeout(r, 600)); + + // Step 2 — Detect network + setCurrentStep(1); + const network = CardTokenizer.detectCardNetwork(pan) as CardData["network"]; + await new Promise((r) => setTimeout(r, 400)); + + // Step 3 — AES-256-GCM encryption (real, just no wallet key) + setCurrentStep(2); + const demoKey = await getDemoKey(); + const cardData: CardData = { + pan, + cvv: form.cvv, + expiryMonth: month, + expiryYear: year, + cardholderName: form.cardholderName, + network, + }; + const { encryptedPayload, tokenHash, last4Digits } = + await CardTokenizer.encryptCard(cardData, demoKey); + await new Promise((r) => setTimeout(r, 200)); + + // Step 4 — Simulated on-chain write (dummy tx hash, no wallet) + setCurrentStep(3); + const demoTxHash = await simulateOnChainStore(tokenHash); + + // Step 5 — Done + setCurrentStep(4); + await new Promise((r) => setTimeout(r, 400)); + + setResult({ + tokenId: tokenHash.substring(0, 16), + maskedPan: CardTokenizer.maskCardNumber(pan), + network, + last4: last4Digits, + expiry: form.expiry, + demoTxHash, + encryptedSize: encryptedPayload.length, + }); + } catch (err: any) { + setError(err.message || "Tokenization failed."); + } finally { + setIsLoading(false); + } + }; + + const copyToken = async () => { + if (!result) return; + await navigator.clipboard.writeText(result.tokenId); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const reset = () => { + setResult(null); + setForm({ cardNumber: "", expiry: "", cvv: "", cardholderName: "" }); + setError(null); + setCurrentStep(0); + }; + + /* ── Render ── */ + return ( +
{ + if (e.target === e.currentTarget && !isLoading) onClose(); + }} + > + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Glow ring */} +
+ +
+ {/* ── Header ── */} +
+
+
+ +
+
+

+ Live Card Demo +

+

+ Try real tokenization — no wallet required +

+
+
+ + {!isLoading && ( + + )} +
+ + {/* ── Body ── */} +
+ {/* Success state */} + {result ? ( + + ) : ( + <> + {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Loading steps */} + {isLoading && ( + + )} + + {/* Form */} + {!isLoading && ( +
+ {/* Card number */} +
+ +
+ + handleChange("cardNumber", e.target.value) + } + placeholder="1234 5678 9012 3456" + className="w-full px-4 py-3 pr-28 bg-white/5 border border-white/10 rounded-xl text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition-all" + maxLength={19} + required + autoComplete="cc-number" + /> + {liveNetwork && ( +
+ + {liveNetwork} + + {isLuhnValid === true && ( + + )} + {isLuhnValid === false && ( + + )} +
+ )} +
+
+ + {/* Expiry + CVV */} +
+
+ + + handleChange("expiry", e.target.value) + } + placeholder="MM/YY" + className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition-all" + maxLength={5} + required + autoComplete="cc-exp" + /> +
+
+ + + handleChange("cvv", e.target.value) + } + placeholder="•••" + className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition-all" + maxLength={4} + required + autoComplete="cc-csc" + /> +
+
+ + {/* Cardholder name */} +
+ + + handleChange("cardholderName", e.target.value) + } + placeholder="JOHN DOE" + className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 transition-all" + required + autoComplete="cc-name" + /> +
+ + {/* Submit */} + + + {/* Footer note */} +

+ Card data is encrypted in-browser with AES-256-GCM and never sent + to any server. Blockchain TX is simulated. +

+
+ )} + + )} +
+
+
+
+ ); +} + +/* ───────────────────────────────────────────────────────── + Sub-components +───────────────────────────────────────────────────────── */ + +function StepsProgress({ steps, current }: { steps: string[]; current: number }) { + return ( +
+ {steps.map((label, i) => { + const done = i < current; + const active = i === current; + return ( +
+
+ {done ? ( + + ) : active ? ( + + ) : ( + + )} +
+ + {label} + +
+ ); + })} +
+ ); +} + +function SuccessView({ + result, + copied, + onCopy, + onReset, + onClose, +}: { + result: TokenResult; + copied: boolean; + onCopy: () => void; + onReset: () => void; + onClose: () => void; +}) { + const gradient = + NETWORK_GRADIENT[result.network] ?? NETWORK_GRADIENT.unknown; + + return ( +
+ {/* Success badge */} +
+ + + Card successfully tokenized! + +
+ + {/* Mini card visual */} +
+ {/* Subtle pattern */} +
+
+
+
+ + {result.encryptedSize}B encrypted +
+ + {result.network} + +
+
TOKENIZED CARD
+
+ •••• •••• •••• {result.last4} +
+
+
+
Expires
+
{result.expiry}
+
+ +
+
+
+ + {/* Token ID row */} +
+
+
+
+ Token ID +
+
+ {result.tokenId} +
+
+ +
+ + {/* Dummy TX hash */} +
+
+ Simulated TX Hash +
+
+ {result.demoTxHash.substring(0, 42)}… +
+
+
+ + {/* Disclaimer */} +

+ This is a demo. The token is not + stored anywhere. Connect your wallet to tokenize & store cards on-chain. +

+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 92b464a..82862bd 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -3,9 +3,10 @@ import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { CreditCard, TrendingUp, Gift, Store, Ticket, Users, Wallet, LogOut, Menu, X } from "lucide-react"; +import { CreditCard, TrendingUp, Gift, Store, Ticket, Users, Wallet, LogOut, Menu, X, Zap } from "lucide-react"; import { cn } from "@/lib/utils"; import { useWallet } from "@/context/WalletContext"; +import { TryNowModal } from "@/components/TryNowModal"; const navigation = [ { name: "Cards", href: "/cards", icon: CreditCard }, @@ -20,12 +21,13 @@ export function Navigation() { const pathname = usePathname(); const { publicKey, isConnected, isConnecting, connect, disconnect } = useWallet(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [tryNowOpen, setTryNowOpen] = useState(false); const truncateAddress = (address: string) => { return `${address.slice(0, 4)}...${address.slice(-4)}`; }; - return ( + return (<> - ); + + {/* Try Now Modal — portal-style, rendered outside