diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 00000000..ef5f255f --- /dev/null +++ b/AGENT.md @@ -0,0 +1,239 @@ +# zcash.me - Agent Reference + +## Project Overview +A privacy-focused identity and payments platform built on Zcash. +Users create profiles linked to their Zcash addresses and prove ownership +via blockchain transactions. + +## Tech Stack +- **Framework**: Next.js 16 (App Router) + React 19 +- **Language**: TypeScript 5.9 +- **Database**: Supabase (PostgreSQL) +- **State**: Zustand (colocated in `/ui/*/store.ts`) + React Query (server) +- **Styling**: TailwindCSS 4 +- **Animations**: Framer Motion + +## Directory Structure +``` +/app → Next.js pages and API routes +/lib → Core business logic and utilities +/ui → React components by feature +/public → Static assets +``` + +## Zcash Integration Points + +### Address Types (prefer unified) +| Prefix | Type | Privacy | Use | +|--------|------|---------|-----| +| `u1` | Unified | High | Recommended | +| `zs1` | Sapling | High | Acceptable | +| `t1`/`t3` | Transparent | None | Warn user | + +### Verification Flow (ZVS) +1. User generates QR → creates session with memo `zvs/{session_id},{u-address}` +2. User sends 0.003 ZEC to ZVS address with memo +3. OTP computed deterministically from memo (HMAC-SHA256) +4. User enters OTP → pending edits applied, profile verified + +### Key Utilities +- `/lib/zcash/zcashUtils.ts` - Address validation, URI building +- `/lib/verification/` - OTP confirmation logic +- `/lib/swap/` - OneClick SDK for cross-chain swaps + +## Environment Variables +``` +NEXT_PUBLIC_SUPABASE_URL - Database URL +NEXT_PUBLIC_SUPABASE_ANON_KEY - Public DB key +ZVS_SECRET_SEED - HMAC secret for OTP generation +NEXT_PUBLIC_BASE_DOMAIN - zcash.me or localhost +ONECLICK_API_KEY - Defuse swap API +API_KEY - Server-side API auth +``` + +## Quick Start for Agents +1. Read `/lib/AGENT.md` for business logic overview +2. Read `/ui/AGENT.md` for component patterns +3. Check feature-specific AGENT.md in subfolders +4. Use `/app/design-system` to see components + +## Common Tasks + +### Add New Profile Field +1. Update types in `/lib/profile/types.ts` +2. Add validation in `/lib/validation/` +3. Update UI in `/ui/profile/` or `/ui/signup/` +4. Update server action if needed + +### Add New API Endpoint +1. Create route in `/app/api/[route]/route.ts` +2. Use `apiGuard` from `/lib/api/guard.ts` +3. Return consistent `ApiResponse` format + +### Add New UI Component +1. Create in appropriate `/ui/` subfolder +2. Export from folder's `index.ts` +3. Use `/ui/common/` building blocks +4. Add to design-system page if reusable + +### Repo Overview + +What Is This? + + Zcash.me — an open-source public directory for Zcash addresses. Users claim a vanity URL (zcash.me/yourname), register their Zcash address, and get a + shareable profile page with a QR code so people can send them ZEC without copying long addresses. + + --- + Tech Stack + + ┌─────────────┬─────────────────────────────────────────────────┐ + │ Layer │ Technology │ + ├─────────────┼─────────────────────────────────────────────────┤ + │ Framework │ Next.js 16 (App Router, React 19) │ + ├─────────────┼─────────────────────────────────────────────────┤ + │ Language │ TypeScript (strict mode) │ + ├─────────────┼─────────────────────────────────────────────────┤ + │ Database │ Supabase (PostgreSQL + RLS) │ + ├─────────────┼─────────────────────────────────────────────────┤ + │ Styling │ Tailwind CSS v4 │ + ├─────────────┼─────────────────────────────────────────────────┤ + │ Animations │ Framer Motion │ + ├─────────────┼─────────────────────────────────────────────────┤ + │ State │ Zustand (client), TanStack React Query (server) │ + ├─────────────┼─────────────────────────────────────────────────┤ + │ Crypto swap │ Defuse Protocol one-click SDK │ + ├─────────────┼─────────────────────────────────────────────────┤ + │ QR codes │ qrcode.react │ + └─────────────┴─────────────────────────────────────────────────┘ + + --- + Directory Structure (by concern) + + app/ — Pages & Routes + + ┌─────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────┐ + │ Path │ What it does │ + ├─────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ + │ page.tsx / HomePage.tsx │ Landing page — featured profiles carousel, typing effect, "Claim your name" CTA │ + ├─────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ + │ [slug]/ProfilePage.tsx │ Dynamic profile page — donate mode (QR + address), swap mode (Defuse), verify mode (OTP) │ + ├─────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ + │ ns/DirectoryNS.tsx │ Network School member directory — filterable table with search, location, role filters │ + ├─────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ + │ swap-app/ │ Standalone crypto swap interface (token selection, quotes, slippage) │ + ├─────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ + │ leader-app/ │ Referral rewards leaderboard — commission tiers, earnings tracking │ + ├─────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ + │ thread/ │ Community messaging (WIP, mostly TODOs) │ + ├─────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ + │ api/directory/ │ GET /api/directory?q=&limit=&cursor= — search profiles (30s cache) │ + ├─────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ + │ api/resolve/ │ GET /api/resolve?username= — resolve one profile (60s cache) │ + ├─────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤ + │ api/social/ │ GET /api/social?platform=&handle= — find address by social handle (300s cache) │ + └─────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────┘ + + lib/ — Business Logic + + Module: profile/ + Responsibility: Types (Profile, ProfileLink, ProfileTrust), Supabase queries, link enrichment (domain→icon mapping for 30+ platforms), trust + calculation, username policy (NFKC normalization, slug rules) + ──────────────────────────────────────── + Module: signup/ + Responsibility: Profile creation — createProfileAction inserts into zcasher + zcasher_links + ──────────────────────────────────────── + Module: verification/ + Responsibility: Address verification via OTP — HMAC-SHA256 generation matching the Rust ZVS backend. Memo format: zvs/TIMESTAMP,SESSIONID + ──────────────────────────────────────── + Module: zcash/ + Responsibility: Address validation (Sapling zs1, Unified u1, transparent t1/t2/t3), URI building (zcash:ADDR?amount=X&memo=Y), memo Base64URL encoding + ──────────────────────────────────────── + Module: swap/ + Responsibility: Defuse Protocol integration — quote generation, deposit addresses, slippage, token decimals + ──────────────────────────────────────── + Module: leaderboard/ + Responsibility: Referral rewards — base 5% commission, +0.5% per verified link (up to 15%), 12-month reward window + ──────────────────────────────────────── + Module: supabase/ + Responsibility: Client-side and server-side Supabase instances (server uses service key to bypass RLS) + ──────────────────────────────────────── + Module: api/guard.ts + Responsibility: API key enforcement (X-API-Key header required on all API routes) + + ui/ — React Components + + ┌───────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Folder │ Components │ + ├───────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ profile/ │ ProfileCard, ProfileAvatar, ProfileEditor (edit modal), VerifiedBadge, ProfileSearchDropdown, ProfileCardWarning (trust warnings) │ + ├───────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ signup/ │ AddUserForm (multi-step wizard), ZcashAddressInput, SocialLinkInput, CitySearchDropdown │ + ├───────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ verification/ │ VerifyProfileModal, OtpInput, QrUriBlock, AmountAndWallet │ + ├───────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ swap/ │ SwapComposer, SwapCurrencyPair, SwapQuoteDisplay, SwapSlippageControl │ + ├───────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ common/ │ Shared form inputs, buttons, dropdowns │ + ├───────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ links/ │ Social link connection/verification flows, provider configs │ + └───────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + + --- + Data Model (Supabase) + + zcasher — the main profiles table: + - id, name (username), display_name, bio, address (Zcash address) + - address_verified (boolean — proved via on-chain OTP) + - nearest_city_name, country, iso2 + - is_ns, is_ns_core, is_ns_longterm (Network School flags) + - referred_by_zcasher_id (referral tracking) + + zcasher_links — social/web links per profile: + - zcasher_id (FK), url, label, platform + - is_verified, pending_verif, verification_expires_at + + zcasher_searchable — denormalized view with link_search_text for full-text search + + --- + Key Flows + + Signup + + 1. User enters username → real-time availability check + 2. Enters Zcash address → validated (Sapling/Unified preferred, transparent warned) + 3. Adds social links → normalized per platform + 4. Submits → profile created in zcasher + links in zcasher_links + + Address Verification (ZVS) + + 1. User clicks "Verify" → generateMemoAction creates a ZVS memo + 2. User sends a tiny ZEC transaction with that memo to a shielded address + 3. ZVS backend (Rust, separate Azure VM) processes it and returns an OTP + 4. User enters OTP → confirmOtpAction validates HMAC → address_verified = true + + Directory Search + + Tiered ranking: username prefix match > username contains > display name prefix > display name contains > link text contains. Secondary sort by + verified status then alphabetical. + + Referral Rewards + + Referrers earn commission (5%–15% depending on verified link count) on referred users' activity for 12 months, with eligibility requiring verification + within 4 weeks. + + --- + Architecture Highlights + + - Privacy-first: no analytics, no cookies, no tracking + - API-key gated routes for wallet integrations (search, resolve, social lookup) + - Server actions for mutations (Next.js "use server") + - React Query for caching/fetching on the client + - Component hierarchy: RootLayout → ProfileHeader + page content → feature-specific component trees with modals via createPortal + Framer Motion + animations + - Service Worker kill switch in public/sw.js — nukes old PWA caches on activation + + --- + WIP / Incomplete + + - Thread/messaging (app/thread/, lib/thread/) — skeleton in place but server actions are TODOs + - Link verification — framework exists in ui/links/ but provider flows are partially implemented diff --git a/app/AGENT.md b/app/AGENT.md new file mode 100644 index 00000000..3a6f2f03 --- /dev/null +++ b/app/AGENT.md @@ -0,0 +1,52 @@ +# /app - Next.js App Router + +## Purpose +Contains all page routes and API endpoints for zcash.me. Uses Next.js 16 App Router with React 19. + +## Key Routes + +| Path | Description | +|------|-------------| +| `/` | Homepage with featured Zcash profiles | +| `/[slug]` | Dynamic profile pages (e.g., /alice) | +| `/ns` | Network School directory - filtered profile list | +| `/swap-app` | Cryptocurrency swap interface (Defuse OneClick) | +| `/leader-app` | Referral leaderboard dashboard | +| `/stats-app` | Network statistics | +| `/thread` | Discussion board (OTP-verified posting) | +| `/design-system` | Component showcase for manual testing | + +## API Routes + +### `/api/resolve/[username]` - GET +Profile lookup by username. Returns profile with links. + +### `/api/directory` - GET +Search profiles with ranking. Supports `q`, `limit`, `cursor`, `verified_only` params. +Features space-insensitive, case-insensitive matching with relevance ranking. + +### `/api/social` - GET +Social platform lookup (stub implementation). + +## Zcash-Specific Patterns +- Profile pages display Zcash unified addresses (u1...) prominently +- QR codes encode `zcash:` URIs with memo for verification +- Swap routes handle ZEC as primary currency with cross-chain support + +## Testing Harness +- No automated tests in /app +- Use `/design-system` route for manual component testing +- API routes use rate limiting via `/lib/api/guard.ts` + +## Adding New Pages +1. Create folder under `/app/[route-name]` +2. Add `page.tsx` with default export +3. Use server components by default, `'use client'` only when needed +4. Import UI from `/ui/*`, logic from `/lib/*` + +## Environment Variables +``` +NEXT_PUBLIC_BASE_DOMAIN - zcash.me or localhost:3000 +NEXT_PUBLIC_SUPABASE_URL - Database URL +ZVS_SECRET_SEED - HMAC secret for OTP generation +``` diff --git a/app/[slug]/ProfilePage.tsx b/app/[slug]/ProfilePage.tsx index cfc0fcf0..839e87f7 100644 --- a/app/[slug]/ProfilePage.tsx +++ b/app/[slug]/ProfilePage.tsx @@ -4,8 +4,6 @@ import { useEffect, useState, useCallback } from "react"; import type { Profile } from "@/lib/profile/types"; import type { Token, SwapContextQuoteData, SwapQuoteDisplay } from "@/lib/swap/types"; -// Stores -import { useEditsStore } from "@/lib/stores/edits"; // Swap utilities import { getTokenId } from "@/lib/swap/utils"; @@ -68,9 +66,6 @@ export default function ProfilePage({ const [forceShowQR, setForceShowQR] = useState(false); const [isProfileEditing, setIsProfileEditing] = useState(false); - // Granular subscriptions to prevent unnecessary re-renders - const pendingEdits = useEditsStore(state => state.pendingEdits); - // Local state const [mode, setMode] = useState<'donate' | 'swap' | 'verification'>('donate'); const [originTokenId, setOriginTokenId] = useState(null); @@ -249,7 +244,6 @@ export default function ProfilePage({ {mode === "verification" ? ( ) : mode === "swap" ? (
diff --git a/app/api/directory/route.ts b/app/api/directory/route.ts index fec1f162..09d91f3b 100644 --- a/app/api/directory/route.ts +++ b/app/api/directory/route.ts @@ -18,6 +18,7 @@ interface ZcasherLink { id: number; label: string; url: string; + platform?: string; is_verified: boolean; zcasher_id: number; } @@ -26,6 +27,7 @@ interface LinkOutput { id: number; label: string; url: string; + platform?: string; is_verified: boolean; } @@ -272,7 +274,7 @@ export async function GET(request: Request): Promise { if (profileIds.length > 0) { const { data: links, error: linksError } = await supabase .from("zcasher_links") - .select("id,label,url,is_verified,zcasher_id") + .select("id,label,url,platform,is_verified,zcasher_id") .in("zcasher_id", profileIds); if (linksError) { @@ -293,10 +295,10 @@ export async function GET(request: Request): Promise { const profileLinks = linksMap.get(p.id) || []; const authenticated_links: LinkOutput[] = profileLinks .filter((l) => l.is_verified) - .map((l) => ({ id: l.id, label: l.label, url: l.url, is_verified: l.is_verified })); + .map((l) => ({ id: l.id, label: l.label, url: l.url, platform: l.platform, is_verified: l.is_verified })); const unauthenticated_links: LinkOutput[] = profileLinks .filter((l) => !l.is_verified) - .map((l) => ({ id: l.id, label: l.label, url: l.url, is_verified: l.is_verified })); + .map((l) => ({ id: l.id, label: l.label, url: l.url, platform: l.platform, is_verified: l.is_verified })); return { id: p.id, diff --git a/app/api/resolve/[username]/route.ts b/app/api/resolve/[username]/route.ts index b9414b7a..94a8765b 100644 --- a/app/api/resolve/[username]/route.ts +++ b/app/api/resolve/[username]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { createSupabaseServerClient } from "../../../../lib/supabase/supabase-server"; -import { enforceApiGuard, withCacheHeaders } from "../../../../lib/api/guard"; +import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; +import { enforceApiGuard, withCacheHeaders } from "@/lib/api/guard"; interface ZcasherProfile { id: number; @@ -18,6 +18,7 @@ interface ZcasherLink { id: number; label: string; url: string; + platform?: string; is_verified: boolean; } @@ -73,7 +74,7 @@ export async function GET( const { data: links, error: linksError } = await supabase .from("zcasher_links") - .select("id,label,url,is_verified") + .select("id,label,url,platform,is_verified") .eq("zcasher_id", typedProfile.id); if (linksError) { diff --git a/app/api/resolve/route.ts b/app/api/resolve/route.ts index 02d58a8b..b5e2dada 100644 --- a/app/api/resolve/route.ts +++ b/app/api/resolve/route.ts @@ -1,5 +1,5 @@ -import { createSupabaseServerClient } from "../../../lib/supabase/supabase-server"; -import { enforceApiGuard, withCacheHeaders } from "../../../lib/api/guard"; +import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; +import { enforceApiGuard, withCacheHeaders } from "@/lib/api/guard"; const jsonResponse = (body: Record, status: number = 200, cacheSeconds: number = 0): Response => new Response(JSON.stringify(body), { @@ -23,6 +23,7 @@ interface ZcasherLink { id: number; label: string; url: string; + platform?: string; is_verified: boolean; } @@ -65,7 +66,7 @@ export async function GET(request: Request): Promise { const { data: links, error: linksError } = await supabase .from("zcasher_links") - .select("id,label,url,is_verified") + .select("id,label,url,platform,is_verified") .eq("zcasher_id", typedProfile.id); if (linksError) { diff --git a/app/api/social/route.ts b/app/api/social/route.ts index 93fc036f..42ff94db 100644 --- a/app/api/social/route.ts +++ b/app/api/social/route.ts @@ -1,31 +1,111 @@ -import { lookupSocialAddress } from "../../../lib/profile/social-lookup"; -import { enforceApiGuard, withCacheHeaders } from "../../../lib/api/guard"; +import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; +import { enforceApiGuard, withCacheHeaders } from "@/lib/api/guard"; +import { normalizeSocialUsername, type SocialPlatform } from "@/lib/profile/usernameNormalizer"; + +const PLATFORM_ALIASES: Record = { + x: "X", + twitter: "X", + github: "GitHub", + instagram: "Instagram", + reddit: "Reddit", + linkedin: "LinkedIn", + discord: "Discord", + tiktok: "TikTok", + bluesky: "Bluesky", + mastodon: "Mastodon", + snapchat: "Snapchat", + telegram: "Telegram", +}; + +const json = (body: Record, status: number, cacheSeconds = 0): Response => + new Response(JSON.stringify(body), { + status, + headers: withCacheHeaders({ "Content-Type": "application/json" }, cacheSeconds), + }); export async function GET(request: Request): Promise { const guard = await enforceApiGuard(request, { cacheSeconds: 300 }); if (guard instanceof Response) return guard; const { searchParams } = new URL(request.url); - const platform = searchParams.get("platform") || ""; - const handle = searchParams.get("handle") || ""; - - if (!platform || !handle) { - return new Response( - JSON.stringify({ error: "missing_parameters", platform: null, handle: null }), - { - status: 400, - headers: { "Content-Type": "application/json" }, - } - ); + const rawPlatform = (searchParams.get("platform") || "").trim().toLowerCase(); + const rawHandle = searchParams.get("handle") || ""; + + const platform = PLATFORM_ALIASES[rawPlatform]; + if (!platform) { + return json({ error: "unsupported_platform", address: null, handle: null }, 400); + } + + const handle = normalizeSocialUsername(rawHandle, platform); + if (!handle) { + return json({ error: "invalid_handle", address: null, handle: null }, 400); } - const result = await lookupSocialAddress(platform, handle); + const supabase = createSupabaseServerClient(); + if (!supabase) { + return json({ error: "server_misconfigured", address: null, handle }, 500); + } - return new Response(JSON.stringify(result.body), { - status: result.status, - headers: withCacheHeaders( - { "Content-Type": "application/json" }, - guard.cacheSeconds - ), - }); + // Query by platform column + handle match on label or url tail + const { data: links, error: linksError } = await supabase + .from("zcasher_links") + .select("id,zcasher_id,label,url,is_verified") + .eq("platform", platform) + .eq("is_verified", true) + .or(`label.ilike.${handle},url.ilike.%/${handle}`) + .limit(25); + + if (linksError) { + return json({ error: "lookup_failed", address: null, handle }, 500); + } + + if (!links || !links.length) { + return json({ error: "not_found", address: null, handle }, 404); + } + + // Fetch profiles for matched links + const ids = [...new Set(links.map((l) => l.zcasher_id))]; + const { data: profiles, error: profileError } = await supabase + .from("zcasher") + .select("id,address,name,address_verified") + .in("id", ids); + + if (profileError || !profiles?.length) { + return json({ error: "profile_lookup_failed", address: null, handle }, 500); + } + + const profilesById = new Map(profiles.map((p) => [p.id, p])); + + // Pick best: prefer verified link + verified address, then oldest profile + const best = links + .map((link) => ({ link, profile: profilesById.get(link.zcasher_id) })) + .filter((c): c is { link: typeof links[0]; profile: NonNullable } => + !!c.profile?.address + ) + .sort((a, b) => { + const scoreA = (a.profile.address_verified ? 1 : 0); + const scoreB = (b.profile.address_verified ? 1 : 0); + if (scoreA !== scoreB) return scoreB - scoreA; + return a.profile.id - b.profile.id; + })[0]; + + if (!best) { + return json({ error: "address_missing", address: null, handle }, 404); + } + + return json( + { + link: { + platform: platform.toLowerCase(), + handle, + url: best.link.url, + is_verified: true, + }, + address: best.profile.address, + profile_name: best.profile.name, + address_verified: !!best.profile.address_verified, + }, + 200, + guard.cacheSeconds + ); } diff --git a/app/design-system/page.tsx b/app/design-system/page.tsx index abf278bd..0b245e3e 100644 --- a/app/design-system/page.tsx +++ b/app/design-system/page.tsx @@ -1,36 +1,34 @@ "use client"; import { useState } from "react"; -import { - // Buttons - Button, - CopyButton, - IconButton, - // Layout - Card, - Section, - Divider, - // Feedback - Badge, - Spinner, - Alert, - // Forms - Input, - TextArea, - Select, - Checkbox, - FormField, - Dropdown, - // Modals - Modal, - ModalHeader, - ModalBody, - ModalFooter, - ConfirmDialog, - TutorialModal, - // Other - HelpIcon, -} from "@/ui/common"; +// Buttons +import Button from "@/ui/common/buttons/Button"; +import CopyButton from "@/ui/common/buttons/CopyButton"; +import IconButton from "@/ui/common/buttons/IconButton"; +// Layout +import Card from "@/ui/common/layout/Card"; +import Section from "@/ui/common/layout/Section"; +import Divider from "@/ui/common/layout/Divider"; +// Feedback +import Badge from "@/ui/common/feedback/Badge"; +import Spinner from "@/ui/common/feedback/Spinner"; +import Alert from "@/ui/common/feedback/Alert"; +// Forms +import Input from "@/ui/common/forms/Input"; +import TextArea from "@/ui/common/forms/TextArea"; +import Select from "@/ui/common/forms/Select"; +import Checkbox from "@/ui/common/forms/Checkbox"; +import FormField from "@/ui/common/forms/FormField"; +import Dropdown from "@/ui/common/forms/Dropdown"; +// Modals +import Modal from "@/ui/common/modals/Modal"; +import ModalHeader from "@/ui/common/modals/ModalHeader"; +import ModalBody from "@/ui/common/modals/ModalBody"; +import ModalFooter from "@/ui/common/modals/ModalFooter"; +import ConfirmDialog from "@/ui/common/modals/ConfirmDialog"; +import TutorialModal from "@/ui/common/modals/TutorialModal"; +// Other +import HelpIcon from "@/ui/common/HelpIcon"; export default function DesignSystemPage() { const [modalOpen, setModalOpen] = useState(false); diff --git a/app/ns/DirectoryNS.tsx b/app/ns/DirectoryNS.tsx index eb2c2c92..d00777a4 100644 --- a/app/ns/DirectoryNS.tsx +++ b/app/ns/DirectoryNS.tsx @@ -8,20 +8,20 @@ import ProfileAvatar from "@/ui/profile/ProfileAvatar"; import AmountAndWallet from "@/ui/verification/AmountAndWallet"; import QrUriBlock from "@/ui/verification/QrUriBlock"; import HelpMessage from "@/ui/verification/HelpMessage"; -import InlineCopyButton from "./InlineCopyButton"; -import SocialLinks from "./SocialLinks"; -import TagBadges from "./TagBadges"; -import NsFilters from "./NsFilters"; -import NsHeader from "./NsHeader"; -import NsLocationFilterModal from "./NsLocationFilterModal"; -import NsTable from "./NsTable"; -import NsUnverifiedLinkModal from "./NsUnverifiedLinkModal"; -import useFlightPaths from "./useFlightPaths"; -import useNsCounts from "./useNsCounts"; -import useNsDirectory, { type EnrichedLink } from "./useNsDirectory"; -import useNsFilters from "./useNsFilters"; -import useProfileModal from "./useProfileModal"; -import { getProfileTags, normalizeSlug } from "./directoryNsUtils"; +import InlineCopyButton from "./table/InlineCopyButton"; +import SocialLinks from "./table/SocialLinks"; +import TagBadges from "./table/TagBadges"; +import NsFilters from "./filters/NsFilters"; +import NsHeader from "./filters/NsHeader"; +import NsLocationFilterModal from "./filters/NsLocationFilterModal"; +import NsTable from "./table/NsTable"; +import NsUnverifiedLinkModal from "./shared/NsUnverifiedLinkModal"; +import useFlightPaths from "./hooks/useFlightPaths"; +import useNsCounts from "./filters/useNsCounts"; +import useNsDirectory, { type EnrichedLink } from "./hooks/useNsDirectory"; +import useNsFilters from "./filters/useNsFilters"; +import useProfileModal from "./hooks/useProfileModal"; +import { getProfileTags, normalizeSlug } from "./shared/directoryNsUtils"; export default function DirectoryAlt({ initialProfiles = null }: { initialProfiles?: Profile[] | null }) { // Local state diff --git a/app/ns/NsFilters.tsx b/app/ns/filters/NsFilters.tsx similarity index 94% rename from app/ns/NsFilters.tsx rename to app/ns/filters/NsFilters.tsx index f67e9663..69d81237 100644 --- a/app/ns/NsFilters.tsx +++ b/app/ns/filters/NsFilters.tsx @@ -1,10 +1,10 @@ "use client"; import type { StaticImageData } from "next/image"; -import allIcon from "./assets/network-state-plus-flag-avatar-logo-black.png"; -import coreIcon from "./assets/network-state-plus-flag-avatar-logo-core-team.png"; -import longTermIcon from "./assets/network-state-plus-flag-avatar-logo-long-term.png"; +import allIcon from "../assets/network-state-plus-flag-avatar-logo-black.png"; +import coreIcon from "../assets/network-state-plus-flag-avatar-logo-core-team.png"; +import longTermIcon from "../assets/network-state-plus-flag-avatar-logo-long-term.png"; import discordFavicon from "@/lib/profile/assets/favicons/favicon-discord-32.png"; -import { FILTER_BASE, FILTER_CONTENT } from "./directoryNsStyles"; +import { FILTER_BASE, FILTER_CONTENT } from "../shared/directoryNsStyles"; const getFilterButtonClass = (active: boolean, activeClass: string, hoverClass: string): string => { const scopedHoverClass = hoverClass.replace(/hover:/g, "md:hover:"); diff --git a/app/ns/NsHeader.tsx b/app/ns/filters/NsHeader.tsx similarity index 98% rename from app/ns/NsHeader.tsx rename to app/ns/filters/NsHeader.tsx index 0b14bb8c..41e73e39 100644 --- a/app/ns/NsHeader.tsx +++ b/app/ns/filters/NsHeader.tsx @@ -1,5 +1,5 @@ "use client"; -import znsFlag from "./assets/zns-flag.png"; +import znsFlag from "../assets/zns-flag.png"; interface AnnouncementConfig { message: string; diff --git a/app/ns/NsLocationFilterModal.tsx b/app/ns/filters/NsLocationFilterModal.tsx similarity index 100% rename from app/ns/NsLocationFilterModal.tsx rename to app/ns/filters/NsLocationFilterModal.tsx diff --git a/app/ns/useNsCounts.ts b/app/ns/filters/useNsCounts.ts similarity index 96% rename from app/ns/useNsCounts.ts rename to app/ns/filters/useNsCounts.ts index f8c49b33..623b2908 100644 --- a/app/ns/useNsCounts.ts +++ b/app/ns/filters/useNsCounts.ts @@ -6,7 +6,7 @@ import { isNsProfile, isTruthyFlag, isVerifiedProfile, -} from "./directoryNsUtils"; +} from "../shared/directoryNsUtils"; interface NsCounts { nsCount: number; diff --git a/app/ns/useNsFilters.ts b/app/ns/filters/useNsFilters.ts similarity index 99% rename from app/ns/useNsFilters.ts rename to app/ns/filters/useNsFilters.ts index a9b48215..98e5cc98 100644 --- a/app/ns/useNsFilters.ts +++ b/app/ns/filters/useNsFilters.ts @@ -10,7 +10,7 @@ import { isNsProfile, isTruthyFlag, isVerifiedProfile, -} from "./directoryNsUtils"; +} from "../shared/directoryNsUtils"; interface FilterState { verified: boolean; diff --git a/app/ns/useFlightPaths.ts b/app/ns/hooks/useFlightPaths.ts similarity index 100% rename from app/ns/useFlightPaths.ts rename to app/ns/hooks/useFlightPaths.ts diff --git a/app/ns/useNsDirectory.ts b/app/ns/hooks/useNsDirectory.ts similarity index 100% rename from app/ns/useNsDirectory.ts rename to app/ns/hooks/useNsDirectory.ts diff --git a/app/ns/useProfileModal.ts b/app/ns/hooks/useProfileModal.ts similarity index 94% rename from app/ns/useProfileModal.ts rename to app/ns/hooks/useProfileModal.ts index 51a6e142..8d3a53d2 100644 --- a/app/ns/useProfileModal.ts +++ b/app/ns/hooks/useProfileModal.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import type { Profile } from "@/lib/profile/types"; -import type { UnverifiedLinkData } from "./types"; +import type { UnverifiedLinkData } from "../types"; interface UseProfileModalReturn { activeProfile: Profile | null; diff --git a/app/ns/LoadingDots.tsx b/app/ns/shared/LoadingDots.tsx similarity index 100% rename from app/ns/LoadingDots.tsx rename to app/ns/shared/LoadingDots.tsx diff --git a/app/ns/NsUnverifiedLinkModal.tsx b/app/ns/shared/NsUnverifiedLinkModal.tsx similarity index 97% rename from app/ns/NsUnverifiedLinkModal.tsx rename to app/ns/shared/NsUnverifiedLinkModal.tsx index 4ad17361..b6f49ad4 100644 --- a/app/ns/NsUnverifiedLinkModal.tsx +++ b/app/ns/shared/NsUnverifiedLinkModal.tsx @@ -1,6 +1,6 @@ "use client"; import CopyButton from "@/ui/common/buttons/CopyButton"; -import type { UnverifiedLinkData } from "./types"; +import type { UnverifiedLinkData } from "../types"; interface NsUnverifiedLinkModalProps { unverifiedLink: UnverifiedLinkData | null; diff --git a/app/ns/directoryNsStyles.ts b/app/ns/shared/directoryNsStyles.ts similarity index 100% rename from app/ns/directoryNsStyles.ts rename to app/ns/shared/directoryNsStyles.ts diff --git a/app/ns/directoryNsUtils.ts b/app/ns/shared/directoryNsUtils.ts similarity index 100% rename from app/ns/directoryNsUtils.ts rename to app/ns/shared/directoryNsUtils.ts diff --git a/app/ns/InlineCopyButton.tsx b/app/ns/table/InlineCopyButton.tsx similarity index 100% rename from app/ns/InlineCopyButton.tsx rename to app/ns/table/InlineCopyButton.tsx diff --git a/app/ns/NsRow.tsx b/app/ns/table/NsRow.tsx similarity index 98% rename from app/ns/NsRow.tsx rename to app/ns/table/NsRow.tsx index 0e3454c9..81c4d28d 100644 --- a/app/ns/NsRow.tsx +++ b/app/ns/table/NsRow.tsx @@ -1,8 +1,8 @@ "use client"; import React from "react"; import type { Profile } from "@/lib/profile/types"; -import type { EnrichedLink } from "./useNsDirectory"; -import type { UnverifiedLinkData } from "./types"; +import type { EnrichedLink } from "../hooks/useNsDirectory"; +import type { UnverifiedLinkData } from "../types"; import ProfileAvatar from "@/ui/profile/ProfileAvatar"; import SocialLinks from "./SocialLinks"; import TagBadges from "./TagBadges"; @@ -12,7 +12,7 @@ import { getProfileLocation, getProfileTags, normalizeSlug, -} from "./directoryNsUtils"; +} from "../shared/directoryNsUtils"; interface NsRowProps { profile: Profile; diff --git a/app/ns/NsTable.tsx b/app/ns/table/NsTable.tsx similarity index 93% rename from app/ns/NsTable.tsx rename to app/ns/table/NsTable.tsx index cfd1f381..924861c6 100644 --- a/app/ns/NsTable.tsx +++ b/app/ns/table/NsTable.tsx @@ -1,10 +1,10 @@ "use client"; import { useMemo } from "react"; import type { Profile } from "@/lib/profile/types"; -import type { LinksByProfileId } from "./useNsDirectory"; -import type { UnverifiedLinkData } from "./types"; +import type { LinksByProfileId } from "../hooks/useNsDirectory"; +import type { UnverifiedLinkData } from "../types"; -import LoadingDots from "./LoadingDots"; +import LoadingDots from "../shared/LoadingDots"; import NsRow from "./NsRow"; interface NsTableProps { diff --git a/app/ns/SocialLinks.tsx b/app/ns/table/SocialLinks.tsx similarity index 91% rename from app/ns/SocialLinks.tsx rename to app/ns/table/SocialLinks.tsx index 61c6b1a7..dbbd62ab 100644 --- a/app/ns/SocialLinks.tsx +++ b/app/ns/table/SocialLinks.tsx @@ -1,9 +1,9 @@ "use client"; import React from "react"; import type { StaticImageData } from "next/image"; -import type { EnrichedLink } from "./useNsDirectory"; -import type { UnverifiedLinkData } from "./types"; -import { FALLBACK_ICON, getLinkLabel, getSocialDisplay, getSocialHandle, isDiscordLink } from "@/lib/profile/profileLinks"; +import type { EnrichedLink } from "../hooks/useNsDirectory"; +import type { UnverifiedLinkData } from "../types"; +import { FALLBACK_ICON, getLinkLabel, getSocialDisplay, getSocialHandle } from "@/lib/profile/profileLinks"; interface SocialLinksProps { links?: EnrichedLink[]; @@ -35,7 +35,7 @@ export default function SocialLinks({ return (
{links.map((link) => { - const isDiscord = isDiscordLink(link.url); + const isDiscord = link.platform === "Discord"; const isVerified = Boolean(link.is_verified); const displayHandle = getSocialDisplay(link); const title = link.domainLabel ?? getLinkLabel(link.url); @@ -50,7 +50,7 @@ export default function SocialLinks({ onUnverifiedClick?.({ url: link.url, label: link.label ?? "", - display: getSocialHandle(link.url), + display: getSocialHandle(link.url, link.platform), isDiscord, }); }} diff --git a/app/ns/TagBadges.tsx b/app/ns/table/TagBadges.tsx similarity index 92% rename from app/ns/TagBadges.tsx rename to app/ns/table/TagBadges.tsx index 09bb91d9..3e8a7fcd 100644 --- a/app/ns/TagBadges.tsx +++ b/app/ns/table/TagBadges.tsx @@ -1,7 +1,7 @@ "use client"; import type { StaticImageData } from "next/image"; -import coreIcon from "./assets/network-state-plus-flag-avatar-logo-core-team.png"; -import longTermIcon from "./assets/network-state-plus-flag-avatar-logo-long-term.png"; +import coreIcon from "../assets/network-state-plus-flag-avatar-logo-core-team.png"; +import longTermIcon from "../assets/network-state-plus-flag-avatar-logo-long-term.png"; interface TagBadgesProps { tags?: string[]; diff --git a/lib/AGENT.md b/lib/AGENT.md new file mode 100644 index 00000000..304c3a1d --- /dev/null +++ b/lib/AGENT.md @@ -0,0 +1,55 @@ +# /lib - Core Business Logic + +## Purpose +Shared server-side logic, data fetching, server actions, types, and utilities. +This is the brain of zcash.me - all business logic lives here. + +## Directory Structure + +| Folder | Purpose | +|--------|---------| +| `/zcash` | Zcash address validation, URI building, memo encoding | +| `/profile` | Profile types, fetching, username policies, link handling | +| `/directory` | Search, city filtering, featured profiles | +| `/verification` | OTP confirmation, link verification | +| `/signup` | Profile creation server actions | +| `/swap` | OneClick SDK integration, token types | +| `/validation` | Composable form validators | +| `/leaderboard` | Referral commission calculations | +| `/thread` | Discussion board actions | +| `/supabase` | Database client initialization | +| `/api` | Rate limiting, API guards | + +## Key Exports + +### Server Actions +- `createProfileAction` - Create new profile +- `confirmOtpAction` - Verify OTP from transaction memo +- `updateLinkVerificationAction` - Mark links verified +- `getLeaderboardAction` - Fetch referral rankings + +### Utilities +- `validateZcashAddress()` - Full validation with type detection +- `buildZcashUri()` - Construct zcash: payment URIs +- `buildZcashEditMemo()` - Encode profile edits in memo + +## Zcash Address Types Supported +- **Unified (u1...)** - Recommended, privacy-preserving +- **Sapling (zs1...)** - Shielded pool +- **Transparent (t1.../t3...)** - Public (shown with warnings) +- **TEX (tex1...)** - Discouraged + +## Testing Harness +- No unit tests currently +- Server actions can be tested via API routes +- Validators are pure functions - easy to unit test + +## Database Access +All DB queries go through Supabase client in `/lib/supabase/`. +Main tables: `zcasher`, `zcasher_links`, `zcasher_searchable` + +## Adding New Logic +1. Create folder for feature domain +2. Add `types.ts` for interfaces +3. Add `actions.ts` for server actions (use 'use server') +4. Export from `index.ts` diff --git a/lib/api/AGENT.md b/lib/api/AGENT.md new file mode 100644 index 00000000..28fea168 --- /dev/null +++ b/lib/api/AGENT.md @@ -0,0 +1,92 @@ +# /lib/api - API Utilities + +## Purpose +Security utilities for API routes: rate limiting, API key validation, +and response formatting. + +## Key Files + +### guard.ts +API protection middleware: +```typescript +interface GuardOptions { + rateLimit?: { + window: number; // Time window in ms + maxRequests: number; // Max requests per window + }; + requireApiKey?: boolean; +} + +async function apiGuard( + request: Request, + options?: GuardOptions +): Promise<{ allowed: boolean; error?: string }> +``` + +**Rate Limiting:** +- Per-IP tracking +- Sliding window algorithm +- Returns 429 when exceeded + +**API Key Validation:** +```typescript +// Check header +const apiKey = request.headers.get('x-api-key'); +if (apiKey !== process.env.API_KEY) { + return { allowed: false, error: 'Invalid API key' }; +} +``` + +### types.ts +API response types: +```typescript +interface ApiResponse { + success: boolean; + data?: T; + error?: string; + meta?: { + cursor?: string; + total?: number; + }; +} +``` + +## Usage in API Routes + +```typescript +// app/api/directory/route.ts +import { apiGuard } from '@/lib/api/guard'; + +export async function GET(request: Request) { + const guard = await apiGuard(request, { + rateLimit: { window: 60000, maxRequests: 100 } + }); + + if (!guard.allowed) { + return Response.json( + { error: guard.error }, + { status: 429 } + ); + } + + // ... handle request +} +``` + +## Environment Variables +``` +API_KEY - Server-side API key for validation +NEXT_PUBLIC_API_KEY - Client-side (for authenticated requests) +``` + +## Testing Harness +- Mock time for rate limit tests +- Test various IP scenarios +- Verify API key validation +- Check response format consistency + +## Security Notes +- Never expose server API_KEY to client +- Rate limits apply per-IP +- Log suspicious activity +- Return generic errors (don't leak info) diff --git a/lib/api/types.ts b/lib/api/types.ts index d03b5bce..a5555d88 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -1,5 +1,4 @@ import type { Profile, ProfileLink } from "@/lib/profile/types"; -import type { City } from "@/lib/directory/types"; import type { Token } from "@/lib/swap/types"; /** @@ -64,11 +63,6 @@ export interface DirectoryResponse { } -/** - * Search cities response - */ -export type SearchCitiesResponse = APIResponse; - /** * Swap tokens response */ @@ -134,7 +128,6 @@ export interface CreateProfilePayload { bio?: string; address: string; avatar_url?: string; - nearest_city_id?: number | null; nearest_city_name?: string; referred_by?: string; referred_by_zcasher_id?: number; @@ -153,6 +146,7 @@ export type CreateProfileResponse = APIResponse; export interface ProfileLinkInput { label: string; url: string; + platform?: string; } /** @@ -212,6 +206,29 @@ export interface LinkVerificationUpdate { }; } +/** + * Profile edits payload for saving after OTP verification + */ +export interface ProfileEditsPayload { + name?: string; + display_name?: string; + bio?: string; + profile_image_url?: string; + nearest_city_name?: string; + links?: ProfileLinkEdit[]; +} + +/** + * Profile link edit (for insert/update/delete) + */ +export interface ProfileLinkEdit { + id?: number | null; + url: string; + label?: string; + platform?: string; + _delete?: boolean; +} + /** * Exchange rate response */ diff --git a/lib/directory/AGENT.md b/lib/directory/AGENT.md new file mode 100644 index 00000000..75a83925 --- /dev/null +++ b/lib/directory/AGENT.md @@ -0,0 +1,79 @@ +# /lib/directory - Profile Discovery + +## Purpose +Search and discovery logic for the zcash.me profile directory. +Powers the main search functionality and featured profiles. + +## Key Files + +### searchProfiles.ts +Profile search with ranking: +```typescript +async function searchProfiles(query: string, options?: { + limit?: number; // Default: 25 + cursor?: string; // Pagination + verifiedOnly?: boolean; +}): Promise<{ + results: Profile[]; + nextCursor?: string; + exists: boolean; +}> +``` + +**Ranking Logic:** +1. Username starts with query (highest) +2. Username contains query +3. Display name matches +4. Link text contains query + +### searchCities.ts +Geographic filtering: +```typescript +async function searchCities(query: string): Promise +``` +Used for location-based profile discovery. + +### fetchFeaturedProfiles.server.ts +Homepage featured profiles: +```typescript +async function fetchFeaturedProfiles(): Promise +``` +Returns profiles marked as `featured: true` in database. + +### getNsProfilesAction.ts +Network School directory: +```typescript +async function getNsProfilesAction(): Promise +``` +Filters by `is_ns`, `is_ns_core`, `is_ns_longterm` flags. + +### types.ts +```typescript +interface City { + id: string; + name: string; + country: string; + iso2: string; +} +``` + +## Search Features +- **Case-insensitive** - "Alice" = "alice" +- **Space-insensitive** - "alice z" matches "alicez" +- **Fuzzy matching** - Searches username, display name, links +- **Cursor pagination** - For infinite scroll + +## Database +Queries `zcasher_searchable` - denormalized table optimized for search: +- Pre-computed `link_search_text` +- Indexed for fast queries + +## Testing Harness +- Mock Supabase responses +- Test ranking logic with various queries +- Verify pagination cursor handling +- Test empty/no-result states + +## API Integration +Exposed via `/api/directory` endpoint. +Rate-limited via `/lib/api/guard.ts`. diff --git a/lib/directory/fetchFeaturedProfiles.server.ts b/lib/directory/fetchFeaturedProfiles.server.ts index 13db7a15..3a5cde39 100644 --- a/lib/directory/fetchFeaturedProfiles.server.ts +++ b/lib/directory/fetchFeaturedProfiles.server.ts @@ -30,7 +30,7 @@ export async function fetchFeaturedProfilesServer(limit: number = 6): Promise 0) { const { data: linksData } = await supabase .from("zcasher_links") - .select("id,label,url,is_verified,zcasher_id") + .select("id,label,url,platform,is_verified,zcasher_id") .in("zcasher_id", profileIds) .order("id", { ascending: true }); diff --git a/lib/directory/getNsProfilesAction.ts b/lib/directory/getNsProfilesAction.ts index 0d6581d2..81b7b0c2 100644 --- a/lib/directory/getNsProfilesAction.ts +++ b/lib/directory/getNsProfilesAction.ts @@ -48,13 +48,13 @@ export async function getNsProfilesAction(): Promise { const toKey = (v: number | string): string => String(v); const rankAll = new Map( - (lbAll as RankRow[] || []).map((r) => [toKey(r.referred_by_zcasher_id), r.rank_alltime || 0]) + ((lbAll as RankRow[]) ?? []).map((r) => [toKey(r.referred_by_zcasher_id), r.rank_alltime ?? 0]) ); const rankWeek = new Map( - (lbWeek as RankRow[] || []).map((r) => [toKey(r.referred_by_zcasher_id), r.rank_weekly || 0]) + ((lbWeek as RankRow[]) ?? []).map((r) => [toKey(r.referred_by_zcasher_id), r.rank_weekly ?? 0]) ); const rankMonth = new Map( - (lbMonth as RankRow[] || []).map((r) => [toKey(r.referred_by_zcasher_id), r.rank_monthly || 0]) + ((lbMonth as RankRow[]) ?? []).map((r) => [toKey(r.referred_by_zcasher_id), r.rank_monthly ?? 0]) ); // Fetch all profiles with pagination @@ -74,8 +74,8 @@ export async function getNsProfilesAction(): Promise { break; } - all = all.concat(data || []); - total = count || total; + all = all.concat(data ?? []); + total = count ?? total; if (!data?.length || all.length >= total) break; from += pageSize; @@ -91,9 +91,9 @@ export async function getNsProfilesAction(): Promise { return { ...p, - rank_alltime: rankAll.get(pid) || 0, - rank_weekly: rankWeek.get(pid) || 0, - rank_monthly: rankMonth.get(pid) || 0, + rank_alltime: rankAll.get(pid) ?? 0, + rank_weekly: rankWeek.get(pid) ?? 0, + rank_monthly: rankMonth.get(pid) ?? 0, links: linkList, verified_links_count: linkVerifiedCount, }; diff --git a/lib/directory/searchCities.ts b/lib/directory/searchCities.ts deleted file mode 100644 index 862b2fd1..00000000 --- a/lib/directory/searchCities.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; -import type { City } from "@/lib/directory/types"; - -export async function searchCities(query: string): Promise { - const supabase = createSupabaseServerClient(); - if (!supabase) return []; - - const { data, error } = await supabase - .from("worldcities") - .select("id, city_ascii, city, admin_name, country") - .ilike("city_ascii", `%${query}%`) - .limit(20); - if (error) return []; - return data || []; -} diff --git a/lib/directory/searchCitiesAction.ts b/lib/directory/searchCitiesAction.ts index a9cdb061..00fb35e5 100644 --- a/lib/directory/searchCitiesAction.ts +++ b/lib/directory/searchCitiesAction.ts @@ -1,19 +1,29 @@ "use server"; -import { searchCities } from "@/lib/directory/searchCities"; -import type { SearchCitiesResponse } from "@/lib/api/types"; +import cityTimezones from "city-timezones"; +import type { APIResponse } from "@/lib/api/types"; -/** - * Server Action for searching cities - * Used by CitySearchDropdown component - */ -export async function searchCitiesAction(query: string): Promise { +export interface City { + city: string; + city_ascii: string; + admin_name: string; + country: string; +} + +export async function searchCitiesAction(query: string): Promise> { try { if (!query || typeof query !== "string" || query.trim().length < 2) { return { ok: true, data: [] }; } - const data = await searchCities(query.trim()); + const results = cityTimezones.findFromCityStateProvince(query.trim()); + const data: City[] = results.slice(0, 20).map((r: any) => ({ + city: r.city, + city_ascii: r.city, + admin_name: r.province || "", + country: r.country, + })); + return { ok: true, data }; } catch (error) { return { ok: false, error: String((error as Error)?.message || error), data: [] }; diff --git a/lib/directory/searchProfiles.ts b/lib/directory/searchProfiles.ts deleted file mode 100644 index bfe5f3dc..00000000 --- a/lib/directory/searchProfiles.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; - -/** - * Check if a username exists (exact match, case-insensitive) - */ -export async function checkUsernameExists(username: string): Promise { - if (!username || !username.trim()) return false; - - const supabase = createSupabaseServerClient(); - if (!supabase) return false; - - const q = username.trim().toLowerCase(); - - const { data, error } = await supabase - .from("zcasher_searchable") - .select("id") - .ilike("name", q) - .limit(1); - - if (error) { - return false; - } - - return data && data.length > 0; -} diff --git a/lib/directory/types.ts b/lib/directory/types.ts deleted file mode 100644 index 95745c3a..00000000 --- a/lib/directory/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * City entity used by the directory search flow - */ -export interface City { - id: number; - city: string; - city_ascii: string; - admin_name: string; - country: string; -} diff --git a/lib/leaderboard/AGENT.md b/lib/leaderboard/AGENT.md new file mode 100644 index 00000000..31ca9a0f --- /dev/null +++ b/lib/leaderboard/AGENT.md @@ -0,0 +1,77 @@ +# /lib/leaderboard - Referral System + +## Purpose +Referral commission tracking and leaderboard calculations. +Rewards users for bringing new profiles to zcash.me. + +## Key File + +### getLeaderboardAction.ts +Server action for leaderboard data: +```typescript +'use server' +export async function getLeaderboardAction(): Promise<{ + leaders: LeaderEntry[]; + userRank?: number; + userStats?: UserStats; +}> +``` + +## Data Model + +### LeaderEntry +```typescript +interface LeaderEntry { + profileId: string; + username: string; + displayName: string; + avatarUrl?: string; + referralCount: number; + totalCommission: number; // In ZEC + rank: number; +} +``` + +### Commission Tiers +Multi-tier referral system: +- **Direct referrals**: Higher commission +- **Second-tier**: Smaller percentage +- **Eligibility window**: Time-limited earning period + +## Calculation Logic + +``` +User A refers User B + ↓ +User B creates profile + ↓ +User B receives payments + ↓ +User A earns X% commission on payments + ↓ +Tracked in leaderboard +``` + +## Database Fields +Profiles have referral tracking fields: +- `referred_by` - Profile ID of referrer +- `referral_code` - Unique code for sharing +- `commission_earned` - Total ZEC earned + +## Zcash Integration +- Commissions paid in ZEC +- Tracked via transaction memos +- Settlement to referrer's Zcash address + +## Testing Harness +- Mock referral chains +- Test commission calculations +- Verify ranking logic +- Test edge cases (self-referral, expired windows) + +## UI Integration +Displayed in `/app/leader-app` using data from this action. + +## Network School +NS members may have special referral bonuses tracked via +`is_ns_core` and `is_ns_longterm` flags. diff --git a/lib/leaderboard/getLeaderboardAction.ts b/lib/leaderboard/getLeaderboardAction.ts index 34174fb6..a60de9f1 100644 --- a/lib/leaderboard/getLeaderboardAction.ts +++ b/lib/leaderboard/getLeaderboardAction.ts @@ -216,19 +216,19 @@ export async function getLeaderboardAction( // Build referrer names map const referrerNames = new Map( - (referrers || []).map((r) => [r.id, r.name || `User ${r.id}`]) + (referrers ?? []).map((r) => [r.id, r.name ?? `User ${r.id}`]) ); // Build verified links count map const verifiedLinksMap = new Map(); const pendingLinksMap = new Map(); - for (const link of allLinks || []) { + for (const link of allLinks ?? []) { const id = link.zcasher_id; if (link.is_verified) { - verifiedLinksMap.set(id, (verifiedLinksMap.get(id) || 0) + 1); + verifiedLinksMap.set(id, (verifiedLinksMap.get(id) ?? 0) + 1); } else if (link.pending_verif) { - pendingLinksMap.set(id, (pendingLinksMap.get(id) || 0) + 1); + pendingLinksMap.set(id, (pendingLinksMap.get(id) ?? 0) + 1); } } @@ -261,11 +261,11 @@ export async function getLeaderboardAction( } } - const referrerVerifiedLinks = verifiedLinksMap.get(referrerId) || 0; - const referrerPendingLinks = pendingLinksMap.get(referrerId) || 0; + const referrerVerifiedLinks = verifiedLinksMap.get(referrerId) ?? 0; + const referrerPendingLinks = pendingLinksMap.get(referrerId) ?? 0; - const current = referrerStats.get(referrerId) || { - name: referrerNames.get(referrerId) || `User ${referrerId}`, + const current = referrerStats.get(referrerId) ?? { + name: referrerNames.get(referrerId) ?? `User ${referrerId}`, total: 0, verified: 0, unverified: 0, diff --git a/lib/profile/AGENT.md b/lib/profile/AGENT.md new file mode 100644 index 00000000..7dcf0fc3 --- /dev/null +++ b/lib/profile/AGENT.md @@ -0,0 +1,75 @@ +# /lib/profile - Profile Management + +## Purpose +Core profile logic: types, fetching, username validation, link handling, verification. +Central to the zcash.me identity system. + +## Key Files + +### types.ts +```typescript +interface Profile { + id: string; + name: string; // username (normalized) + display_name: string; // shown in UI + slug: string; // URL path + bio?: string; + address: string; // Zcash address + address_verified: boolean; + avatar_url?: string; + verified_links_count: number; + is_ns?: boolean; // Network School member +} + +interface ProfileLink { + id: string; + provider: string; // 'twitter', 'github', etc. + value: string; // handle or URL + verified: boolean; + verified_at?: string; +} +``` + +### profileFetcher.ts +Database queries for profile retrieval. Uses Supabase client. + +### usernamePolicy.ts +Username validation rules: +- Min/max length (3-30 chars) +- Allowed characters (alphanumeric, underscore) +- Reserved words blocked +- Profanity filter + +### usernameNormalizer.ts +Unicode normalization and sanitization: +- Lowercase conversion +- Diacritic removal +- Homoglyph normalization (prevent impersonation) + +### profileLinks.ts +Link enrichment utilities: +- Icon resolution by provider +- Label formatting +- URL construction + +### social-lookup.ts +Social platform detection from URLs/handles. +Maps input to canonical provider names. + +## Zcash Integration +- `address` field stores Zcash unified/sapling address +- `address_verified` confirms on-chain proof of ownership +- Links can be verified via blockchain transaction + +## Testing Harness +- `usernamePolicy` and `usernameNormalizer` are pure functions +- Mock Supabase client for `profileFetcher` tests +- Example: +```typescript +expect(normalizeUsername('Álice')).toBe('alice'); +expect(isValidUsername('__admin__')).toBe(false); +``` + +## Database Tables +- `zcasher` - Main profile records +- `zcasher_links` - Associated links diff --git a/lib/profile/accountAuthFlow.ts b/lib/profile/accountAuthFlow.ts deleted file mode 100644 index 6612b3c5..00000000 --- a/lib/profile/accountAuthFlow.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { supabase } from "@/lib/supabase/supabase-client"; -import { buildSlug } from "@/lib/profile/profileUtils"; -import type { - Profile, - ProfileLink, - PendingEdits, -} from "@/lib/profile/types"; - -interface AuthProvider { - key: "twitter" | "linkedin_oidc" | "github" | "discord"; - label: string; - match: RegExp; - includeStateParams: boolean; -} - -const AUTH_PROVIDERS: AuthProvider[] = [ - { - key: "twitter", - label: "X.com", - match: /^(https?:\/\/)?(www\.)?(x\.com|twitter\.com)\//i, - includeStateParams: false, - }, - { - key: "linkedin_oidc", - label: "LinkedIn", - match: /^(https?:\/\/)?(www\.)?linkedin\.com\/in\//i, - includeStateParams: true, - }, - { - key: "github", - label: "GitHub", - match: /^(https?:\/\/)?(www\.)?github\.com\//i, - includeStateParams: true, - }, - { - key: "discord", - label: "Discord", - match: /^(https?:\/\/)?(www\.)?(discord\.com|discordapp\.com)\/users\//i, - includeStateParams: true, - }, -]; - -const buildReturnUrl = (profile: Partial | undefined, url: string, includeStateParams: boolean): string => { - if (typeof window === "undefined") return ""; - const slug = buildSlug(profile); - if (!slug) return ""; - const returnUrlObj = new URL(`${window.location.origin}/${slug}`); - if (includeStateParams) { - returnUrlObj.searchParams.set("verify_pid", String(profile?.id ?? "")); - returnUrlObj.searchParams.set("verify_url", url); - } - return returnUrlObj.toString(); -}; - -const storeVerificationContext = (profileId: number | string | undefined, url: string): void => { - if (typeof localStorage === "undefined") return; - localStorage.setItem("verifying_profile_id", String(profileId ?? "")); - localStorage.setItem("verifying_link_url", url); -}; - -export const getAuthProviderForUrl = (url: string | undefined): AuthProvider | null => { - const trimmed = (url || "").trim(); - if (!trimmed) return null; - return AUTH_PROVIDERS.find((provider) => provider.match.test(trimmed)) || null; -}; - -export const getLinkAuthToken = (link: Partial | null | undefined): string | null => { - if (!link) return null; - if (link.id) return `!${link.id}`; - const trimmed = (link.url || "").trim(); - return trimmed ? `+!${trimmed}` : null; -}; - -export const isLinkAuthPending = (pendingEdits: PendingEdits | null | undefined, token: string | null): boolean => - Array.isArray(pendingEdits?.l) && !!token && pendingEdits.l.includes(token); - -interface StartOAuthParams { - providerKey: string; - profile: Partial | undefined; - url: string; - setShowRedirect?: (show: boolean) => void; - setRedirectLabel?: (label: string) => void; -} - -interface OAuthResult { - status: "unknown_provider" | "missing_return" | "redirect" | "error"; -} - -export const startOAuthVerification = async ({ - providerKey, - profile, - url, - setShowRedirect, - setRedirectLabel, -}: StartOAuthParams): Promise => { - const provider = AUTH_PROVIDERS.find((p) => p.key === providerKey); - if (!provider) return { status: "unknown_provider" }; - - if (typeof setShowRedirect === "function") setShowRedirect(true); - if (typeof setRedirectLabel === "function") setRedirectLabel(provider.label); - storeVerificationContext(profile?.id, url); - - const returnUrl = buildReturnUrl(profile, url, provider.includeStateParams); - if (!returnUrl) { - if (typeof setShowRedirect === "function") setShowRedirect(false); - return { status: "missing_return" }; - } - - await new Promise((resolve) => setTimeout(resolve, 1500)); - - try { - const { error } = await supabase.auth.signInWithOAuth({ - provider: provider.key as any, - options: { - redirectTo: returnUrl, - skipBrowserRedirect: false, - }, - }); - if (error) throw error; - return { status: "redirect" }; - } catch (error) { - if (typeof setShowRedirect === "function") setShowRedirect(false); - alert("Verification failed: " + ((error as Error).message || "Unknown error")); - return { status: "error" }; - } -}; diff --git a/lib/profile/getProfileLinksBatchAction.ts b/lib/profile/getProfileLinksBatchAction.ts index b5bd7e9d..5a960c65 100644 --- a/lib/profile/getProfileLinksBatchAction.ts +++ b/lib/profile/getProfileLinksBatchAction.ts @@ -21,7 +21,7 @@ export async function getProfileLinksBatchAction(zcasherIds: number[]): Promise< const { data, error } = await supabase .from("zcasher_links") - .select("id,label,url,is_verified,zcasher_id") + .select("id,label,url,platform,is_verified,zcasher_id") .in("zcasher_id", zcasherIds); if (error) { diff --git a/lib/profile/profileFetcher.ts b/lib/profile/profileFetcher.ts index 3dc1d315..fba9a072 100644 --- a/lib/profile/profileFetcher.ts +++ b/lib/profile/profileFetcher.ts @@ -17,9 +17,9 @@ interface RankData { const mergeRanks = (profile: Profile, ranks: RankData): Profile => ({ ...profile, - rank_alltime: ranks.rank_alltime || 0, - rank_weekly: ranks.rank_weekly || 0, - rank_monthly: ranks.rank_monthly || 0, + rank_alltime: ranks.rank_alltime ?? 0, + rank_weekly: ranks.rank_weekly ?? 0, + rank_monthly: ranks.rank_monthly ?? 0, }); async function findProfileByName(supabase: any, name: string): Promise { @@ -30,9 +30,9 @@ async function findProfileByName(supabase: any, name: string): Promise normalize(p.name || "") === normalize(name) + (p: Profile) => normalize(p.name ?? "") === normalize(name) ); if (!matching.length) return null; @@ -47,7 +47,7 @@ export async function fetchProfileForSlug(rawSlug: string): Promise | null; error: unknown }; + type LinksResult = { data: Array<{ id: number; label?: string; url: string; platform?: string; is_verified: boolean; zcasher_id: number }> | null; error: unknown }; const [alltime, weekly, monthly, links]: [RankResult, WeeklyRankResult, MonthlyRankResult, LinksResult] = await Promise.all([ supabase @@ -121,19 +121,19 @@ export async function fetchProfileForSlug(rawSlug: string): Promise { - const domain = extractDomain(url || ""); + const domain = extractDomain(url ?? ""); const entry = KNOWN_DOMAINS[domain]; - return entry?.icon || FALLBACK_ICON; + return entry?.icon ?? FALLBACK_ICON; }; export const getLinkLabel = (url: string = ""): string => { - const domain = extractDomain(url || ""); + const domain = extractDomain(url ?? ""); const entry = KNOWN_DOMAINS[domain]; - return entry?.label || domain || "Link"; + return entry?.label ?? domain ?? "Link"; }; type SocialPlatform = "X" | "GitHub" | "Instagram" | "Reddit" | "LinkedIn" | "Discord" | "TikTok" | "Bluesky" | "Mastodon" | "Snapchat" | "Telegram"; -const PLATFORM_BY_DOMAIN: Record = { +export const PLATFORM_BY_DOMAIN: Record = { "x.com": "X", "twitter.com": "X", "github.com": "GitHub", @@ -150,33 +150,38 @@ const PLATFORM_BY_DOMAIN: Record = { "telegram.me": "Telegram", } as const; -export const getSocialHandle = (url: string = ""): string => { - const trimmed = (url || "").trim(); +/** + * Derive the platform label from a URL using PLATFORM_BY_DOMAIN. + * Returns "Other" if the domain is not recognized. + */ +export function derivePlatform(url: string): string { + const domain = extractDomain(url); + return PLATFORM_BY_DOMAIN[domain] ?? "Other"; +} + +export const getSocialHandle = (url: string = "", platform?: string | null): string => { + const trimmed = (url ?? "").trim(); if (!trimmed) return ""; - const domain = extractDomain(trimmed); - const platform = PLATFORM_BY_DOMAIN[domain] || null; - if (platform) { - return normalizeSocialUsername(trimmed, platform); + if (platform && platform !== "Other") { + return normalizeSocialUsername(trimmed, platform as import("@/lib/profile/usernameNormalizer").SocialPlatform); } const cleaned = trimmed.split("#")[0].split("?")[0].replace(/\/+$/, ""); const parts = cleaned.split("/"); - const last = parts[parts.length - 1] || ""; + const last = parts[parts.length - 1] ?? ""; return decodeURIComponent(last); }; -export const isDiscordLink = (url: string = ""): boolean => - /^(https?:\/\/)?(www\.)?(discord\.com|discordapp\.com|discord\.gg)\//i.test( - url || "" - ); +export const isDiscordLink = (platform?: string | null): boolean => + platform === "Discord"; export const getSocialDisplay = (link: ProfileLink): string => { if (!link) return ""; - if (isDiscordLink(link.url) && link.is_verified && link.label) { + if (link.platform === "Discord" && link.is_verified && link.label) { return link.label; } - return getSocialHandle(link.url || ""); + return getSocialHandle(link.url ?? "", link.platform); }; @@ -185,15 +190,15 @@ export const getSocialDisplay = (link: ProfileLink): string => { */ export function enrichLink(link: ProfileLink): EnrichedProfileLink { const domain = extractDomain(link.url); - const dbLabel = (link.label || "").trim(); - const handle = getSocialHandle(link.url || ""); - const normalizedDomain = (domain || "").toLowerCase(); - const normalizedHandle = (handle || "").toLowerCase(); + const dbLabel = (link.label ?? "").trim(); + const handle = getSocialHandle(link.url ?? "", link.platform); + const normalizedDomain = (domain ?? "").toLowerCase(); + const normalizedHandle = (handle ?? "").toLowerCase(); const normalizedLabel = dbLabel.toLowerCase(); const isHandleDomain = normalizedHandle === normalizedDomain || normalizedHandle === `www.${normalizedDomain}`; - const domainLabel = (KNOWN_DOMAINS[domain]?.label || "").toLowerCase(); + const domainLabel = (KNOWN_DOMAINS[domain]?.label ?? "").toLowerCase(); const shouldUseHandle = !!handle && !isHandleDomain && @@ -204,25 +209,29 @@ export function enrichLink(link: ProfileLink): EnrichedProfileLink { normalizedLabel.startsWith(`${normalizedDomain}/`) || normalizedLabel.startsWith(`www.${normalizedDomain}/`)); + const platform = link.platform ?? null; + if (KNOWN_DOMAINS[domain]) { return { ...link, - label: (shouldUseHandle ? handle : dbLabel) || KNOWN_DOMAINS[domain].label, + label: (shouldUseHandle ? handle : dbLabel) ?? KNOWN_DOMAINS[domain].label, icon: KNOWN_DOMAINS[domain].icon, domain, handle, + platform, }; } return { ...link, label: - (shouldUseHandle ? handle : dbLabel) || - domain || + (shouldUseHandle ? handle : dbLabel) ?? + domain ?? "Unknown", icon: FALLBACK_ICON, domain, handle, + platform, }; } diff --git a/lib/profile/profileQueries.ts b/lib/profile/profileQueries.ts index 8128a7e1..141665cf 100644 --- a/lib/profile/profileQueries.ts +++ b/lib/profile/profileQueries.ts @@ -16,7 +16,7 @@ export async function getProfileCount(): Promise { return 0; } - return count || 0; + return count ?? 0; } export interface UsernameAvailability { @@ -49,7 +49,7 @@ export async function getUsernameAvailability( } const matches = data.filter((row: { name?: string }) => - normalizeUsernameForCompare(row.name || "") === compareTarget + normalizeUsernameForCompare(row.name ?? "") === compareTarget ); const exists = matches.length > 0; const verifiedMatches = matches.filter((row: { address_verified?: boolean }) => @@ -83,5 +83,5 @@ export async function getDuplicateNameCount(name: string): Promise { return 0; } - return count || 0; + return count ?? 0; } diff --git a/lib/profile/providerAvatars.ts b/lib/profile/providerAvatars.ts deleted file mode 100644 index 0c931f96..00000000 --- a/lib/profile/providerAvatars.ts +++ /dev/null @@ -1,252 +0,0 @@ -// lib/social/providerAvatars.ts - -import { normalizeSocialUsername } from "@/lib/profile/usernameNormalizer"; -import { isValidUrl } from "@/lib/validation/validators"; -import { getSession } from "@/lib/supabase/auth"; -import type { Session } from "@supabase/supabase-js"; - -export const normalizeHandleKey = (value: string | null | undefined): string => - (value || "") - .trim() - .replace(/["'\\]+/g, "") - .toLowerCase(); - -export const normalizeDiscordHandle = (value: string | null | undefined): string => - (value || "") - .trim() - .replace(/["'\\]+/g, "") - .replace(/^@/, "") - .replace(/#0$/, "") - .toLowerCase(); - -export const upgradeXAvatarUrl = (url: string | null | undefined): string | null | undefined => { - if (!url || typeof url !== "string") return url; - const trimmed = url.trim().replace(/^,+/, ""); - const withName = trimmed.replace(/([?&])name=normal\b/i, "$1name=original"); - return withName.replace(/_(normal|bigger|mini)(\.[a-z0-9]+)(\?.*)?$/i, "$2$3"); -}; - -export const buildDiscordAvatarUrl = (id: string | null | undefined, avatar: string | null | undefined): string | null => { - if (!id || !avatar) return null; - return `https://cdn.discordapp.com/avatars/${id}/${avatar}.png?size=4096`; -}; - -export const parseXHandleFromUrl = (rawUrl: string | null | undefined): string | null => { - const m = (rawUrl || "").replace(/\/$/, "").match(/(?:x\.com|twitter\.com)\/([^/?#]+)/i); - return m ? m[1].trim() : null; -}; - -export const parseGithubHandleFromUrl = (rawUrl: string | null | undefined): string | null => { - const m = (rawUrl || "").replace(/\/$/, "").match(/github\.com\/([^/?#]+)/i); - return m ? m[1].trim() : null; -}; - -export const parseDiscordTargetFromUrl = (rawUrl: string | null | undefined): string | null => { - const m = (rawUrl || "").replace(/\/$/, "").match(/(?:discord\.com|discordapp\.com)\/users\/([^/?#]+)/i); - return m ? decodeURIComponent(m[1]) : null; -}; - -export function getXHandle(session: Session | null | undefined): string | null { - const identity = session?.user?.identities?.find?.((i) => i?.provider === "twitter"); - const username = (identity?.identity_data as any)?.username; - return username ? String(username).replace(/^@/, "") : null; -} - -export function getGithubHandle(session: Session | null | undefined): string | null { - const identity = session?.user?.identities?.find?.((i) => i?.provider === "github"); - const login = (identity?.identity_data as any)?.login; - return login ? String(login).replace(/^@/, "") : null; -} - -export function getDiscordId(session: Session | null | undefined): string | null { - const identity = session?.user?.identities?.find?.((i) => i?.provider === "discord"); - const id = (identity?.identity_data as any)?.id; - return id ? String(id).trim() : null; -} - -export async function getDiscordUsername(session: Session | null | undefined): Promise { - const identity = session?.user?.identities?.find?.((i) => i?.provider === "discord"); - const data = (identity?.identity_data as any) || {}; - const username = data.username; - const discriminator = data.discriminator; - - if (username) { - if (discriminator && String(discriminator) !== "0") { - return `${username}#${discriminator}`; - } - return username; - } - - return null; -} - -export function getXAvatarUrl(session: Session | null | undefined): string | null | undefined { - const identity = session?.user?.identities?.find?.((i) => i?.provider === "twitter"); - const url = (identity?.identity_data as any)?.profile_image_url_https; - return url ? upgradeXAvatarUrl(String(url).trim()) : null; -} - -export async function getGithubAvatarUrl(session: Session | null | undefined): Promise { - const identity = session?.user?.identities?.find?.((i) => i?.provider === "github"); - const url = (identity?.identity_data as any)?.avatar_url; - return url ? String(url).trim() : null; -} - -export async function getDiscordAvatarUrl(session: Session | null | undefined): Promise { - const identity = session?.user?.identities?.find?.((i) => i?.provider === "discord"); - const data = (identity?.identity_data as any) || {}; - const id = data.id; - const avatar = data.avatar; - return buildDiscordAvatarUrl(id, avatar); -} - -interface ProviderCallbacks { - setAvatarPrompt: (prompt: { provider: string; url: string }) => void; - setDeletedFields: (fn: (prev: Record) => Record) => void; - handleChange: (field: string, value: string) => void; -} - -export async function applyProviderAvatar( - provider: string, - url: string, - callbacks: ProviderCallbacks -): Promise { - const { setAvatarPrompt, setDeletedFields, handleChange } = callbacks; - - const applyResult = (nextUrl: string | null | undefined) => { - if (!nextUrl) { - setAvatarPrompt({ provider, url }); - return; - } - setDeletedFields((prev) => ({ ...prev, profile_image_url: false })); - handleChange("profile_image_url", nextUrl); - }; - - if (provider === "Discord") { - const target = parseDiscordTargetFromUrl(url); - const targetKey = normalizeHandleKey(normalizeDiscordHandle(target)); - if (!targetKey) { setAvatarPrompt({ provider, url }); return; } - - const { data: { session } } = await getSession(); - let nextUrl: string | null = null; - if (session) { - const discordId = getDiscordId(session); - const discordUsername = await getDiscordUsername(session); - const isNumericTarget = /^[0-9]+$/.test(targetKey); - const usernameCandidates = [discordUsername] - .filter(Boolean) - .flatMap((name) => { - const normalized = normalizeDiscordHandle(name!); - const base = normalized.replace(/#\d+$/, ""); - return [normalized, base].filter(Boolean); - }); - const match = isNumericTarget - ? !!discordId && String(discordId) === String(targetKey) - : usernameCandidates.includes(targetKey); - - if (!match) { setAvatarPrompt({ provider, url }); return; } - - nextUrl = await getDiscordAvatarUrl(session); - } - applyResult(nextUrl); - } else if (provider === "X") { - const target = parseXHandleFromUrl(url); - const targetKey = normalizeHandleKey(target); - if (!targetKey) { setAvatarPrompt({ provider, url }); return; } - - const { data: { session } } = await getSession(); - let nextUrl: string | null | undefined = null; - if (session) { - const xHandle = getXHandle(session); - if (!xHandle || normalizeHandleKey(xHandle) !== targetKey) { - setAvatarPrompt({ provider, url }); - return; - } - nextUrl = getXAvatarUrl(session); - } - applyResult(nextUrl); - } else if (provider === "GitHub") { - const target = parseGithubHandleFromUrl(url); - const targetKey = normalizeHandleKey(target); - if (!targetKey) { setAvatarPrompt({ provider, url }); return; } - - let nextUrl: string | null = null; - try { - const res = await fetch(`https://api.github.com/users/${encodeURIComponent(targetKey)}`); - if (res.ok) { - const data = await res.json(); - nextUrl = data?.avatar_url || null; - } - } catch { - // Ignore fetch errors - } - applyResult(nextUrl); - } -} - -import { HOSTS } from "@/lib/profile/usernameNormalizer"; - -export function detectPlatformFromUrl(rawUrl: string | null | undefined): string | null { - const trimmed = (rawUrl || "").trim(); - if (!trimmed) return null; - - const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; - - try { - const url = new URL(normalized); - const host = url.hostname.toLowerCase(); - for (const [platform, hosts] of Object.entries(HOSTS)) { - if ((hosts as string[]).includes(host)) return platform; - } - } catch { - return null; - } - - return null; -} - -export function parseSocialUrl(rawUrl: string | null | undefined): { platform: string; username: string; otherUrl: string } { - const trimmed = (rawUrl || "").trim(); - if (!trimmed) { - return { platform: "X", username: "", otherUrl: "" }; - } - - const platform = detectPlatformFromUrl(trimmed); - if (!platform) { - return { platform: "Other", username: "", otherUrl: trimmed }; - } - - return { - platform, - username: normalizeSocialUsername(trimmed, platform as any), - otherUrl: "", - }; -} - -export function isValidImageUrl(url: string | null | undefined): { valid: boolean; reason: string | null } { - if (!url) return { valid: true, reason: null }; - - const trimmed = url.trim(); - const { valid } = isValidUrl(trimmed); - if (!valid) { - return { valid: false, reason: "Invalid URL format" }; - } - - const hasImageExt = /\.(png|jpg)(\?.*)?$/i.test(trimmed); - let isGithubAvatar = false; - if (!hasImageExt) { - try { - const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; - const u = new URL(normalized); - isGithubAvatar = u.hostname.toLowerCase() === "avatars.githubusercontent.com"; - } catch { - isGithubAvatar = false; - } - } - - if (!hasImageExt && !isGithubAvatar) { - return { valid: false, reason: "Image URL must end in .png or .jpg" }; - } - - return { valid: true, reason: null }; -} diff --git a/lib/profile/social-lookup.ts b/lib/profile/social-lookup.ts deleted file mode 100644 index 95525038..00000000 --- a/lib/profile/social-lookup.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { createSupabaseServerClient } from "../supabase/supabase-server"; - -type PlatformLabel = "X" | "GitHub" | "Instagram" | "Reddit" | "LinkedIn" | "Discord" | "TikTok" | "Bluesky" | "Mastodon" | "Snapchat" | "Telegram"; - -const HOSTS_MAP: Record = { - X: ["x.com", "twitter.com", "www.x.com", "www.twitter.com"], - GitHub: ["github.com", "www.github.com"], - Instagram: ["instagram.com", "www.instagram.com"], - Reddit: ["reddit.com", "www.reddit.com"], - LinkedIn: ["linkedin.com", "www.linkedin.com"], - Discord: [ - "discord.com", - "www.discord.com", - "discordapp.com", - "www.discordapp.com", - "discord.gg", - "www.discord.gg" - ], - TikTok: ["tiktok.com", "www.tiktok.com"], - Bluesky: ["bsky.app"], - Mastodon: ["mastodon.social"], - Snapchat: ["snapchat.com", "www.snapchat.com"], - Telegram: ["t.me", "www.t.me", "telegram.me", "www.telegram.me"], -}; - -const normalizeSocialUsername = (raw: string = "", platform: PlatformLabel): string => { - let v = raw.normalize("NFKC").trim(); - - // Strip protocol - v = v.replace(/^https?:\/\//i, ""); - - // Strip leading @ - v = v.replace(/^@+/, ""); - - // Strip quotes/backslashes that often come from pasted JSON or escaped strings - v = v.replace(/["'\\]+/g, ""); - - // Strip known platform domains - const hosts = HOSTS_MAP[platform]; - if (hosts) { - for (const h of hosts) { - if (v.startsWith(h)) { - v = v.slice(h.length); - } - } - } - - // Remove common path prefixes - v = v.replace(/^\/+/, ""); - v = v.replace(/^(user|users|in|profile|add)\//, ""); - - // Strip query strings and fragments - v = v.split("?")[0].split("#")[0]; - - // Remove embedded mobile subdomains pasted into path - v = v.replace(/^(mobile\.|m\.)?(x\.com|twitter\.com)\//i, ""); - - // Keep only first path segment (no /status/, /reels/, etc) - v = v.split("/")[0]; - - // Remove trailing slashes - v = v.replace(/\/+$/, ""); - - // Block spaces and @ entirely - v = v.replace(/[@\s]/g, ""); - - return v; -}; - -interface PlatformConfig { - label: PlatformLabel; - hosts: string[]; - includeNumericId?: boolean; -} - -interface PlatformAlias { - alias: string; -} - -type PlatformConfigEntry = PlatformConfig | PlatformAlias; - -const PLATFORM_CONFIG: Record = { - x: { - label: "X", - hosts: ["x.com", "twitter.com", "www.x.com", "www.twitter.com"], - includeNumericId: true, - }, - twitter: { - alias: "x", - }, - github: { - label: "GitHub", - hosts: ["github.com", "www.github.com"], - }, - instagram: { - label: "Instagram", - hosts: ["instagram.com", "www.instagram.com"], - }, - reddit: { - label: "Reddit", - hosts: ["reddit.com", "www.reddit.com"], - }, - linkedin: { - label: "LinkedIn", - hosts: ["linkedin.com", "www.linkedin.com"], - }, - discord: { - label: "Discord", - hosts: [ - "discord.com", - "www.discord.com", - "discordapp.com", - "www.discordapp.com", - "discord.gg", - "www.discord.gg", - ], - }, - tiktok: { - label: "TikTok", - hosts: ["tiktok.com", "www.tiktok.com"], - }, - bluesky: { - label: "Bluesky", - hosts: ["bsky.app"], - }, - mastodon: { - label: "Mastodon", - hosts: ["mastodon.social"], - }, - snapchat: { - label: "Snapchat", - hosts: ["snapchat.com", "www.snapchat.com"], - }, - telegram: { - label: "Telegram", - hosts: ["t.me", "www.t.me", "telegram.me", "www.telegram.me"], - }, -}; - -const resolvePlatformConfig = (platform: string = ""): PlatformConfig | null => { - const key = String(platform || "").trim().toLowerCase(); - const config = PLATFORM_CONFIG[key]; - if (!config) return null; - if ("alias" in config) return (PLATFORM_CONFIG[config.alias] as PlatformConfig) || null; - return config as PlatformConfig; -}; - -const normalizeHandle = (raw: string = "", config: PlatformConfig): string => - normalizeSocialUsername(decodeURIComponent(raw || "").trim(), config.label); - -const buildUrlPatterns = (handle: string, config: PlatformConfig): string[] => { - const patterns = (config.hosts || []).map((host) => `%${host}/${handle}%`); - if (config.includeNumericId) { - patterns.push(`%/i/user/${handle}%`, `%/user/${handle}%`); - } - return patterns; -}; - -interface ZcasherLink { - id: number; - zcasher_id: number; - label: string; - url: string; - is_verified: boolean; -} - -interface ZcasherProfile { - id: number; - address: string; - name: string; - address_verified: boolean; -} - -interface Candidate { - link: ZcasherLink; - profile: ZcasherProfile; -} - -const pickBestCandidate = (candidates: Candidate[]): Candidate | null => { - if (!candidates.length) return null; - return candidates - .slice() - .sort((a, b) => { - const scoreA = - (a.link.is_verified ? 2 : 0) + (a.profile.address_verified ? 1 : 0); - const scoreB = - (b.link.is_verified ? 2 : 0) + (b.profile.address_verified ? 1 : 0); - if (scoreA !== scoreB) return scoreB - scoreA; - return a.profile.id - b.profile.id; - })[0]; -}; - -interface LookupError { - status: 400 | 404 | 500; - body: { - address: null; - handle: string | null; - error: string; - }; -} - -interface LookupSuccess { - status: 200; - body: { - link: { - platform: string; - handle: string; - url: string; - is_verified: boolean; - }; - address: string; - profile_name: string; - address_verified: boolean; - }; -} - -type LookupResult = LookupError | LookupSuccess; - -export async function lookupSocialAddress(platform: string, rawHandle: string): Promise { - const config = resolvePlatformConfig(platform); - if (!config) { - return { - status: 400, - body: { address: null, handle: null, error: "unsupported_platform" }, - }; - } - - const handle = normalizeHandle(rawHandle, config); - if (!handle) { - return { - status: 400, - body: { address: null, handle: null, error: "invalid_handle" }, - }; - } - - const supabase = createSupabaseServerClient(); - - if (!supabase) { - return { - status: 500, - body: { address: null, handle, error: "server_misconfigured" }, - }; - } - - const urlPatterns = buildUrlPatterns(handle, config); - - const labelPromise = supabase - .from("zcasher_links") - .select("id,zcasher_id,label,url,is_verified") - .ilike("label", handle) - .limit(25); - - const urlPromise = urlPatterns.length - ? supabase - .from("zcasher_links") - .select("id,zcasher_id,label,url,is_verified") - .or(urlPatterns.map((p) => `url.ilike.${p}`).join(",")) - .limit(50) - : Promise.resolve({ data: [], error: null }); - - const [{ data: labelMatches, error: labelError }, { data: urlMatches, error: urlError }] = - await Promise.all([labelPromise, urlPromise]); - - if (labelError || urlError) { - return { - status: 500, - body: { address: null, handle, error: "lookup_failed" }, - }; - } - - const linksMap = new Map(); - (labelMatches || []).forEach((link) => linksMap.set(link.id, link as ZcasherLink)); - (urlMatches || []).forEach((link) => linksMap.set(link.id, link as ZcasherLink)); - const links = Array.from(linksMap.values()); - - if (!links.length) { - return { - status: 404, - body: { address: null, handle, error: "not_found" }, - }; - } - - const verifiedLinks = links.filter((link) => link.is_verified); - if (!verifiedLinks.length) { - return { - status: 404, - body: { address: null, handle, error: "not_verified" }, - }; - } - - const ids = Array.from( - new Set(verifiedLinks.map((link) => link.zcasher_id).filter(Boolean)) - ); - - const { data: profiles, error: profileError } = await supabase - .from("zcasher") - .select("id,address,name,address_verified") - .in("id", ids); - - if (profileError) { - return { - status: 500, - body: { address: null, handle, error: "profile_lookup_failed" }, - }; - } - - const profilesById = new Map( - (profiles || []).map((profile) => [profile.id, profile as ZcasherProfile]) - ); - - const candidates = verifiedLinks - .map((link) => ({ - link, - profile: profilesById.get(link.zcasher_id), - })) - .filter((entry): entry is Candidate => !!entry.profile?.address); - - const best = pickBestCandidate(candidates); - - if (!best) { - return { - status: 404, - body: { address: null, handle, error: "address_missing" }, - }; - } - - return { - status: 200, - body: { - link: { - platform: config.label.toLowerCase(), - handle, - url: best.link.url, - is_verified: !!best.link.is_verified, - }, - address: best.profile.address, - profile_name: best.profile.name, - address_verified: !!best.profile.address_verified, - }, - }; -} diff --git a/lib/profile/types.ts b/lib/profile/types.ts index e7c135a6..d716e088 100644 --- a/lib/profile/types.ts +++ b/lib/profile/types.ts @@ -13,7 +13,6 @@ export interface Profile { bio?: string; address: string; address_verified: boolean; - nearest_city_id?: number | null; nearest_city_name?: string; avatar_url?: string; profile_image_url?: string; @@ -68,6 +67,7 @@ export interface ProfileLink { id?: number | null; url: string; label?: string; + platform?: string | null; is_verified: boolean; verification_expires_at?: string; zcasher_id?: number; @@ -81,7 +81,7 @@ export interface EnrichedProfileLink extends ProfileLink { label: string; domain?: string; handle?: string; - platform?: "X" | "GitHub" | "Instagram" | "Discord" | null; + platform?: string | null; } /** @@ -101,21 +101,6 @@ export interface LinkVerificationPayload { [key: string]: unknown; } -export interface PendingProfileChange { - name?: string; - display_name?: string; - bio?: string; - profile_image_url?: string; - address?: string; - c?: string; - d?: string[]; -} - -export interface PendingEdits { - profile?: PendingProfileChange; - l?: string[]; -} - /** * Rank type discriminator */ diff --git a/lib/profile/usernameNormalizer.ts b/lib/profile/usernameNormalizer.ts index ef2b4ebb..9edfca10 100644 --- a/lib/profile/usernameNormalizer.ts +++ b/lib/profile/usernameNormalizer.ts @@ -92,6 +92,6 @@ export function buildSocialUrl(platform: SocialPlatform, username: string): stri const config = PLATFORMS[platform]; if (!config) return null; - const prefix = config.prefix || ""; + const prefix = config.prefix ?? ""; return `${config.base}${prefix}${username}`; } diff --git a/lib/profile/verifyLinkDb.ts b/lib/profile/verifyLinkDb.ts deleted file mode 100644 index 8814ec4c..00000000 --- a/lib/profile/verifyLinkDb.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; -import type { ProfileLink, LinkVerificationPayload } from "@/lib/profile/types"; - -interface UpdateLinkVerificationResult { - data: ProfileLink[] | null; - error: Error | null; -} - -/** - * Mark a zcasher_links row as verified, trying exact URL match first, - * then falling back to ilike patterns. - */ -export async function updateLinkVerification( - profileId: number, - handle: string, - variants: string[], - updatePayload: LinkVerificationPayload -): Promise { - const supabase = createSupabaseServerClient(); - if (!supabase) { - return { data: null, error: new Error("Supabase client not available") }; - } - - let { data, error } = await supabase - .from('zcasher_links') - .update(updatePayload) - .eq('zcasher_id', profileId) - .in('url', variants) - .select(); - - if ((!data || data.length === 0) && !error) { - const patternX = `%://x.com/${handle}%`; - const patternTw = `%://twitter.com/${handle}%`; - const patternWX = `%://www.x.com/${handle}%`; - const patternWT = `%://www.twitter.com/${handle}%`; - const patternLI = `%://linkedin.com/in/${handle}%`; - const patternWLI = `%://www.linkedin.com/in/${handle}%`; - const patternGH = `%://github.com/${handle}%`; - const patternWGH = `%://www.github.com/${handle}%`; - const patternD1 = `%://discord.com/users/${handle}%`; - const patternD2 = `%://www.discord.com/users/${handle}%`; - const patternDA = `%://discordapp.com/users/${handle}%`; - const patternWDA = `%://www.discordapp.com/users/${handle}%`; - const { data: data2, error: error2 } = await supabase - .from('zcasher_links') - .update(updatePayload) - .eq('zcasher_id', profileId) - .or(`url.ilike.${patternX},url.ilike.${patternTw},url.ilike.${patternWX},url.ilike.${patternWT},url.ilike.${patternLI},url.ilike.${patternWLI},url.ilike.${patternGH},url.ilike.${patternWGH},url.ilike.${patternD1},url.ilike.${patternD2},url.ilike.${patternDA},url.ilike.${patternWDA}`) - .select(); - data = data2; error = error2; - } - - if (error) { - throw error; - } - if (!data || data.length === 0) { - } - - return { data, error }; -} diff --git a/lib/rates/getRateAction.ts b/lib/rates/getRateAction.ts index f45347a9..36345a69 100644 --- a/lib/rates/getRateAction.ts +++ b/lib/rates/getRateAction.ts @@ -50,17 +50,17 @@ const providersForFiat = (fiat: string, asset: string = "ZEC"): Provider[] => { { name: "Coinbase", url: `https://api.coinbase.com/v2/prices/${mapping.coinbase}-${fiatUpper}/spot`, - parse: (data: unknown) => parseFloat((data as { data?: { amount?: string } })?.data?.amount || ""), + parse: (data: unknown) => parseFloat((data as { data?: { amount?: string } })?.data?.amount ?? ""), }, { name: "CoinGecko", url: `https://api.coingecko.com/api/v3/simple/price?ids=${mapping.coingecko}&vs_currencies=${fiatLower}`, - parse: (data: unknown) => parseFloat((data as Record>)?.[mapping.coingecko]?.[fiatLower] || ""), + parse: (data: unknown) => parseFloat((data as Record>)?.[mapping.coingecko]?.[fiatLower] ?? ""), }, { name: "CryptoCompare", url: `https://min-api.cryptocompare.com/data/price?fsym=${mapping.cryptocompare}&tsyms=${fiatUpper}`, - parse: (data: unknown) => parseFloat((data as Record)?.[fiatUpper] || ""), + parse: (data: unknown) => parseFloat((data as Record)?.[fiatUpper] ?? ""), }, ]; }; @@ -123,10 +123,9 @@ async function fetchRateFromProvider(fiat: string, asset: string): Promise { - console.log("[SERVER ACTION] getRateAction called with fiat=", fiat, "asset=", asset); try { - const fiatUpper = (fiat || "USD").toUpperCase(); - const assetUpper = (asset || "ZEC").toUpperCase(); + const fiatUpper = (fiat ?? "USD").toUpperCase(); + const assetUpper = (asset ?? "ZEC").toUpperCase(); // Fetch rate - caching handled by Next.js fetch cache with revalidate: 10 const result = await fetchRateFromProvider(fiatUpper, assetUpper); @@ -136,8 +135,8 @@ export async function getRateAction(fiat: string = "USD", asset: string = "ZEC") ok: false, rate: undefined, source: undefined, - fiat: (fiat || "USD").toUpperCase(), - asset: (asset || "ZEC").toUpperCase(), + fiat: (fiat ?? "USD").toUpperCase(), + asset: (asset ?? "ZEC").toUpperCase(), error: String((e as Error)?.message || e), }; } diff --git a/lib/signup/AGENT.md b/lib/signup/AGENT.md new file mode 100644 index 00000000..9cdcfa48 --- /dev/null +++ b/lib/signup/AGENT.md @@ -0,0 +1,92 @@ +# /lib/signup - Profile Creation + +## Purpose +Server actions for creating new Zcash profiles. +Handles validation, database insertion, and initial setup. + +## Key Files + +### createProfileAction.ts +Main server action for profile creation: +```typescript +'use server' +export async function createProfileAction(input: { + username: string; + displayName: string; + bio?: string; + address: string; + links?: LinkInput[]; + cityId?: string; +}): Promise<{ + success: boolean; + profileId?: string; + slug?: string; + error?: string; +}> +``` + +### createProfile.ts +Core creation logic (called by action): +```typescript +async function createProfile(data: ProfileInput): Promise +``` + +## Validation Steps +1. **Username** - Policy check via `/lib/profile/usernamePolicy.ts` +2. **Address** - Zcash validation via `/lib/zcash/zcashUtils.ts` +3. **Uniqueness** - Check username not taken +4. **Links** - Validate URLs/handles + +## Database Operations +```typescript +// Insert profile +const { data: profile } = await supabase + .from('zcasher') + .insert({ + name: normalizedUsername, + display_name: displayName, + slug: generateSlug(username), + address: address, + bio: bio, + nearest_city_id: cityId + }) + .select() + .single(); + +// Insert links +if (links.length > 0) { + await supabase + .from('zcasher_links') + .insert(links.map(l => ({ + profile_id: profile.id, + provider: l.provider, + value: l.value + }))); +} +``` + +## Zcash Address Handling +- Validates address format before storage +- Stores original address (preserves case for unified) +- `address_verified` defaults to false +- User must complete OTP flow to verify + +## Error Handling +```typescript +// Common errors +{ error: 'Username already taken' } +{ error: 'Invalid Zcash address' } +{ error: 'Username contains invalid characters' } +{ error: 'Database error' } +``` + +## Testing Harness +- Mock Supabase client +- Test validation edge cases +- Verify slug generation +- Test link insertion + +## Related Files +- `/ui/signup/` - Form components +- `/lib/profile/usernamePolicy.ts` - Validation rules +- `/lib/zcash/zcashUtils.ts` - Address validation diff --git a/lib/signup/createProfile.ts b/lib/signup/createProfile.ts index c69fe5f8..119d0a71 100644 --- a/lib/signup/createProfile.ts +++ b/lib/signup/createProfile.ts @@ -1,6 +1,7 @@ import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; import type { Profile } from "@/lib/profile/types"; import type { CreateProfilePayload, ProfileLinkInput } from "@/lib/api/types"; +import { derivePlatform } from "@/lib/profile/profileLinks"; export async function checkAddressTaken(address: string): Promise { const supabase = createSupabaseServerClient(); @@ -37,6 +38,7 @@ export async function insertProfileLinks(zcasherId: number, links: ProfileLinkIn zcasher_id: zcasherId, label: entry.label, url: entry.url, + platform: entry.platform ?? derivePlatform(entry.url), is_verified: false, }]); } diff --git a/lib/stores/edits.ts b/lib/stores/edits.ts deleted file mode 100644 index 311d9b40..00000000 --- a/lib/stores/edits.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { create } from 'zustand'; -import type { Profile } from '@/lib/profile/types'; -import { isValidUrl } from '@/lib/validation/validators'; - -export interface ParsedLink { - id: number | null; - url: string; - username?: string; - previewUrl?: string; - valid: boolean; - reason: string | null; - is_verified: boolean; - verification_expires_at?: string; - _uid: string; - platform?: "X" | "GitHub" | "Instagram" | "Discord"; - otherUrl?: string; - label?: string; - icon?: string; - domain?: string; - handle?: string; -} - -export interface FormState { - address: string; - name: string; - display_name: string; - bio: string; - profile_image_url: string; - links: ParsedLink[]; - nearest_city_id: number | null; - nearest_city_name: string; -} - -export interface PendingEdits { - profile?: Record; - l?: any[]; - [key: string]: any; -} - -interface OriginalState { - address: string; - name: string; - display_name: string; - bio: string; - profile_image_url: string; - links: ParsedLink[]; - nearest_city_id: number | null; - nearest_city_name: string; -} - -interface DeletedFields { - address: boolean; - name: boolean; - display_name: boolean; - bio: boolean; - profile_image_url: boolean; - nearest_city: boolean; -} - -interface EditsState { - // Form state - form: FormState; - original: OriginalState; - deletedFields: DeletedFields; - linkAuthTokens: string[]; // Tokens like "!123" or "+!https://x.com/handle" - pendingEdits: PendingEdits; // Auto-computed from form vs original - - // Actions - setForm: (form: FormState | ((prev: FormState) => FormState)) => void; - updateField: (field: keyof FormState, value: any) => void; - setDeletedField: (field: keyof DeletedFields, value: boolean) => void; - initializeForm: (profile: Profile, links: ParsedLink[]) => void; - addLinkAuthToken: (token: string) => void; - removeLinkAuthToken: (token: string) => void; -} - -const emptyForm: FormState = { - address: '', - name: '', - display_name: '', - bio: '', - profile_image_url: '', - links: [], - nearest_city_id: null, - nearest_city_name: '', -}; - -const emptyDeletedFields: DeletedFields = { - address: false, - name: false, - display_name: false, - bio: false, - profile_image_url: false, - nearest_city: false, -}; - -// Helper to compute pendingEdits from current state -function computePendingEdits( - form: FormState, - original: OriginalState, - deletedFields: DeletedFields, - linkAuthTokens: string[] -): PendingEdits { - const profileChanges: Record = {}; - const deletedTokens: string[] = []; - - // Check each field for changes - const fieldMapping: Record = { - name: 'n', - display_name: 'h', - bio: 'b', - address: 'a', - profile_image_url: 'i', - }; - - for (const [field, token] of Object.entries(fieldMapping)) { - const key = field as keyof FormState; - if (deletedFields[key as keyof DeletedFields]) { - deletedTokens.push(token); - } else if (form[key] !== original[key]) { - profileChanges[field] = form[key]; - } - } - - if (deletedTokens.length > 0) { - profileChanges.d = deletedTokens; - } - - // Handle city changes - if (deletedFields.nearest_city && original.nearest_city_id) { - profileChanges.c = '-'; - } else if (form.nearest_city_id && form.nearest_city_id !== original.nearest_city_id) { - profileChanges.c = String(form.nearest_city_id); - } - - // Compute link tokens (complex logic for tracking link changes) - const effectTokens: string[] = []; - const originalById = new Map(); - const originalUrlSet = new Set(); - - for (const l of original.links) { - if (!l) continue; - const url = (l.url || '').trim(); - if (l.id) originalById.set(String(l.id), { ...l, url }); - if (url) originalUrlSet.add(url); - } - - const currentUrls = new Set( - form.links.map((l) => (l.url || '').trim()).filter(Boolean) - ); - const currentById = new Map( - form.links - .filter((l) => l.id) - .map((l) => [String(l.id), (l.url || '').trim()]) - ); - const currentIdSet = new Set( - form.links - .filter((l) => l.id) - .map((l) => String(l.id)) - ); - - // Normalize verification tokens - if a +! token's URL no longer exists, - // replace it with a new URL - let normalizedVerify = [...linkAuthTokens]; - for (const token of linkAuthTokens) { - if (!token.startsWith('+!')) continue; - const oldUrl = token.slice(2); - const stillExists = form.links.some( - (l) => (l.url || '').trim() === oldUrl.trim() - ); - - if (!stillExists) { - normalizedVerify = normalizedVerify.filter((t) => t !== token); - const newUrl = form.links - .map((l) => (l.url || '').trim()) - .find((u) => u && !originalUrlSet.has(u)); - if (newUrl) normalizedVerify.push(`+!${newUrl}`); - } - } - - // Compute changes for each link - for (const row of form.links) { - const id = row.id ?? null; - const newUrlRaw = (row.url || '').trim(); - const { valid: urlValid } = isValidUrl(newUrlRaw); - const newUrl = urlValid ? newUrlRaw : ''; - - if (id) { - const original = originalById.get(String(id)); - const originalUrl = original ? original.url : ''; - if (newUrl === originalUrl) continue; - if (!newUrl) { - effectTokens.push(`-${id}`); - continue; - } - effectTokens.push(`+${id}:${newUrl}`); - } else { - if (!newUrl) continue; - const isNew = !originalUrlSet.has(newUrl); - const verifyToken = `+!${newUrl}`; - const isExplicitVerify = normalizedVerify.includes(verifyToken); - if (isNew && !isExplicitVerify) { - effectTokens.push(`+${newUrl}`); - } - } - } - - // Any original link id missing from current form is a deletion. - for (const [id] of originalById) { - if (!currentIdSet.has(id)) { - effectTokens.push(`-${id}`); - } - } - - // Preserve old tokens that are still relevant - const preservedOld = normalizedVerify.filter((t) => { - if (/^![0-9]+$/.test(t) || /^\+!/.test(t)) return true; - if (/^-[0-9]+$/.test(t)) return true; - if (/^\+[0-9]+:/.test(t)) { - const id = t.slice(1, t.indexOf(':')); - const original = originalById.get(id); - const currentUrl = currentById.get(id) || ''; - const { valid: currentValid } = isValidUrl(currentUrl); - if (!currentUrl || !currentValid) return false; - if (original && currentUrl === original.url) return false; - const hasNewer = effectTokens.some((et) => et.startsWith(`+${id}:`)); - return !hasNewer; - } - if (/^\+[^!]/.test(t) && !t.includes(':')) { - const url = t.slice(1).trim(); - const hasExplicitVerify = normalizedVerify.includes(`+!${url}`); - return currentUrls.has(url) && !hasExplicitVerify; - } - return false; - }); - - // Merge and deduplicate - const uniqTokens = (arr: string[]) => { - const seen = new Set(); - const out: string[] = []; - for (const t of arr) { - if (!seen.has(t)) { - seen.add(t); - out.push(t); - } - } - return out; - }; - - const merged = uniqTokens([...effectTokens, ...preservedOld]); - - // Final filtering - const linkTokens = merged.filter((t) => { - if (t.startsWith('!')) { - const id = t.slice(1); - return !merged.includes(`-${id}`); - } - if (t.startsWith('+!')) { - const url = (t.slice(2) || '').trim(); - if (!url) return false; - return currentUrls.has(url); - } - return true; - }); - - const result: PendingEdits = {}; - if (Object.keys(profileChanges).length > 0) { - result.profile = profileChanges; - } - if (linkTokens.length > 0) { - result.l = linkTokens; - } - - return result; -} - -export const useEditsStore = create((set) => ({ - form: emptyForm, - original: emptyForm, - deletedFields: emptyDeletedFields, - linkAuthTokens: [], - pendingEdits: {}, - - setForm: (form) => - set((state) => { - const newForm = typeof form === 'function' ? form(state.form) : form; - return { - form: newForm, - pendingEdits: computePendingEdits(newForm, state.original, state.deletedFields, state.linkAuthTokens), - }; - }), - - updateField: (field, value) => - set((state) => { - const newForm = { ...state.form, [field]: value }; - return { - form: newForm, - pendingEdits: computePendingEdits(newForm, state.original, state.deletedFields, state.linkAuthTokens), - }; - }), - - setDeletedField: (field, value) => - set((state) => { - const newDeletedFields = { ...state.deletedFields, [field]: value }; - // If deleting, clear the field; if undeleting, restore original - const newForm = { ...state.form }; - if (field === 'nearest_city') { - // Special handling for city field - if (value) { - newForm.nearest_city_id = null; - newForm.nearest_city_name = ''; - } else { - newForm.nearest_city_id = state.original.nearest_city_id; - newForm.nearest_city_name = state.original.nearest_city_name; - } - } else { - if (value) { - newForm[field] = '' as any; - } else { - newForm[field] = state.original[field] as any; - } - } - return { - deletedFields: newDeletedFields, - form: newForm, - pendingEdits: computePendingEdits(newForm, state.original, newDeletedFields, state.linkAuthTokens), - }; - }), - - initializeForm: (profile, links) => - set({ - form: { - address: profile.address || '', - name: profile.name || '', - display_name: profile.display_name || '', - bio: profile.bio || '', - profile_image_url: profile.profile_image_url || '', - links: links || [], - nearest_city_id: profile.nearest_city_id || null, - nearest_city_name: profile.nearest_city_name || '', - }, - original: { - address: profile.address || '', - name: profile.name || '', - display_name: profile.display_name || '', - bio: profile.bio || '', - profile_image_url: profile.profile_image_url || '', - links: links || [], - nearest_city_id: profile.nearest_city_id || null, - nearest_city_name: profile.nearest_city_name || '', - }, - deletedFields: emptyDeletedFields, - linkAuthTokens: [], - pendingEdits: {}, // No changes initially - }), - - addLinkAuthToken: (token) => - set((state) => { - const newTokens = state.linkAuthTokens.includes(token) - ? state.linkAuthTokens - : [...state.linkAuthTokens, token]; - return { - linkAuthTokens: newTokens, - pendingEdits: computePendingEdits(state.form, state.original, state.deletedFields, newTokens), - }; - }), - - removeLinkAuthToken: (token) => - set((state) => { - const newTokens = state.linkAuthTokens.filter((t) => t !== token); - return { - linkAuthTokens: newTokens, - pendingEdits: computePendingEdits(state.form, state.original, state.deletedFields, newTokens), - }; - }), -})); diff --git a/lib/stores/messaging.ts b/lib/stores/messaging.ts deleted file mode 100644 index 3d242814..00000000 --- a/lib/stores/messaging.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { create } from 'zustand'; - -type OtpPhaseHistoryItem = { - phase?: string | null; -}; - -export type ProfileMode = "verification" | "swap" | "memo"; - -/** - * Messaging store - manages memo/message composition and verification state for Zcash - */ -interface MessagingState { - currentProfileAddress: string | null; - mode: ProfileMode; - showBack: boolean; - - // Memo composition state - memo: string; - amount: string; - - verify: { - amount: string; - zId: number | null; - requestId: string | null; - }; - // Verification polling state - verifyQrEnabled: boolean; - pollStatus: string | null; - pollOtpStatus: string | null; - pollOtpPhase: string | null; - pollOtpPhaseHistory: OtpPhaseHistoryItem[]; - otpInlineSuccess: boolean; - pollError: string; - pollDebug: string; - pollStartedAt: string | null; - pollElapsedMs: number; - - // Actions - ensureProfile: (address: string) => void; - setMode: (mode: ProfileMode | ((prev: ProfileMode) => ProfileMode)) => void; - setShowBack: (showBack: boolean) => void; - setMemo: (memo: string) => void; - setAmount: (amount: string) => void; - setVerify: (verify: { amount: string; zId: number | null; requestId: string | null } | ((prev: { amount: string; zId: number | null; requestId: string | null }) => { amount: string; zId: number | null; requestId: string | null })) => void; - - // Verification polling actions - setVerifyQrEnabled: (enabled: boolean) => void; - setPollStatus: (status: string | null) => void; - setPollOtpStatus: (status: string | null) => void; - setPollOtpPhase: (phase: string | null) => void; - setPollOtpPhaseHistory: (history: OtpPhaseHistoryItem[]) => void; - setOtpInlineSuccess: (success: boolean) => void; - setPollError: (error: string) => void; - setPollDebug: (debug: string | ((prev: string) => string)) => void; - setPollStartedAt: (startedAt: string | null) => void; - setPollElapsedMs: (elapsed: number) => void; - resetVerificationPolling: () => void; -} - -const initialVerifyState = { - verifyQrEnabled: false, - pollStatus: null, - pollOtpStatus: null, - pollOtpPhase: null, - pollOtpPhaseHistory: [], - otpInlineSuccess: false, - pollError: '', - pollDebug: '', - pollStartedAt: null, - pollElapsedMs: 0, -}; - -export const useMessagingStore = create((set, get) => ({ - currentProfileAddress: null, - mode: 'memo', - showBack: false, - memo: '', - amount: '', - verify: { amount: '0.003', zId: null, requestId: null }, - ...initialVerifyState, - - ensureProfile: (address) => { - if (get().currentProfileAddress !== address) { - set({ - currentProfileAddress: address, - mode: 'memo', - showBack: false, - memo: '', - amount: '', - verify: { amount: '0.003', zId: null, requestId: null }, - ...initialVerifyState, - }); - } - }, - setMode: (mode) => - set((state) => ({ - mode: typeof mode === 'function' ? mode(state.mode) : mode, - })), - setShowBack: (showBack) => set({ showBack }), - setMemo: (memo) => set({ memo }), - setAmount: (amount) => set({ amount }), - setVerify: (verify) => - set((state) => ({ - verify: typeof verify === 'function' ? verify(state.verify) : verify, - })), - - // Verification polling actions - setVerifyQrEnabled: (enabled) => set({ verifyQrEnabled: enabled }), - setPollStatus: (status) => set({ pollStatus: status }), - setPollOtpStatus: (status) => set({ pollOtpStatus: status }), - setPollOtpPhase: (phase) => set({ pollOtpPhase: phase }), - setPollOtpPhaseHistory: (history) => set({ pollOtpPhaseHistory: history }), - setOtpInlineSuccess: (success) => set({ otpInlineSuccess: success }), - setPollError: (error) => set({ pollError: error }), - setPollDebug: (debug) => - set((state) => ({ - pollDebug: typeof debug === 'function' ? debug(state.pollDebug) : debug, - })), - setPollStartedAt: (startedAt) => set({ pollStartedAt: startedAt }), - setPollElapsedMs: (elapsed) => set({ pollElapsedMs: elapsed }), - resetVerificationPolling: () => set((state) => ({ - ...initialVerifyState, - verify: { ...state.verify, zId: null, requestId: null }, - })), -})); diff --git a/lib/stores/swap.ts b/lib/stores/swap.ts deleted file mode 100644 index d999d12a..00000000 --- a/lib/stores/swap.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { create } from 'zustand'; -import type { - SwapContextQuoteData, - SwapQuoteDisplay, - Token, -} from '@/lib/swap/types'; - -interface SwapState { - currentProfileAddress: string | null; - tokens: Token[]; - originTokenId: string | null; - destinationTokenId: string | null; - swapAmount: string; - refundAddress: string; - destAddress: string; - slippageTolerance: string; - quoteData: SwapContextQuoteData; - quotePreview: SwapQuoteDisplay | null; - depositUri: string; - statusKey: { depositAddress: string } | null; - quoteStatus: string; - swapError: string; - - ensureProfile: (address: string, zecTokenId: string | null) => void; - setTokens: (tokens: Token[]) => void; - setOriginTokenId: (id: string | null) => void; - setDestinationTokenId: (id: string | null) => void; - setSwapAmount: (amount: string) => void; - setRefundAddress: (address: string) => void; - setDestAddress: (address: string) => void; - setSlippageTolerance: (tolerance: string) => void; - setQuoteData: (data: SwapContextQuoteData) => void; - setQuotePreview: (preview: SwapQuoteDisplay | null) => void; - setDepositUri: (uri: string) => void; - setStatusKey: (key: { depositAddress: string } | null) => void; - setQuoteStatus: (status: string) => void; - setSwapError: (error: string) => void; - swapDirection: () => void; - resetQuote: () => void; - resetSwapState: (zecTokenId: string | null) => void; -} - -export const useSwapStore = create((set, get) => ({ - currentProfileAddress: null, - tokens: [], - originTokenId: null, - destinationTokenId: null, - swapAmount: '', - refundAddress: '', - destAddress: '', - slippageTolerance: '1', - quoteData: null, - quotePreview: null, - depositUri: '', - statusKey: null, - quoteStatus: '', - swapError: '', - - ensureProfile: (address, zecTokenId) => { - if (get().currentProfileAddress !== address) { - set({ - currentProfileAddress: address, - originTokenId: zecTokenId, - destinationTokenId: null, - swapAmount: '', - refundAddress: '', - destAddress: '', - slippageTolerance: '1', - quoteData: null, - quotePreview: null, - quoteStatus: '', - depositUri: '', - statusKey: null, - swapError: '', - }); - } - }, - setTokens: (tokens) => set({ tokens }), - setOriginTokenId: (id) => set({ originTokenId: id }), - setDestinationTokenId: (id) => set({ destinationTokenId: id }), - setSwapAmount: (amount) => set({ swapAmount: amount }), - setRefundAddress: (address) => set({ refundAddress: address }), - setDestAddress: (address) => set({ destAddress: address }), - setSlippageTolerance: (tolerance) => set({ slippageTolerance: tolerance }), - setQuoteData: (data) => set({ quoteData: data }), - setQuotePreview: (preview) => set({ quotePreview: preview }), - setDepositUri: (uri) => set({ depositUri: uri }), - setStatusKey: (key) => set({ statusKey: key }), - setQuoteStatus: (status) => set({ quoteStatus: status }), - setSwapError: (error) => set({ swapError: error }), - swapDirection: () => { - const { originTokenId, destinationTokenId } = get(); - set({ - originTokenId: destinationTokenId, - destinationTokenId: originTokenId, - }); - }, - resetQuote: () => set({ quoteData: null, quotePreview: null, quoteStatus: '' }), - resetSwapState: (zecTokenId) => - set({ - originTokenId: zecTokenId, - destinationTokenId: null, - swapAmount: '', - refundAddress: '', - destAddress: '', - slippageTolerance: '1', - quoteData: null, - quotePreview: null, - quoteStatus: '', - depositUri: '', - statusKey: null, - swapError: '', - }), -})); diff --git a/lib/stores/thread.ts b/lib/stores/thread.ts deleted file mode 100644 index d413f778..00000000 --- a/lib/stores/thread.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { create } from 'zustand'; -import { immer } from 'zustand/middleware/immer'; -import type { ThreadStore, ThreadMessage, Board } from '@/lib/thread/types'; - -export const useThreadStore = create()( - immer((set) => ({ - // Messages - messages: [], - isLoadingMessages: false, - - // Current board - currentBoardId: '', - currentBoard: undefined, - - // All boards - boards: [], - isLoadingBoards: false, - - // UI state - showComposer: true, - - // Actions - setCurrentBoardId: (id: string) => - set((state) => { - state.currentBoardId = id; - }), - - setMessages: (messages: ThreadMessage[]) => - set((state) => { - state.messages = messages; - }), - - addMessage: (message: ThreadMessage) => - set((state) => { - state.messages.unshift(message); - }), - - setBoards: (boards: Board[]) => - set((state) => { - state.boards = boards; - }), - - setCurrentBoard: (board: Board) => - set((state) => { - state.currentBoard = board; - }), - - setLoadingMessages: (loading: boolean) => - set((state) => { - state.isLoadingMessages = loading; - }), - - setLoadingBoards: (loading: boolean) => - set((state) => { - state.isLoadingBoards = loading; - }), - - setShowComposer: (show: boolean) => - set((state) => { - state.showComposer = show; - }), - })) -); - -export function useResetThreadStore() { - return () => - useThreadStore.setState({ - messages: [], - isLoadingMessages: false, - currentBoardId: '', - currentBoard: undefined, - boards: [], - isLoadingBoards: false, - showComposer: true, - }); -} diff --git a/lib/supabase/AGENT.md b/lib/supabase/AGENT.md new file mode 100644 index 00000000..7a05774c --- /dev/null +++ b/lib/supabase/AGENT.md @@ -0,0 +1,83 @@ +# /lib/supabase - Database Client + +## Purpose +Supabase client initialization and database connection management. +Single source of truth for all database access. + +## Client Setup + +### Server-Side Client +```typescript +import { createClient } from '@supabase/supabase-js'; + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_KEY! // Server-only key +); +``` + +### Client-Side Client +```typescript +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // Public key +); +``` + +## Environment Variables +``` +NEXT_PUBLIC_SUPABASE_URL - Supabase project URL +NEXT_PUBLIC_SUPABASE_ANON_KEY - Public anon key (client) +SUPABASE_SERVICE_KEY - Service role key (server only) +``` + +## Database Tables + +### zcasher (Profiles) +| Column | Type | Purpose | +|--------|------|---------| +| id | uuid | Primary key | +| name | text | Username (normalized) | +| display_name | text | Shown in UI | +| slug | text | URL path | +| address | text | Zcash address | +| address_verified | boolean | Blockchain verified | +| bio | text | Short description | +| avatar_url | text | Profile image | +| is_ns | boolean | Network School member | +| featured | boolean | Homepage featured | + +### zcasher_links (Profile Links) +| Column | Type | Purpose | +|--------|------|---------| +| id | uuid | Primary key | +| profile_id | uuid | FK to zcasher | +| provider | text | Platform name | +| value | text | Handle or URL | +| verified | boolean | Link verified | + +### zcasher_searchable (Search Index) +Denormalized view for fast search queries. + +## Query Patterns + +```typescript +// Fetch profile by slug +const { data } = await supabase + .from('zcasher') + .select('*, zcasher_links(*)') + .eq('slug', slug) + .single(); + +// Search profiles +const { data } = await supabase + .from('zcasher_searchable') + .select('*') + .ilike('name', `%${query}%`) + .limit(25); +``` + +## Testing Harness +- Mock Supabase client in tests +- Use test database for integration +- Never use production keys in tests diff --git a/lib/supabase/auth.ts b/lib/supabase/auth.ts deleted file mode 100644 index 2b46936a..00000000 --- a/lib/supabase/auth.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { supabase } from "@/lib/supabase/supabase-client"; -import type { AuthChangeEvent, Session } from "@supabase/supabase-js"; - -export function getSession() { - return supabase.auth.getSession(); -} - -export function onAuthStateChange(callback: (event: AuthChangeEvent, session: Session | null) => void) { - return supabase.auth.onAuthStateChange(callback); -} diff --git a/lib/supabase/supabase-server.ts b/lib/supabase/supabase-server.ts index 2ce9e055..5fdca6e0 100644 --- a/lib/supabase/supabase-server.ts +++ b/lib/supabase/supabase-server.ts @@ -3,14 +3,17 @@ import { createClient, SupabaseClient } from "@supabase/supabase-js"; export function createSupabaseServerClient(): SupabaseClient | null { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; - const supabaseAnonKey = - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY; + // Prefer service role key for server-side operations (bypasses RLS) + const supabaseKey = + process.env.SUPABASE_SERVICE_KEY || + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || + process.env.SUPABASE_ANON_KEY; - if (!supabaseUrl || !supabaseAnonKey) { + if (!supabaseUrl || !supabaseKey) { return null; } - return createClient(supabaseUrl, supabaseAnonKey, { + return createClient(supabaseUrl, supabaseKey, { auth: { persistSession: false }, }); } diff --git a/lib/swap/AGENT.md b/lib/swap/AGENT.md new file mode 100644 index 00000000..625fa574 --- /dev/null +++ b/lib/swap/AGENT.md @@ -0,0 +1,85 @@ +# /lib/swap - Cryptocurrency Swap + +## Purpose +Integration with Defuse Protocol's OneClick SDK for cross-chain swaps. +Allows users to receive payments in any token, converted to ZEC. + +## Key Files + +### types.ts +```typescript +interface Token { + symbol: string; + name: string; + decimals: number; + address?: string; // contract address for ERC20 + chainId: string; +} + +interface SwapQuote { + fromToken: Token; + toToken: Token; // Usually ZEC + fromAmount: string; + toAmount: string; + rate: string; + slippage: number; + expiresAt: number; +} + +interface SwapDeposit { + address: string; // Deposit address (chain-specific) + memo?: string; // Required for some chains + expiresAt: number; +} +``` + +### oneClick.ts +OneClick SDK wrapper: +```typescript +import { OneClickClient } from '@anthropic/defuse-one-click-sdk'; + +// Initialize client +const client = new OneClickClient({ apiKey: ONECLICK_API_KEY }); + +// Get supported tokens +await client.getTokens(); + +// Get quote +await client.getQuote({ from, to, amount }); + +// Create deposit address +await client.createDeposit({ quoteId, destinationAddress }); +``` + +### utils.ts +Helper functions for swap calculations and formatting. + +## Zcash as Destination +Primary use case: receive any crypto → convert to ZEC +- User's Zcash address is the final destination +- Supports unified addresses for privacy +- OneClick handles cross-chain bridging + +## Environment Variables +``` +ONECLICK_API_KEY - Server-side Defuse API key +``` + +## Testing Harness +- Mock OneClick SDK responses +- Test quote calculations locally +- Use testnet for integration tests + +## State Management +Swap state lives in `/lib/stores/swap.ts` (Zustand): +- Selected tokens +- Amounts +- Current quote +- Deposit info +- Slippage tolerance + +## Error Handling +- Quote expiration (refresh needed) +- Insufficient liquidity +- Network errors +- Invalid addresses diff --git a/lib/swap/oneClick.ts b/lib/swap/oneClick.ts index 425f8a97..75020174 100644 --- a/lib/swap/oneClick.ts +++ b/lib/swap/oneClick.ts @@ -67,6 +67,98 @@ function toBasisPoints(value: number | string | null | undefined, defaultBps: nu return Math.max(0, Math.min(10_000, bps)); } +// ============================================================================ +// Shared swap parameter validation and request building +// ============================================================================ + +interface SwapParams { + fromToken: string; + toToken: string; + amountIn: string; + destAddress: string; + refundAddress: string; + slippageTolerance?: number | string; + tokens: Token[]; +} + +interface ValidatedSwapParams { + originToken: Token; + destToken: Token; + amountBase: string; +} + +type SwapValidationResult = + | { ok: true; params: ValidatedSwapParams } + | { ok: false; error: string; retryable: boolean }; + +/** + * Validate swap parameters and resolve tokens + */ +function validateSwapParams(params: SwapParams): SwapValidationResult { + if (!OpenAPI.TOKEN) { + return { ok: false, error: "1Click API key not configured", retryable: false }; + } + + if (!params.fromToken || !params.toToken || !params.amountIn || !params.destAddress || !params.refundAddress) { + return { ok: false, error: "Missing required fields", retryable: false }; + } + + const originToken = findToken(params.tokens, params.fromToken); + const destToken = findToken(params.tokens, params.toToken); + + if (!originToken) { + return { ok: false, error: "From token not found", retryable: false }; + } + if (!destToken) { + return { ok: false, error: "To token not found", retryable: false }; + } + + const amountBase = toBaseUnits(params.amountIn, originToken.decimals); + if (!amountBase) { + return { ok: false, error: "Amount must be greater than 0", retryable: false }; + } + + return { + ok: true, + params: { originToken, destToken, amountBase }, + }; +} + +/** + * Build a QuoteRequest object + */ +function buildQuoteRequest( + params: SwapParams, + validated: ValidatedSwapParams, + isDryRun: boolean +): QuoteRequest { + return { + dry: isDryRun, + swapType: QuoteRequest.swapType.EXACT_INPUT, + slippageTolerance: toBasisPoints(params.slippageTolerance, 100), + originAsset: params.fromToken, + depositType: QuoteRequest.depositType.ORIGIN_CHAIN, + destinationAsset: params.toToken, + amount: validated.amountBase, + refundTo: params.refundAddress, + refundType: QuoteRequest.refundType.ORIGIN_CHAIN, + recipient: params.destAddress, + recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, + deadline: deadlineIso(), + quoteWaitingTimeMs: 3000, + appFees: [ + { + recipient: "zcash-me.near", + fee: 150, + }, + ], + }; +} + +// ============================================================================ +// Public API +// ============================================================================ + /** * Fetch available swap tokens */ @@ -115,62 +207,16 @@ export async function getSwapTokens(): Promise<{ tokens: Token[] } | { error: st /** * Get swap quote (dry run - no deposit address generated) */ -export async function getSwapQuote(params: { - fromToken: string; - toToken: string; - amountIn: string; - destAddress: string; - refundAddress: string; - slippageTolerance?: number | string; - tokens: Token[]; -}): Promise { - if (!OpenAPI.TOKEN) { - return { ok: false, error: "1Click API key not configured", retryable: false }; - } - - // Validate inputs - if (!params.fromToken || !params.toToken || !params.amountIn || !params.destAddress || !params.refundAddress) { - return { ok: false, error: "Missing required fields", retryable: false }; - } - - const originToken = findToken(params.tokens, params.fromToken); - const destToken = findToken(params.tokens, params.toToken); - - if (!originToken) { - return { ok: false, error: "From token not found", retryable: false }; - } - if (!destToken) { - return { ok: false, error: "To token not found", retryable: false }; +export async function getSwapQuote(params: SwapParams): Promise { + const validation = validateSwapParams(params); + if (!validation.ok) { + return validation; } - const amountBase = toBaseUnits(params.amountIn, originToken.decimals); - if (!amountBase) { - return { ok: false, error: "Amount must be greater than 0", retryable: false }; - } + const { originToken, destToken } = validation.params; try { - const request: QuoteRequest = { - dry: true, // Dry run - no deposit address - swapType: QuoteRequest.swapType.EXACT_INPUT, - slippageTolerance: toBasisPoints(params.slippageTolerance, 100), - originAsset: params.fromToken, - depositType: QuoteRequest.depositType.ORIGIN_CHAIN, - destinationAsset: params.toToken, - amount: amountBase, - refundTo: params.refundAddress, - refundType: QuoteRequest.refundType.ORIGIN_CHAIN, - recipient: params.destAddress, - recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, - deadline: deadlineIso(), - quoteWaitingTimeMs: 3000, - appFees: [ - { - recipient: "zcash-me.near", - fee: 150, - }, - ], - }; - + const request = buildQuoteRequest(params, validation.params, true); const response: SDKQuoteResponse = await OneClickService.getQuote(request); // Format minAmountOut from base units to decimal @@ -184,8 +230,8 @@ export async function getSwapQuote(params: { quote: { amountInFormatted: response.quote.amountInFormatted, amountOutFormatted: response.quote.amountOutFormatted, - amountInUsd: parseFloat(response.quote.amountInUsd) || undefined, - amountOutUsd: parseFloat(response.quote.amountOutUsd) || undefined, + amountInUsd: parseFloat(response.quote.amountInUsd) ?? undefined, + amountOutUsd: parseFloat(response.quote.amountOutUsd) ?? undefined, timeEstimate: response.quote.timeEstimate, minAmountOut: response.quote.minAmountOut, }, @@ -194,8 +240,8 @@ export async function getSwapQuote(params: { toSymbol: destToken.symbol, amountInFormatted: response.quote.amountInFormatted, amountOutFormatted: response.quote.amountOutFormatted, - amountInUsd: parseFloat(response.quote.amountInUsd) || undefined, - amountOutUsd: parseFloat(response.quote.amountOutUsd) || undefined, + amountInUsd: parseFloat(response.quote.amountInUsd) ?? undefined, + amountOutUsd: parseFloat(response.quote.amountOutUsd) ?? undefined, timeEstimate: response.quote.timeEstimate ? `~${response.quote.timeEstimate}s` : "Unknown", minAmountOut: minAmountOutFormatted, }, @@ -214,62 +260,16 @@ export async function getSwapQuote(params: { /** * Confirm swap and get deposit address (dry=false) */ -export async function confirmSwap(params: { - fromToken: string; - toToken: string; - amountIn: string; - destAddress: string; - refundAddress: string; - slippageTolerance?: number | string; - tokens: Token[]; -}): Promise { - if (!OpenAPI.TOKEN) { - return { ok: false, error: "1Click API key not configured", retryable: false }; - } - - // Validate inputs - if (!params.fromToken || !params.toToken || !params.amountIn || !params.destAddress || !params.refundAddress) { - return { ok: false, error: "Missing required fields", retryable: false }; +export async function confirmSwap(params: SwapParams): Promise { + const validation = validateSwapParams(params); + if (!validation.ok) { + return validation; } - const originToken = findToken(params.tokens, params.fromToken); - const destToken = findToken(params.tokens, params.toToken); - - if (!originToken) { - return { ok: false, error: "From token not found", retryable: false }; - } - if (!destToken) { - return { ok: false, error: "To token not found", retryable: false }; - } - - const amountBase = toBaseUnits(params.amountIn, originToken.decimals); - if (!amountBase) { - return { ok: false, error: "Amount must be greater than 0", retryable: false }; - } + const { originToken, amountBase } = validation.params; try { - const request: QuoteRequest = { - dry: false, // Real swap - generates deposit address - swapType: QuoteRequest.swapType.EXACT_INPUT, - slippageTolerance: toBasisPoints(params.slippageTolerance, 100), - originAsset: params.fromToken, - depositType: QuoteRequest.depositType.ORIGIN_CHAIN, - destinationAsset: params.toToken, - amount: amountBase, - refundTo: params.refundAddress, - refundType: QuoteRequest.refundType.ORIGIN_CHAIN, - recipient: params.destAddress, - recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, - deadline: deadlineIso(), - quoteWaitingTimeMs: 3000, - appFees: [ - { - recipient: "zcash-me.near", - fee: 150, - }, - ], - }; - + const request = buildQuoteRequest(params, validation.params, false); const response: SDKQuoteResponse = await OneClickService.getQuote(request); if (!response.quote.depositAddress) { diff --git a/lib/thread/AGENT.md b/lib/thread/AGENT.md new file mode 100644 index 00000000..3466f35d --- /dev/null +++ b/lib/thread/AGENT.md @@ -0,0 +1,102 @@ +# /lib/thread - Discussion Board Logic + +## Purpose +Server actions and types for the OTP-verified discussion board. +Users post messages by proving identity via Zcash transactions. + +## Key Files + +### types.ts +```typescript +interface ThreadMessage { + id: string; + boardId: string; + authorId: string; + authorUsername: string; + authorDisplayName: string; + content: string; + createdAt: string; + verified: boolean; +} + +interface Board { + id: string; + name: string; + description?: string; + memberCount: number; + messageCount: number; + createdAt: string; +} + +interface ThreadStore { + currentBoard: Board | null; + messages: ThreadMessage[]; + composerContent: string; +} +``` + +### actions.ts +Server actions (partially implemented): +```typescript +'use server' + +// Fetch available boards +export async function fetchBoards(): Promise + +// Post verified message +export async function postMessage(input: { + boardId: string; + content: string; + otp: string; +}): Promise<{ success: boolean; message?: ThreadMessage }> + +// Create new board +export async function createBoard(input: { + name: string; + description?: string; +}): Promise<{ success: boolean; board?: Board }> +``` + +### utils.ts +Helper functions for thread operations. + +## Verification Flow +1. User writes message +2. Generates OTP +3. Sends Zcash tx with OTP in memo +4. Server confirms OTP +5. Message posted with verified badge + +## Anti-Spam Mechanism +- Each post requires on-chain proof +- Small fee (~0.0001 ZEC) per message +- Ties posts to verified profiles +- Rate limits per user + +## Database Tables +```sql +zcasher_boards ( + id, name, description, created_at +) + +zcasher_thread_messages ( + id, board_id, author_id, content, + verified, created_at +) +``` + +## Status: Partially Implemented +The actions file has TODO comments - some features pending: +- Board creation flow +- Message editing/deletion +- Moderation tools + +## Testing Harness +- Mock database responses +- Test message posting flow +- Verify OTP integration +- Test board switching + +## UI Integration +Components in `/ui/thread/` consume this logic. +State managed by Zustand store in `/lib/stores/thread.ts`. diff --git a/lib/validation/AGENT.md b/lib/validation/AGENT.md new file mode 100644 index 00000000..599dbcbf --- /dev/null +++ b/lib/validation/AGENT.md @@ -0,0 +1,72 @@ +# /lib/validation - Form Validators + +## Purpose +Composable, reusable validators for form inputs. Pure functions that return +structured validation results. + +## Key Files + +### validators.ts +Core validator functions: + +```typescript +interface ValidationResult { + valid: boolean; + reason?: string; + level?: 'error' | 'warning' | 'info'; +} + +// Basic validators +required(value: string): ValidationResult +minLength(min: number): (value: string) => ValidationResult +maxLength(max: number): (value: string) => ValidationResult +email(value: string): ValidationResult +url(value: string): ValidationResult +digits(value: string): ValidationResult +range(min: number, max: number): (value: number) => ValidationResult + +// Composable pattern +compose(...validators): (value: any) => ValidationResult +``` + +## Usage Examples + +```typescript +// Simple validation +const result = required(''); +// { valid: false, reason: 'Required' } + +// Composed validators +const validateUsername = compose( + required, + minLength(3), + maxLength(30), + (v) => /^[a-z0-9_]+$/.test(v) + ? { valid: true } + : { valid: false, reason: 'Invalid characters' } +); +``` + +## Zcash-Specific Validators +Zcash address validation lives in `/lib/zcash/zcashUtils.ts` but follows +the same `{ valid, reason }` pattern for consistency. + +## Testing Harness +All validators are pure functions - trivial to test: + +```typescript +test('required rejects empty', () => { + expect(required('')).toEqual({ valid: false, reason: 'Required' }); +}); + +test('email validates format', () => { + expect(email('test@example.com').valid).toBe(true); + expect(email('invalid').valid).toBe(false); +}); +``` + +## Adding New Validators +1. Add function to `validators.ts` +2. Return `{ valid: boolean, reason?: string, level?: string }` +3. Make it composable (curry if needs config) +4. Export from `index.ts` diff --git a/lib/validation/index.ts b/lib/validation/index.ts deleted file mode 100644 index ea856f47..00000000 --- a/lib/validation/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Type exports -export type { ValidationResult, Validator } from './types'; - -// Validator function exports -export { - validateRequired, - validateEmail, - validateUrl, - validateMinLength, - validateMaxLength, - validatePattern, - validateMatch, - validateOptional, - validateRange, - validateDigitsOnly, - validateExactLength, - composeValidators, -} from './validators'; - -// React hook exports -export { useValidation, useValidationGroup } from './useValidation'; -export type { UseValidationOptions, UseValidationResult } from './useValidation'; diff --git a/lib/validation/useValidation.ts b/lib/validation/useValidation.ts deleted file mode 100644 index 723813bb..00000000 --- a/lib/validation/useValidation.ts +++ /dev/null @@ -1,453 +0,0 @@ -/** - * React hook for managing validation state - * - * Provides a unified interface for handling form field validation with support for - * validation on change, blur, and manual triggers. - * - * @module useValidation - */ - -'use client'; - -import { useState, useCallback, useMemo } from 'react'; -import type { Validator, ValidationResult } from './types'; - -/** - * Configuration options for the useValidation hook - * - * @template T - The type of value being validated (defaults to string) - * - * @property initialValue - The initial value of the field - * @property validators - Optional array of validator functions to apply - * @property validateOnChange - Whether to validate on every change (default: false) - * @property validateOnBlur - Whether to validate on blur (default: true) - */ -export interface UseValidationOptions { - /** - * Initial value for the field - */ - initialValue: T; - - /** - * Array of validator functions to run against the value - * Validators are run in order, stopping at the first error - */ - validators?: Validator[]; - - /** - * Whether to automatically validate on every value change - * @default false - */ - validateOnChange?: boolean; - - /** - * Whether to automatically validate when field loses focus - * @default true - */ - validateOnBlur?: boolean; -} - -/** - * Return value from the useValidation hook - * - * @template T - The type of value being validated - * - * @property value - Current value of the field - * @property setValue - Function to update the value (with optional validation) - * @property validation - Current validation result - * @property validate - Function to manually trigger validation - * @property reset - Function to reset to initial state - * @property isDirty - Whether the value has been modified from initial - * @property isTouched - Whether the field has been interacted with - * @property setTouched - Function to mark field as touched - */ -export interface UseValidationResult { - /** - * Current value of the field - */ - value: T; - - /** - * Update the field value - * @param value - New value - * @param shouldValidate - Override validateOnChange setting for this update - */ - setValue: (value: T, shouldValidate?: boolean) => void; - - /** - * Current validation state - * Contains { valid: true } if not yet validated or validation passed - */ - validation: ValidationResult; - - /** - * Manually trigger validation - * @returns true if valid, false if invalid - */ - validate: () => boolean; - - /** - * Reset field to initial value and clear validation - */ - reset: () => void; - - /** - * Whether the value has changed from initial value - */ - isDirty: boolean; - - /** - * Whether the field has been interacted with (focused/blurred) - */ - isTouched: boolean; - - /** - * Mark the field as touched - */ - setTouched: (touched: boolean) => void; -} - -/** - * Custom hook for managing field validation state - * - * Handles validation logic, state management, and provides utilities - * for form field validation patterns. - * - * @template T - The type of value being validated (defaults to string) - * @param options - Configuration options - * @returns Validation state and control functions - * - * @example Basic usage - * ```tsx - * function EmailField() { - * const { value, setValue, validation, validate, isTouched, setTouched } = useValidation({ - * initialValue: '', - * validators: [validateRequired('Email is required'), validateEmail()], - * validateOnChange: false, - * validateOnBlur: true - * }); - * - * return ( - *
- * setValue(e.target.value)} - * onBlur={() => { - * setTouched(true); - * validate(); - * }} - * /> - * {isTouched && !validation.valid && ( - *

{validation.reason}

- * )} - *
- * ); - * } - * ``` - * - * @example With composition - * ```tsx - * function PasswordField() { - * const password = useValidation({ - * initialValue: '', - * validators: [ - * validateRequired('Password is required'), - * validateMinLength(8, 'Password must be at least 8 characters') - * ], - * }); - * - * const confirmPassword = useValidation({ - * initialValue: '', - * validators: [ - * validateRequired('Please confirm password'), - * validateMatch(password.value, 'password') - * ], - * }); - * - * const handleSubmit = () => { - * const passwordValid = password.validate(); - * const confirmValid = confirmPassword.validate(); - * if (passwordValid && confirmValid) { - * // Submit form - * } - * }; - * - * return ( - *
- * password.setValue(e.target.value)} - * /> - * confirmPassword.setValue(e.target.value)} - * /> - *
- * ); - * } - * ``` - * - * @example Validation on submit only - * ```tsx - * function UsernameField() { - * const { value, setValue, validation, validate } = useValidation({ - * initialValue: '', - * validators: [ - * validateRequired('Username is required'), - * validateMinLength(3), - * validateMaxLength(20) - * ], - * validateOnChange: false, // Don't validate while typing - * validateOnBlur: false, // Don't validate on blur - * }); - * - * const handleSubmit = () => { - * if (validate()) { - * // Submit - * } - * }; - * - * return ( - *
- * setValue(e.target.value)} /> - * - * {!validation.valid &&

{validation.reason}

} - *
- * ); - * } - * ``` - */ -export function useValidation( - options: UseValidationOptions -): UseValidationResult { - const { - initialValue, - validators = [], - validateOnChange = false, - validateOnBlur = true, - } = options; - - // State - const [value, setValueState] = useState(initialValue); - const [validation, setValidation] = useState({ valid: true }); - const [isTouched, setIsTouched] = useState(false); - - // Check if value has changed from initial - const isDirty = useMemo(() => { - return value !== initialValue; - }, [value, initialValue]); - - /** - * Run all validators against current value - * Returns the first error or { valid: true } - */ - const runValidators = useCallback( - (valueToValidate: T): ValidationResult => { - if (validators.length === 0) { - return { valid: true }; - } - - for (const validator of validators) { - const result = validator(valueToValidate); - if (!result.valid) { - return result; - } - // If valid but has a warning/info level, return it - if (result.level === 'warning' || result.level === 'info') { - return result; - } - } - - return { valid: true }; - }, - [validators] - ); - - /** - * Validate the current value - * Updates validation state and returns validity - */ - const validate = useCallback((): boolean => { - const result = runValidators(value); - setValidation(result); - return result.valid; - }, [value, runValidators]); - - /** - * Update the value with optional validation - */ - const setValue = useCallback( - (newValue: T, shouldValidate?: boolean) => { - setValueState(newValue); - - // Determine if we should validate - const doValidate = shouldValidate ?? validateOnChange; - - if (doValidate) { - const result = runValidators(newValue); - setValidation(result); - } - }, - [validateOnChange, runValidators] - ); - - /** - * Mark field as touched - */ - const setTouched = useCallback( - (touched: boolean) => { - setIsTouched(touched); - - // Validate on blur if enabled and field is being marked as touched - if (touched && validateOnBlur) { - const result = runValidators(value); - setValidation(result); - } - }, - [validateOnBlur, value, runValidators] - ); - - /** - * Reset to initial state - */ - const reset = useCallback(() => { - setValueState(initialValue); - setValidation({ valid: true }); - setIsTouched(false); - }, [initialValue]); - - return { - value, - setValue, - validation, - validate, - reset, - isDirty, - isTouched, - setTouched, - }; -} - -/** - * Helper hook for managing multiple related validations - * - * Useful for forms with multiple fields that need coordinated validation. - * - * @param validations - Object mapping field names to useValidation results - * @returns Object with validation utilities - * - * @example - * ```tsx - * function SignupForm() { - * const username = useValidation({ - * initialValue: '', - * validators: [validateRequired(), validateMinLength(3)] - * }); - * - * const email = useValidation({ - * initialValue: '', - * validators: [validateRequired(), validateEmail()] - * }); - * - * const form = useValidationGroup({ username, email }); - * - * const handleSubmit = () => { - * if (form.validateAll()) { - * // All fields valid, submit form - * console.log(form.values); // { username: '...', email: '...' } - * } - * }; - * - * return ( - *
- * username.setValue(e.target.value)} /> - * email.setValue(e.target.value)} /> - * - *
- * ); - * } - * ``` - */ -export function useValidationGroup>>( - validations: T -) { - /** - * Validate all fields in the group - * @returns true if all fields are valid - */ - const validateAll = useCallback((): boolean => { - let allValid = true; - for (const field of Object.values(validations)) { - const isValid = field.validate(); - if (!isValid) { - allValid = false; - } - } - return allValid; - }, [validations]); - - /** - * Reset all fields in the group - */ - const resetAll = useCallback(() => { - for (const field of Object.values(validations)) { - field.reset(); - } - }, [validations]); - - /** - * Check if all fields are currently valid - */ - const isValid = useMemo(() => { - return Object.values(validations).every((field) => field.validation.valid); - }, [validations]); - - /** - * Check if any field has been modified - */ - const isDirty = useMemo(() => { - return Object.values(validations).some((field) => field.isDirty); - }, [validations]); - - /** - * Check if any field has been touched - */ - const isTouched = useMemo(() => { - return Object.values(validations).some((field) => field.isTouched); - }, [validations]); - - /** - * Get all current values as an object - */ - const values = useMemo(() => { - const result: Record = {}; - for (const [key, field] of Object.entries(validations)) { - result[key] = field.value; - } - return result as { [K in keyof T]: T[K]['value'] }; - }, [validations]); - - /** - * Get all validation results as an object - */ - const validationResults = useMemo(() => { - const result: Record = {}; - for (const [key, field] of Object.entries(validations)) { - result[key] = field.validation; - } - return result as { [K in keyof T]: ValidationResult }; - }, [validations]); - - return { - validateAll, - resetAll, - isValid, - isDirty, - isTouched, - values, - validationResults, - }; -} diff --git a/lib/verification/AGENT.md b/lib/verification/AGENT.md new file mode 100644 index 00000000..5a3ac4df --- /dev/null +++ b/lib/verification/AGENT.md @@ -0,0 +1,121 @@ +# /lib/verification - ZVS Verification Logic + +## Purpose +Server-side logic for Zcash Verification System (ZVS). Users prove address ownership +by sending a transaction with a specific memo, then entering a deterministic OTP. + +## How Verification Works + +1. **Client requests memo** - Calls `generateMemoAction` → server creates session ID, memo, and URI +2. **User sends transaction** - To ZVS address with memo in format `zvs/{session_id},{u-address}` +3. **Backend wallet receives tx** - Decrypts memo, computes OTP, sends ZEC back with OTP in memo +4. **User enters OTP** - Client sends memo + OTP to server for verification +5. **Profile verified** - Server recomputes OTP from memo, marks profile as verified + +## Key Files + +### generateMemoAction.ts +Server action that generates memo + zcash: URI. The session ID and memo are +created server-side so the client can never fabricate memos to brute-force OTPs. +```typescript +"use server" +import { generateMemoAction } from "./generateMemoAction"; + +const result = await generateMemoAction(profileId, "0.003"); +// → { ok: true, memo: "zvs/1234...,u1abc...", uri: "zcash:u1...?amount=0.003&memo=..." } +``` + +### memoStore.ts +In-memory store for server-issued memos. Tracks OTP attempts per memo and +rejects any memo not issued by the server. +- Max 5 OTP attempts per memo (configurable via `MAX_ATTEMPTS`) +- Memos expire after 30 minutes +- On exhaustion, `confirmOtpAction` auto-generates a new memo and returns it +```typescript +import { registerMemo, getMemoEntry, recordFailure, removeMemo } from "./memoStore"; +``` + +### session.ts +Session ID generation and memo building (server-side only). +```typescript +import { generateSessionId, buildZvsMemo, parseZvsMemo } from "./session"; + +const sessionId = generateSessionId(); // 16 random ASCII digits +const memo = buildZvsMemo(sessionId, userAddress); +// → "zvs/1234567890123456,u1abc..." +``` + +### otp.ts +HMAC-SHA256 based OTP generation and verification. +```typescript +import { generateOtp, verifyOtp } from "./otp"; + +const otp = await generateOtp(memo); // 6-digit string +const isValid = await verifyOtp(memo, userInput); // boolean +``` +Requires `ZVS_SECRET_SEED` environment variable (throws in production if missing). + +### confirmOtpAction.ts +Main server action for OTP verification. Checks the memo was server-issued, +enforces the 5-attempt cap, and on exhaustion returns a fresh memo + URI. +```typescript +"use server" +import { confirmOtpAction } from "./confirmOtpAction"; + +// Client passes memo (from server) + OTP (from user input) +const result = await confirmOtpAction(zcasherId, otp, memo, edits?); +// Success: { ok: true, data: { status: "verified" } } +// Failure: { ok: false, data: { status: "invalid" }, error: "...N attempts remaining." } +// Exhausted: { ok: false, data: { status: "exhausted", newMemo, newUri } } +``` + +## Memo Format +``` +zvs/{session_id},{user_address} +``` +- No curly braces in actual memo +- session_id: 16 ASCII digits +- user_address: Full unified address + +Example: +``` +zvs/2026021505421234,u1d9l0a8ldht9zcpkmppd8s9lpev724l5afh3dl9ds8rt09aunghcx7xtnk980e9rjgn5j6jjfxvspm300g65a9sxq3uu68dlrwc8lhvektu7tacrxlm6lh549jed7k0wxpajv7xl46u23v6vzq6ycjg48avwdpfqlrmk4c8ft8qqy3vx5 +``` + +## Security Notes +- OTP is deterministic (HMAC-SHA256) - same memo always produces same OTP +- Secret seed stored in `ZVS_SECRET_SEED` env var (required in production) +- Memo generation is server-side only — client cannot fabricate memos +- In-memory store rejects unrecognised memos and caps OTP attempts at 5 +- After 5 failed attempts the memo is invalidated and a new one is issued +- Memo lives in client React state for display only; if user refreshes they must start over + +## Flow Diagram +``` +Client calls generateMemoAction(profileId, amount) + ↓ + Server: generateSessionId() → buildZvsMemo() → buildZcashUri() + ↓ + Server: registerMemo() in memoStore (tracks attempts) + ↓ + Returns { memo, uri } to client + ↓ + Client stores memo in React state, displays QR + ↓ + User sends transaction with memo from wallet + ↓ + Backend wallet receives tx, computes OTP, sends back + ↓ + User sees OTP in wallet, enters it + ↓ + Client calls confirmOtpAction(zcasherId, otp, memo) + ↓ + Server: getMemoEntry() → reject if unknown/expired + ↓ + Server: verifyOtp(memo, otp) + ↓ + Valid → removeMemo(), update zcasher.address_verified = true + Invalid → recordFailure() + └─ exhausted? → generate new memo, return { newMemo, newUri } + └─ not yet → return "N attempts remaining" +``` diff --git a/lib/verification/confirmOtp.ts b/lib/verification/confirmOtp.ts deleted file mode 100644 index f3ec61e3..00000000 --- a/lib/verification/confirmOtp.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; -import type { OTPConfirmResponse } from "@/lib/api/types"; - -interface SupabaseRpcResult { - data: OTPConfirmResponse | null; - error: Error | null; -} - -export async function confirmOtp(zcasherId: number | string, otp: string): Promise { - const supabase = createSupabaseServerClient(); - if (!supabase) { - return { data: null, error: new Error("Supabase client not available") }; - } - - return supabase.rpc("confirm_otp_sql", { in_zcasher_id: zcasherId, in_otp: otp }); -} diff --git a/lib/verification/confirmOtpAction.ts b/lib/verification/confirmOtpAction.ts index 48e0cc1b..5920eda8 100644 --- a/lib/verification/confirmOtpAction.ts +++ b/lib/verification/confirmOtpAction.ts @@ -1,36 +1,186 @@ "use server"; -import { confirmOtp } from "@/lib/verification/confirmOtp"; -import type { ConfirmOtpResponse } from "@/lib/api/types"; +import { verifyOtp } from "@/lib/verification/otp"; +import { parseZvsMemo } from "@/lib/verification/session"; +import { getMemoEntry, recordFailure, removeMemo, getMaxAttempts } from "@/lib/verification/memoStore"; +import { generateMemoAction } from "@/lib/verification/generateMemoAction"; +import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; +import type { ConfirmOtpResponse, ProfileEditsPayload } from "@/lib/api/types"; +import { derivePlatform } from "@/lib/profile/profileLinks"; /** - * Server Action for confirming OTP - * Used by InlineOtpForm and SubmitOtp components + * Server Action for confirming OTP using HMAC-SHA256 verification. + * + * The memo must have been issued by generateMemoAction (exists in the + * in-memory store). Each memo allows at most 5 OTP attempts — after + * which the server invalidates it and returns a fresh memo + URI so + * the client can restart without a page reload. */ -export async function confirmOtpAction(zcasherId: number | string, otp: string): Promise { +export async function confirmOtpAction( + zcasherId: number | string, + otp: string, + memo: string, + edits?: ProfileEditsPayload +): Promise { try { + // --- Input validation --------------------------------------------------- if (!zcasherId || !otp || typeof otp !== "string" || !otp.trim()) { + return { ok: false, error: "Invalid input", data: { status: "invalid" } }; + } + + if (!memo || typeof memo !== "string" || !memo.trim()) { + return { + ok: false, + error: "Invalid memo. Please generate a new QR code.", + data: { status: "invalid" }, + }; + } + + const profileId = + typeof zcasherId === "string" ? parseInt(zcasherId, 10) : zcasherId; + if (isNaN(profileId)) { + return { ok: false, error: "Invalid profile ID", data: { status: "invalid" } }; + } + + const trimmedMemo = memo.trim(); + + // --- Check memo was server-issued --------------------------------------- + const entry = getMemoEntry(trimmedMemo); + if (!entry) { + return { + ok: false, + error: "Memo expired or not recognised. Please generate a new QR code.", + data: { status: "invalid" }, + }; + } + + // --- Verify OTP --------------------------------------------------------- + const isValid = await verifyOtp(trimmedMemo, otp.trim()); + + if (!isValid) { + // Record the failed attempt; check if exhausted + const exhausted = recordFailure(trimmedMemo); + + if (exhausted) { + // Generate a fresh memo + URI for the same profile & amount + const fresh = await generateMemoAction(profileId, entry.amount); + + return { + ok: false, + error: `Too many attempts. A new QR code has been generated — please send a new transaction.`, + data: { + status: "exhausted", + newMemo: fresh.ok ? fresh.memo : undefined, + newUri: fresh.ok ? fresh.uri : undefined, + }, + }; + } + + const remaining = getMaxAttempts() - entry.attempts - 1; + return { + ok: false, + error: `Invalid verification code. ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`, + data: { status: "invalid" }, + }; + } + + // --- OTP valid — proceed with verification ------------------------------ + removeMemo(trimmedMemo); + + // Parse memo to extract address + const parsed = parseZvsMemo(trimmedMemo); + if (!parsed) { return { ok: false, - error: "Invalid input", + error: "Invalid memo format.", data: { status: "invalid" }, }; } - const { data, error } = await confirmOtp(zcasherId, otp.trim()); + const supabase = createSupabaseServerClient(); + if (!supabase) { + return { + ok: false, + error: "Database connection unavailable", + data: { status: "error" }, + }; + } + + // Verify address matches profile + const { data: profile, error: fetchError } = await supabase + .from("zcasher") + .select("address") + .eq("id", profileId) + .single(); + + if (fetchError || !profile) { + return { ok: false, error: "Profile not found", data: { status: "error" } }; + } + + if (parsed.userAddress !== profile.address) { + return { + ok: false, + error: "Address mismatch. The verification memo does not match this profile.", + data: { status: "invalid" }, + }; + } + + // --- Apply profile update ----------------------------------------------- + const profileUpdate: Record = { address_verified: true }; + + if (edits) { + if (edits.name !== undefined) profileUpdate.name = edits.name; + if (edits.display_name !== undefined) profileUpdate.display_name = edits.display_name; + if (edits.bio !== undefined) profileUpdate.bio = edits.bio; + if (edits.profile_image_url !== undefined) profileUpdate.profile_image_url = edits.profile_image_url; + if (edits.nearest_city_name !== undefined) profileUpdate.nearest_city_name = edits.nearest_city_name; + } + + const { error } = await supabase + .from("zcasher") + .update(profileUpdate) + .eq("id", profileId); if (error) { return { ok: false, - error: error.message || "OTP confirmation failed", + error: error.message || "Failed to verify profile", data: { status: "error" }, }; } - return { - ok: true, - data: data || { status: "unknown" }, - }; + // --- Apply link edits --------------------------------------------------- + if (edits?.links && edits.links.length > 0) { + for (const link of edits.links) { + if (link._delete && link.id) { + await supabase + .from("zcasher_links") + .delete() + .eq("id", link.id) + .eq("zcasher_id", profileId); + } else if (link.id) { + await supabase + .from("zcasher_links") + .update({ + url: link.url, + label: link.label || null, + platform: link.platform ?? derivePlatform(link.url), + }) + .eq("id", link.id) + .eq("zcasher_id", profileId); + } else if (!link._delete) { + await supabase.from("zcasher_links").insert({ + zcasher_id: profileId, + url: link.url, + label: link.label || null, + platform: link.platform ?? derivePlatform(link.url), + is_verified: false, + }); + } + } + } + + return { ok: true, data: { status: "verified" } }; } catch (error) { return { ok: false, diff --git a/lib/verification/generateMemoAction.ts b/lib/verification/generateMemoAction.ts new file mode 100644 index 00000000..e4eb3fc9 --- /dev/null +++ b/lib/verification/generateMemoAction.ts @@ -0,0 +1,67 @@ +"use server"; + +import { generateSessionId, buildZvsMemo } from "@/lib/verification/session"; +import { buildZcashUri } from "@/lib/zcash/zcashUtils"; +import { registerMemo } from "@/lib/verification/memoStore"; +import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; + +const SIGNIN_ADDR = + "u1lff6xhc9p2c3aefrms5624aqd5mdlys87xcu0u0g3rynnjfs4g5nf0u5q8sczex3jctc2xesauktvdr9gd77zauaejje3zrdpj4uppssdmzzu33lfkzc9y0hlq7rt94kt4rqpq6d4h8a0px597htclme3pav3wft4k94u4pqqn3h4dmdp8wcvvumgqak5ynwy7qm6e797t356ud38we"; + +const MIN_AMOUNT = 0.001; + + +interface GenerateMemoResult { + ok: boolean; + memo?: string; + uri?: string; + error?: string; +} + +/** + * Server action: generate a verification memo + zcash: URI. + * + * The session ID and memo are created server-side so the client + * can never fabricate memos to brute-force OTPs. + */ +export async function generateMemoAction( + profileId: number, + amount: string +): Promise { + try { + // Validate amount + const numAmount = parseFloat(amount); + if (!Number.isFinite(numAmount) || numAmount < MIN_AMOUNT) { + return { ok: false, error: `Amount must be at least ${MIN_AMOUNT} ZEC` }; + } + + // Look up profile address + const supabase = createSupabaseServerClient(); + if (!supabase) { + return { ok: false, error: "Database connection unavailable" }; + } + + const { data: profile, error: fetchError } = await supabase + .from("zcasher") + .select("address") + .eq("id", profileId) + .single(); + + if (fetchError || !profile?.address) { + return { ok: false, error: "Profile not found" }; + } + + // Generate server-side memo + const sessionId = generateSessionId(); + const memo = buildZvsMemo(sessionId, profile.address); + const cleanAmount = amount.replace(/[^\d.]/g, ""); + const uri = buildZcashUri(SIGNIN_ADDR, cleanAmount, memo); + + // Register in the in-memory store (tracks attempts) + registerMemo(memo, profileId, cleanAmount); + + return { ok: true, memo, uri }; + } catch { + return { ok: false, error: "Failed to generate verification memo" }; + } +} diff --git a/lib/verification/memoStore.ts b/lib/verification/memoStore.ts new file mode 100644 index 00000000..500aa78e --- /dev/null +++ b/lib/verification/memoStore.ts @@ -0,0 +1,68 @@ +/** + * In-memory store for server-issued verification memos. + * + * Tracks which memos the server has generated and how many OTP + * attempts have been made against each. Memos not in this store + * are rejected — preventing client-crafted memo brute-force attacks. + */ + +interface MemoEntry { + attempts: number; + createdAt: number; + profileId: number; + amount: string; +} + +const store = new Map(); + +const MAX_ATTEMPTS = 5; +const MEMO_TTL_MS = 30 * 60 * 1000; // 30 minutes + +/** Register a newly generated memo as valid. */ +export function registerMemo(memo: string, profileId: number, amount: string): void { + cleanup(); + store.set(memo, { attempts: 0, createdAt: Date.now(), profileId, amount }); +} + +/** Check whether a memo exists and is still valid. */ +export function getMemoEntry(memo: string): MemoEntry | null { + const entry = store.get(memo); + if (!entry) return null; + if (Date.now() - entry.createdAt > MEMO_TTL_MS) { + store.delete(memo); + return null; + } + return entry; +} + +/** + * Record a failed OTP attempt. + * Returns true if the memo is now exhausted (>= MAX_ATTEMPTS). + */ +export function recordFailure(memo: string): boolean { + const entry = store.get(memo); + if (!entry) return false; + entry.attempts += 1; + if (entry.attempts >= MAX_ATTEMPTS) { + store.delete(memo); + return true; + } + return false; +} + +/** Remove a memo (e.g. after successful verification). */ +export function removeMemo(memo: string): void { + store.delete(memo); +} + +export function getMaxAttempts(): number { + return MAX_ATTEMPTS; +} + +/** Evict expired entries. */ +function cleanup(): void { + const now = Date.now(); + for (const [key, entry] of store) { + if (now - entry.createdAt > MEMO_TTL_MS) store.delete(key); + } +} diff --git a/lib/verification/otp.ts b/lib/verification/otp.ts new file mode 100644 index 00000000..dda1be23 --- /dev/null +++ b/lib/verification/otp.ts @@ -0,0 +1,83 @@ +/** + * ZVS OTP Generation using HMAC-SHA256 + * + * The OTP is deterministically generated from the session ID using HMAC-SHA256. + * This matches the ZVS backend (Rust) implementation in otp_rules.rs. + */ + +import { parseZvsMemo } from './session'; + +/** + * Get the secret seed as hex-decoded bytes (matches ZVS backend) + */ +function getSecretSeedBytes(): Uint8Array { + const seed = process.env.ZVS_SECRET_SEED; + if (!seed) { + throw new Error('ZVS_SECRET_SEED environment variable is required'); + } + return hexToBytes(seed); +} + +/** + * Convert hex string to Uint8Array + */ +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Generate a 6-digit OTP from a memo using HMAC-SHA256 + * + * Matches ZVS backend: HMAC-SHA256(secret_bytes, session_id) + * + * @param memo - The ZVS memo string (e.g., "zvs/2026021505421234,u1d9l0a8...") + * @returns 6-digit OTP string + */ +export async function generateOtp(memo: string): Promise { + // Extract session_id from memo (ZVS only hashes session_id, not full memo) + const parsed = parseZvsMemo(memo); + if (!parsed) { + throw new Error('Invalid memo format'); + } + const sessionId = parsed.sessionId; + + const encoder = new TextEncoder(); + // Use .slice() to get a Uint8Array backed by ArrayBuffer (not ArrayBufferLike) + const keyData = getSecretSeedBytes().slice(); + const messageData = encoder.encode(sessionId); + + // Import the secret key for HMAC + const key = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + // Generate HMAC-SHA256 + const signature = await crypto.subtle.sign('HMAC', key, messageData); + const hashArray = new Uint8Array(signature); + + // Extract 6 digits from the hash (matches ZVS: u32::from_be_bytes([0..4]) % 1_000_000) + const code = ((hashArray[0] << 24) | (hashArray[1] << 16) | (hashArray[2] << 8) | hashArray[3]) >>> 0; + const otp = (code % 1000000).toString().padStart(6, '0'); + + return otp; +} + +/** + * Verify an OTP against a memo + * + * @param memo - The ZVS memo string + * @param providedOtp - The OTP provided by the user + * @returns true if the OTP matches + */ +export async function verifyOtp(memo: string, providedOtp: string): Promise { + const expectedOtp = await generateOtp(memo); + return expectedOtp === providedOtp.trim(); +} diff --git a/lib/verification/session.ts b/lib/verification/session.ts new file mode 100644 index 00000000..9e1cef42 --- /dev/null +++ b/lib/verification/session.ts @@ -0,0 +1,47 @@ +/** + * Generate a 16-digit ASCII session ID for ZVS verification + * Uses crypto.getRandomValues for secure random number generation + */ +export function generateSessionId(): string { + const digits = '0123456789'; + const array = new Uint8Array(16); + + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(array); + } else { + // Fallback for environments without crypto (shouldn't happen in modern browsers) + for (let i = 0; i < 16; i++) { + array[i] = Math.floor(Math.random() * 256); + } + } + + let sessionId = ''; + for (let i = 0; i < 16; i++) { + sessionId += digits[array[i] % 10]; + } + + return sessionId; +} + +/** + * Build the ZVS memo string + * Format: zvs/session_id,u-address + * Example: zvs/2026021505421234,u1d9l0a8ldht9zcp... + */ +export function buildZvsMemo(sessionId: string, userAddress: string): string { + return `zvs/${sessionId},${userAddress}`; +} + +/** + * Parse a ZVS memo string + * Returns null if the memo format is invalid + */ +export function parseZvsMemo(memo: string): { sessionId: string; userAddress: string } | null { + const match = memo.match(/^zvs\/(\d{16}),(.+)$/); + if (!match) return null; + + return { + sessionId: match[1], + userAddress: match[2], + }; +} diff --git a/lib/verification/updateLinkVerificationAction.ts b/lib/verification/updateLinkVerificationAction.ts deleted file mode 100644 index 04adcd63..00000000 --- a/lib/verification/updateLinkVerificationAction.ts +++ /dev/null @@ -1,33 +0,0 @@ -"use server"; - -import { updateLinkVerification } from "@/lib/profile/verifyLinkDb"; -import type { VoidActionResult } from "@/lib/actions/types"; -import type { LinkVerificationPayload } from "@/lib/profile/types"; - -/** - * Server Action for updating link verification status - * Used by useVerificationFlow hook - */ -export async function updateLinkVerificationAction( - profileId: number, - handle: string, - variants: string[], - updatePayload: LinkVerificationPayload -): Promise { - try { - if (!profileId || !handle || !Array.isArray(variants) || !updatePayload) { - return { - ok: false, - error: "Invalid input parameters", - }; - } - - await updateLinkVerification(profileId, handle, variants, updatePayload); - return { ok: true }; - } catch (error) { - return { - ok: false, - error: String((error as Error)?.message || error), - }; - } -} diff --git a/lib/zcash/AGENT.md b/lib/zcash/AGENT.md new file mode 100644 index 00000000..f5a41dcd --- /dev/null +++ b/lib/zcash/AGENT.md @@ -0,0 +1,68 @@ +# /lib/zcash - Zcash Utilities + +## Purpose +Core Zcash blockchain utilities: address validation, URI construction, memo encoding. +This is the most critical module for Zcash-specific functionality. + +## Main File: zcashUtils.ts + +### Address Validation + +```typescript +validateZcashAddress(address: string): { + valid: boolean; + addressType: 'unified' | 'sapling' | 'transparent' | 'tex' | 'viewing_key' | 'invalid'; + reason?: string; +} +``` + +**Address Formats:** +| Prefix | Type | Privacy | Recommendation | +|--------|------|---------|----------------| +| `u1` | Unified | High | Recommended | +| `zs1` | Sapling | High | Acceptable | +| `t1`, `t3` | Transparent | None | Show warning | +| `tex1` | TEX | None | Discouraged | +| `uview`, `zview` | Viewing Key | N/A | Reject | + +### URI Construction + +```typescript +buildZcashUri(address: string, amount?: number, memo?: string): string +// Returns: zcash:u1abc...?amount=0.001&memo=base64encoded +``` + +Used for QR codes and wallet deep links. Memo is base64url encoded. + +### Edit Memo Encoding + +```typescript +buildZcashEditMemo(otp: string, edits: ProfileEdits): string +// Returns compact JSON for blockchain memo field (max 512 bytes) +``` + +Format: `{"otp":"123456","edits":{"name":"Alice"}}` +Must fit in Zcash memo field - keep edits minimal. + +### Helper: getZcashAddressHint() +Returns user-friendly guidance for each address type. +Used in UI to educate users about privacy implications. + +## Dependencies +- `bech32` / `bech32m` - Unified/Sapling address decoding +- `bs58check` - Transparent address validation + +## Testing Harness +Pure functions - ideal for unit testing: +```typescript +// Example test +expect(validateZcashAddress('u1abc...')).toEqual({ + valid: true, + addressType: 'unified' +}); +``` + +## Common Patterns +- Always validate before displaying/storing addresses +- Unified addresses preferred - nudge users toward privacy +- Memo encoding must handle UTF-8 properly diff --git a/lib/zcash/zcashUtils.ts b/lib/zcash/zcashUtils.ts index 06f33233..83ac9cb6 100644 --- a/lib/zcash/zcashUtils.ts +++ b/lib/zcash/zcashUtils.ts @@ -69,7 +69,7 @@ interface ZcashValidationResult { } export function validateZcashAddress(address: string = ""): ZcashValidationResult { - const a = (address || "").trim(); + const a = (address ?? "").trim(); if (!a) return { valid: false, type: "none", reason: "empty" }; if (isViewingKey(a)) return { valid: false, type: "viewing_key", reason: "viewing_key" }; if (isTex(a)) return { valid: true, type: "tex", reason: "tex_disallowed" }; diff --git a/package-lock.json b/package-lock.json index 3de3869a..b823739c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@tanstack/react-query": "^5.90.21", "bech32": "^2.0.0", "bs58check": "^4.0.0", + "city-timezones": "^1.3.3", "emojilib": "^4.0.0", "framer-motion": "^12.23.24", "next": "^16.1.6", @@ -2194,6 +2195,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/city-timezones": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/city-timezones/-/city-timezones-1.3.3.tgz", + "integrity": "sha512-tyH1Tje3mee1mWkjerhx/8CLOfTJn6A5L6swAqLRceoToj9bvKNkfcKESoxG9rApXBKxKeZQUQbbzYcoRSJbZw==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", diff --git a/package.json b/package.json index ffe07a2e..6e851dec 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tanstack/react-query": "^5.90.21", "bech32": "^2.0.0", "bs58check": "^4.0.0", + "city-timezones": "^1.3.3", "emojilib": "^4.0.0", "framer-motion": "^12.23.24", "next": "^16.1.6", diff --git a/supabase/migrations/drop-worldcities.sql b/supabase/migrations/drop-worldcities.sql new file mode 100644 index 00000000..c25cb360 --- /dev/null +++ b/supabase/migrations/drop-worldcities.sql @@ -0,0 +1,16 @@ +-- Migration: Remove worldcities table and nearest_city_id column +-- Run in this order. All three are irreversible without a backup. +-- +-- Prerequisites: +-- - Ensure zcasher_searchable view does not select nearest_city_id (recreate if needed) +-- - Ensure no RLS policies reference nearest_city_id +-- - Code no longer references nearest_city_id or worldcities (done in c2bc932) + +-- 1. Remove the FK that ties zcasher.nearest_city_id → worldcities.id +ALTER TABLE zcasher DROP CONSTRAINT zcasher_nearest_city_fk; + +-- 2. Drop the column from every profile row (nearest_city_name already stores the display string) +ALTER TABLE zcasher DROP COLUMN nearest_city_id; + +-- 3. Delete the ~44k-row reference table entirely (city search now uses city-timezones npm package) +DROP TABLE worldcities; diff --git a/ui/AGENT.md b/ui/AGENT.md new file mode 100644 index 00000000..0a8ceff4 --- /dev/null +++ b/ui/AGENT.md @@ -0,0 +1,60 @@ +# /ui - React Components + +## Purpose +Reusable React components organized by feature domain. All UI presentation +lives here - pages in `/app` compose these components. + +## Directory Structure + +| Folder | Purpose | +|--------|---------| +| `/common` | Design system: buttons, forms, modals, layout | +| `/profile` | Profile cards, editors, avatars, badges | +| `/signup` | Profile creation form components | +| `/verification` | OTP input, QR codes, verification flows | +| `/swap` | Swap composer, token selection, quotes | +| `/thread` | Discussion board, message cards | +| `/messaging` | Memo composer with emoji support | +| `/social` | Social link verification UI | +| `/ns-directory` | Network School specific components | +| `/styles` | Shared style utilities | + +## Component Conventions + +### File Naming +- `ComponentName.tsx` - Main component +- `componentUtils.ts` - Helper functions +- `componentTypes.ts` - TypeScript interfaces +- `useComponentHook.ts` - Custom hooks + +### Client vs Server Components +```typescript +// Server component (default) +export function ProfileCard({ profile }) { ... } + +// Client component (when needed) +'use client'; +export function InteractiveForm() { ... } +``` + +Use `'use client'` only when component needs: +- Event handlers (onClick, onChange) +- Hooks (useState, useEffect) +- Browser APIs + +## Styling +- TailwindCSS 4 with utility classes +- No CSS modules or styled-components +- Framer Motion for animations + +## Zcash UI Patterns +- QR codes use `zcash:` URI scheme +- Address inputs validate on blur +- Privacy warnings for transparent addresses +- Unified address (u1...) shown prominently + +## Adding Components +1. Create in appropriate feature folder +2. Export from folder's `index.ts` +3. Use `/common` components for consistency +4. Add to design-system page if reusable diff --git a/ui/common/AGENT.md b/ui/common/AGENT.md new file mode 100644 index 00000000..7310277b --- /dev/null +++ b/ui/common/AGENT.md @@ -0,0 +1,87 @@ +# /ui/common - Design System + +## Purpose +Core design system components. Building blocks for all UI in zcash.me. +~3500 LOC of reusable, accessible components. + +## Components + +### Forms +| Component | Purpose | +|-----------|---------| +| `Input` | Text input with validation states | +| `TextArea` | Multi-line text input | +| `Checkbox` | Checkbox with label | +| `Select` | Native select dropdown | +| `Dropdown` | Custom dropdown with search | +| `FormField` | Wrapper with label and error | + +### Buttons +| Component | Purpose | +|-----------|---------| +| `Button` | Primary action button | +| `IconButton` | Icon-only button | +| `CopyButton` | Copy-to-clipboard with feedback | + +### Layout +| Component | Purpose | +|-----------|---------| +| `Card` | Content container with shadow | +| `Section` | Page section with heading | +| `Divider` | Visual separator | + +### Modals +| Component | Purpose | +|-----------|---------| +| `Modal` | Base modal component | +| `ModalHeader` | Modal title bar | +| `ModalBody` | Modal content area | +| `ModalFooter` | Modal action buttons | +| `ConfirmDialog` | Yes/No confirmation | +| `TutorialModal` | Large tutorial/onboarding | +| `ModalPortal` | Portal for modal rendering | + +### Feedback +| Component | Purpose | +|-----------|---------| +| `Alert` | Info/warning/error messages | +| `Badge` | Status indicators | +| `Spinner` | Loading indicator | + +### Utilities +| Component | Purpose | +|-----------|---------| +| `HelpIcon` | Tooltip trigger icon | +| `Transitions` | Animation wrappers | + +## Usage Pattern +```typescript +// Direct imports - no barrel exports +import Button from '@/ui/common/buttons/Button'; +import Input from '@/ui/common/forms/Input'; +import Modal from '@/ui/common/modals/Modal'; +import Card from '@/ui/common/layout/Card'; + + + + + +``` + +## Styling Conventions +- TailwindCSS utilities +- Consistent spacing scale (4px base) +- Color palette via Tailwind config +- Responsive: mobile-first + +## Testing Harness +Visit `/app/design-system` to see all components rendered. +Good for visual regression testing. + +## Adding Components +1. Create `ComponentName.tsx` in appropriate subdirectory +2. Use default export +3. Add example to design-system page +4. Keep API minimal - props over config + +Note: This design system uses direct imports (no barrel exports) for better tree-shaking and build performance. diff --git a/ui/common/animations/index.ts b/ui/common/animations/index.ts deleted file mode 100644 index 63575246..00000000 --- a/ui/common/animations/index.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Animation Variants - * - * Pre-configured Framer Motion animation variants for consistent transitions - * throughout the application. - * - * ## Philosophy - * - * - **Fast & Responsive**: Durations range from 0.15s to 0.22s for snappy feel - * - **Subtle Motion**: Small offsets (20-40px) and scale changes (0.95-1.0) - * - **Purposeful**: Each variant serves a specific UI pattern - * - **Accessible**: All animations respect prefers-reduced-motion (handled by Framer Motion) - * - * ## Usage Patterns - * - * ### Basic Animation - * ```tsx - * import { motion } from 'framer-motion' - * import { fadeIn } from '@/ui/common/animations' - * - * - * Content - * - * ``` - * - * ### With AnimatePresence - * ```tsx - * import { motion, AnimatePresence } from 'framer-motion' - * import { scaleIn } from '@/ui/common/animations' - * - * - * {isVisible && ( - * - * Content - * - * )} - * - * ``` - * - * ### With Custom Direction - * ```tsx - * import { motion, AnimatePresence } from 'framer-motion' - * import { slideIn } from '@/ui/common/animations' - * - * const [step, setStep] = useState(0) - * const [direction, setDirection] = useState(1) - * - * - * - * Step {step} - * - * - * ``` - * - * ### Modal with Backdrop - * ```tsx - * import { motion, AnimatePresence } from 'framer-motion' - * import { modalVariant, backdropVariant } from '@/ui/common/animations' - * - * - * {isOpen && ( - * <> - * - * - *
- * Modal content - *
- *
- * - * )} - *
- * ``` - * - * ## Animation Variants - * - * - `fadeIn` - Simple opacity fade - * - `scaleIn` - Scale + opacity (cards, tooltips) - * - `slideIn` - Horizontal slide with direction (wizards, carousels) - * - `slideUp` - Vertical slide from bottom (notifications) - * - `slideDown` - Vertical slide from top (dropdowns) - * - `expandCollapse` - Height-based expansion (accordions) - * - `modalVariant` - Scale + opacity for modals - * - `backdropVariant` - Fade for modal backdrops - * - * ## Reference Implementations - * - * - ui/signup/StepContainer.tsx - slideIn usage - * - ui/verification/SubmitOtp.tsx - modal with backdrop - * - ui/profile/VerifiedBadge.tsx - CSS-based expand/collapse - * - * @module ui/common/animations - */ - -export { - fadeIn, - scaleIn, - slideIn, - slideUp, - slideDown, - expandCollapse, - modalVariant, - backdropVariant -} from './transitions' diff --git a/ui/common/animations/transitions.ts b/ui/common/animations/transitions.ts index e6274e41..029b9e86 100644 --- a/ui/common/animations/transitions.ts +++ b/ui/common/animations/transitions.ts @@ -8,7 +8,7 @@ import type { Variants } from 'framer-motion' * @example * ```tsx * import { motion } from 'framer-motion' - * import { fadeIn } from '@/ui/common/animations' + * import { fadeIn } from '@/ui/common/animations/transitions' * * * ``` * - * @see ui/verification/SubmitOtp.tsx for modal implementation (uses Tailwind animate-in) + * @see ui/verification/VerifyProfileModal.tsx for modal implementation (uses Tailwind animate-in) */ export const modalVariant: Variants = { initial: { scale: 0.95, opacity: 0 }, @@ -247,7 +247,7 @@ export const modalVariant: Variants = { * @example * ```tsx * import { motion } from 'framer-motion' - * import { backdropVariant } from '@/ui/common/animations' + * import { backdropVariant } from '@/ui/common/animations/transitions' * * - -
- ); -} - -// Example 2: Searchable dropdown with icons -export function SearchableDropdownExample() { - const [token, setToken] = useState(); - - const tokenOptions: DropdownOption[] = [ - { - id: "btc", - label: "Bitcoin", - description: "BTC", - icon: ( -
- ₿ -
- ), - }, - { - id: "eth", - label: "Ethereum", - description: "ETH", - icon: ( -
- Ξ -
- ), - }, - { - id: "zec", - label: "Zcash", - description: "ZEC", - icon: ( -
- Z -
- ), - }, - ]; - - return ( -
- -
- ); -} - -// Example 3: Dropdown with numeric IDs -export function NumericIdDropdownExample() { - const [selectedId, setSelectedId] = useState(); - - const numericOptions: DropdownOption[] = [ - { id: 1, label: "Option 1", description: "First option" }, - { id: 2, label: "Option 2", description: "Second option" }, - { id: 3, label: "Option 3", description: "Third option" }, - ]; - - return ( -
- - options={numericOptions} - value={selectedId} - onChange={setSelectedId} - placeholder="Select an option" - /> -
- ); -} - -// Example 4: Dropdown with custom filter function -export function CustomFilterDropdownExample() { - const [country, setCountry] = useState(); - - const countryOptions: DropdownOption[] = [ - { id: "us", label: "United States", description: "North America" }, - { id: "uk", label: "United Kingdom", description: "Europe" }, - { id: "jp", label: "Japan", description: "Asia" }, - { id: "au", label: "Australia", description: "Oceania" }, - ]; - - // Custom filter that searches both label and id - const customFilter = (option: DropdownOption, search: string) => { - const searchLower = search.toLowerCase(); - return ( - option.label.toLowerCase().includes(searchLower) || - option.id.toLowerCase().includes(searchLower) || - (option.description?.toLowerCase().includes(searchLower) ?? false) - ); - }; - - return ( -
- -
- ); -} - -// Example 5: Dropdown with custom option rendering -export function CustomRenderDropdownExample() { - const [user, setUser] = useState(); - - interface UserOption extends DropdownOption { - email?: string; - avatar?: string; - } - - const userOptions: UserOption[] = [ - { - id: "1", - label: "John Doe", - email: "john@example.com", - avatar: "👤", - }, - { - id: "2", - label: "Jane Smith", - email: "jane@example.com", - avatar: "👤", - }, - ]; - - return ( -
- { - const userOption = option as UserOption; - return ( -
- {userOption.avatar} -
-
{option.label}
-
- {userOption.email} -
-
-
- ); - }} - placeholder="Select user" - /> -
- ); -} - -// Example 6: Dropdown with label and error -export function LabeledDropdownExample() { - const [category, setCategory] = useState(); - const [error, setError] = useState(); - - const categoryOptions: DropdownOption[] = [ - { id: "tech", label: "Technology" }, - { id: "health", label: "Healthcare" }, - { id: "finance", label: "Finance" }, - ]; - - const handleSubmit = () => { - if (!category) { - setError("Please select a category"); - } else { - setError(undefined); - // Handle submit - } - }; - - return ( -
- { - setCategory(val); - setError(undefined); - }} - error={error} - placeholder="Select a category" - /> - -
- ); -} - -// Example 7: Disabled dropdown -export function DisabledDropdownExample() { - const options: DropdownOption[] = [ - { id: "1", label: "Option 1" }, - { id: "2", label: "Option 2", disabled: true }, - { id: "3", label: "Option 3" }, - ]; - - return ( -
- {}} - placeholder="Select option" - disabled - label="Disabled Dropdown" - /> - - {}} - placeholder="Enabled with disabled option" - label="Options with disabled items" - /> -
- ); -} diff --git a/ui/common/forms/FieldMessages.tsx b/ui/common/forms/FieldMessages.tsx new file mode 100644 index 00000000..90a7f984 --- /dev/null +++ b/ui/common/forms/FieldMessages.tsx @@ -0,0 +1,47 @@ +interface FieldMessagesProps { + id?: string; + showValidation: boolean; + hasError: boolean; + hasInfo: boolean; + displayMessage?: string; + infoMessage?: string; +} + +/** + * Shared message display for form fields (error, info, static info) + */ +export default function FieldMessages({ + id, + showValidation, + hasError, + hasInfo, + displayMessage, + infoMessage, +}: FieldMessagesProps) { + const messageId = id ? `${id}-message` : undefined; + + return ( + <> + {/* Error message */} + {showValidation && hasError && displayMessage && ( + + )} + + {/* Info message */} + {showValidation && hasInfo && displayMessage && ( +

+ {displayMessage} +

+ )} + + {/* Static info message */} + {!hasError && !hasInfo && infoMessage && ( +

+ {infoMessage} +

+ )} + + ); +} diff --git a/ui/common/forms/Input.tsx b/ui/common/forms/Input.tsx index 4b1981cc..21371085 100644 --- a/ui/common/forms/Input.tsx +++ b/ui/common/forms/Input.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useEffect } from "react"; import type { InputHTMLAttributes } from "react"; +import { useFieldValidation } from "./useFieldValidation"; +import FieldMessages from "./FieldMessages"; /** * Base text input component with validation states and sizing options. @@ -95,39 +96,16 @@ export default function Input({ disabled = false, ...props }: InputProps) { - const [validationState, setValidationState] = useState<{ - valid: boolean; - reason: string | null; - }>({ valid: true, reason: null }); - - useEffect(() => { - if (!validate || !value) { - setValidationState({ valid: true, reason: null }); - return; - } - const result = validate(value.trim()); - setValidationState({ - valid: result.valid, - reason: result.reason || null, - }); - }, [value, validate]); - - const hasError = error || (showValidation && !validationState.valid); - const hasInfo = !hasError && validationState.valid && validationState.reason; - const displayMessage = errorMessage || validationState.reason || infoMessage; - - const getBorderClass = () => { - if (readOnly || disabled) { - return "border-black/40 bg-gray-100 text-gray-500 cursor-not-allowed"; - } - if (hasError) { - return "border-red-400 focus:border-red-500"; - } - if (hasInfo) { - return "border-blue-400 focus:border-blue-500"; - } - return "border-black/30 focus:border-blue-600"; - }; + const { hasError, hasInfo, displayMessage, getBorderClass } = useFieldValidation({ + value, + error, + errorMessage, + infoMessage, + validate, + showValidation, + readOnly, + disabled, + }); return (
@@ -150,36 +128,14 @@ export default function Input({ {...props} /> - {/* Error message */} - {showValidation && hasError && displayMessage && ( - - )} - - {/* Info message */} - {showValidation && hasInfo && displayMessage && ( -

- {displayMessage} -

- )} - - {/* Static info message */} - {!hasError && !hasInfo && infoMessage && ( -

- {infoMessage} -

- )} +
); } diff --git a/ui/common/forms/TextArea.tsx b/ui/common/forms/TextArea.tsx index 092234be..89892a7d 100644 --- a/ui/common/forms/TextArea.tsx +++ b/ui/common/forms/TextArea.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useEffect } from "react"; import type { TextareaHTMLAttributes } from "react"; +import { useFieldValidation } from "./useFieldValidation"; +import FieldMessages from "./FieldMessages"; /** * Multi-line text input component with validation states and sizing options. @@ -99,39 +100,16 @@ export default function TextArea({ disabled = false, ...props }: TextAreaProps) { - const [validationState, setValidationState] = useState<{ - valid: boolean; - reason: string | null; - }>({ valid: true, reason: null }); - - useEffect(() => { - if (!validate || !value) { - setValidationState({ valid: true, reason: null }); - return; - } - const result = validate(value.trim()); - setValidationState({ - valid: result.valid, - reason: result.reason || null, - }); - }, [value, validate]); - - const hasError = error || (showValidation && !validationState.valid); - const hasInfo = !hasError && validationState.valid && validationState.reason; - const displayMessage = errorMessage || validationState.reason || infoMessage; - - const getBorderClass = () => { - if (readOnly || disabled) { - return "border-black/40 bg-gray-100 text-gray-500 cursor-not-allowed"; - } - if (hasError) { - return "border-red-400 focus:border-red-500"; - } - if (hasInfo) { - return "border-blue-400 focus:border-blue-500"; - } - return "border-black/30 focus:border-blue-600"; - }; + const { hasError, hasInfo, displayMessage, getBorderClass } = useFieldValidation({ + value, + error, + errorMessage, + infoMessage, + validate, + showValidation, + readOnly, + disabled, + }); return (
@@ -155,36 +133,14 @@ export default function TextArea({ {...props} /> - {/* Error message */} - {showValidation && hasError && displayMessage && ( - - )} - - {/* Info message */} - {showValidation && hasInfo && displayMessage && ( -

- {displayMessage} -

- )} - - {/* Static info message */} - {!hasError && !hasInfo && infoMessage && ( -

- {infoMessage} -

- )} +
); } diff --git a/ui/common/forms/index.ts b/ui/common/forms/index.ts deleted file mode 100644 index 4176b413..00000000 --- a/ui/common/forms/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Form Components - * - * Reusable form components including dropdowns, inputs, and other - * interactive form elements. - * - * @example - * ```tsx - * import { Dropdown, Input, FormField } from '@/ui/common/forms' - * - * function MyForm() { - * return ( - * - * - * - * ) - * } - * ``` - */ - -// Dropdown components (existing) -export { default as Dropdown } from "./Dropdown"; -export type { DropdownProps } from "./Dropdown"; - -export { default as DropdownOptionComponent } from "./DropdownOption"; -export type { - DropdownOption, - DropdownOptionComponentProps, -} from "./DropdownOption"; - -// Input components (Phase 2.1) -export { default as Input } from "./Input"; -export type { InputProps } from "./Input"; - -export { default as TextArea } from "./TextArea"; -export type { TextAreaProps } from "./TextArea"; - -export { default as Select } from "./Select"; -export type { SelectProps, SelectOption } from "./Select"; - -export { default as Checkbox } from "./Checkbox"; -export type { CheckboxProps } from "./Checkbox"; - -export { default as FormField } from "./FormField"; -export type { FormFieldProps } from "./FormField"; diff --git a/ui/styles/fields.ts b/ui/common/forms/styles.ts similarity index 100% rename from ui/styles/fields.ts rename to ui/common/forms/styles.ts diff --git a/ui/common/forms/useFieldValidation.ts b/ui/common/forms/useFieldValidation.ts new file mode 100644 index 00000000..a8def87e --- /dev/null +++ b/ui/common/forms/useFieldValidation.ts @@ -0,0 +1,86 @@ +import { useState, useEffect } from "react"; + +export interface ValidationResult { + valid: boolean; + reason?: string | null; +} + +export interface FieldValidationState { + valid: boolean; + reason: string | null; +} + +export interface UseFieldValidationOptions { + value: string; + error?: boolean; + errorMessage?: string; + infoMessage?: string; + validate?: (value: string) => ValidationResult; + showValidation?: boolean; + readOnly?: boolean; + disabled?: boolean; +} + +export interface UseFieldValidationResult { + validationState: FieldValidationState; + hasError: boolean; + hasInfo: boolean; + displayMessage: string | undefined; + getBorderClass: () => string; +} + +/** + * Shared validation logic for form fields (Input, TextArea, etc.) + */ +export function useFieldValidation({ + value, + error = false, + errorMessage, + infoMessage, + validate, + showValidation = false, + readOnly = false, + disabled = false, +}: UseFieldValidationOptions): UseFieldValidationResult { + const [validationState, setValidationState] = useState({ + valid: true, + reason: null, + }); + + useEffect(() => { + if (!validate || !value) { + setValidationState({ valid: true, reason: null }); + return; + } + const result = validate(value.trim()); + setValidationState({ + valid: result.valid, + reason: result.reason || null, + }); + }, [value, validate]); + + const hasError = error || (showValidation && !validationState.valid); + const hasInfo = !hasError && validationState.valid && Boolean(validationState.reason); + const displayMessage = errorMessage || validationState.reason || infoMessage; + + const getBorderClass = () => { + if (readOnly || disabled) { + return "border-black/40 bg-gray-100 text-gray-500 cursor-not-allowed"; + } + if (hasError) { + return "border-red-400 focus:border-red-500"; + } + if (hasInfo) { + return "border-blue-400 focus:border-blue-500"; + } + return "border-black/30 focus:border-blue-600"; + }; + + return { + validationState, + hasError, + hasInfo, + displayMessage, + getBorderClass, + }; +} diff --git a/ui/common/index.ts b/ui/common/index.ts deleted file mode 100644 index c3d0ac8f..00000000 --- a/ui/common/index.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Common UI Components Library - * - * A comprehensive design system providing reusable, accessible components - * with consistent styling across the application. - * - * @example - * ```tsx - * import { Button, Card, Badge } from '@/ui/common' - * - * function MyComponent() { - * return ( - * - * Active - * - * - * ) - * } - * ``` - */ - -// Buttons -export { default as Button } from "./buttons/Button"; -export type { ButtonProps } from "./buttons/Button"; - -export { default as CopyButton } from "./buttons/CopyButton"; -export type { CopyButtonProps } from "./buttons/CopyButton"; - -export { default as IconButton } from "./buttons/IconButton"; -export type { IconButtonProps } from "./buttons/IconButton"; - -// Layout -export { default as Card } from "./layout/Card"; -export type { CardProps } from "./layout/Card"; - -export { default as Section } from "./layout/Section"; -export type { SectionProps } from "./layout/Section"; - -export { default as Divider } from "./layout/Divider"; -export type { DividerProps } from "./layout/Divider"; - -// Feedback -export { default as Badge } from "./feedback/Badge"; -export type { BadgeProps } from "./feedback/Badge"; - -export { default as Spinner } from "./feedback/Spinner"; -export type { SpinnerProps } from "./feedback/Spinner"; - -export { default as Alert } from "./feedback/Alert"; -export type { AlertProps } from "./feedback/Alert"; - -// Forms -export { default as Dropdown } from "./forms/Dropdown"; -export type { DropdownProps } from "./forms/Dropdown"; - -export { default as DropdownOptionComponent } from "./forms/DropdownOption"; -export type { - DropdownOption, - DropdownOptionComponentProps, -} from "./forms/DropdownOption"; - -export { default as Input } from "./forms/Input"; -export type { InputProps } from "./forms/Input"; - -export { default as TextArea } from "./forms/TextArea"; -export type { TextAreaProps } from "./forms/TextArea"; - -export { default as Select } from "./forms/Select"; -export type { SelectProps, SelectOption } from "./forms/Select"; - -export { default as Checkbox } from "./forms/Checkbox"; -export type { CheckboxProps } from "./forms/Checkbox"; - -export { default as FormField } from "./forms/FormField"; -export type { FormFieldProps } from "./forms/FormField"; - -// Modals -export { default as Modal } from "./modals/Modal"; -export type { ModalProps } from "./modals/Modal"; - -export { default as ModalHeader } from "./modals/ModalHeader"; -export type { ModalHeaderProps } from "./modals/ModalHeader"; - -export { default as ModalBody } from "./modals/ModalBody"; -export type { ModalBodyProps } from "./modals/ModalBody"; - -export { default as ModalFooter } from "./modals/ModalFooter"; -export type { ModalFooterProps } from "./modals/ModalFooter"; - -export { default as ConfirmDialog } from "./modals/ConfirmDialog"; -export type { ConfirmDialogProps } from "./modals/ConfirmDialog"; - -export { default as TutorialModal } from "./modals/TutorialModal"; -export type { TutorialModalProps, TutorialStep } from "./modals/TutorialModal"; - -// Animations -export { - fadeIn, - scaleIn, - slideIn, - slideUp, - slideDown, - expandCollapse, - modalVariant, - backdropVariant -} from "./animations"; - -// Existing common components -export { default as HelpIcon } from "./HelpIcon"; -export { default as ModalPortal } from "./ModalPortal"; diff --git a/ui/common/layout/index.ts b/ui/common/layout/index.ts deleted file mode 100644 index ebaffcb3..00000000 --- a/ui/common/layout/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Layout components module - * - * Provides container and structural components for consistent page layouts. - */ - -export { default as Card } from "./Card"; -export type { CardProps } from "./Card"; - -export { default as Section } from "./Section"; -export type { SectionProps } from "./Section"; - -export { default as Divider } from "./Divider"; -export type { DividerProps } from "./Divider"; diff --git a/ui/common/modals/examples.tsx b/ui/common/modals/examples.tsx deleted file mode 100644 index 1d2aec7e..00000000 --- a/ui/common/modals/examples.tsx +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Modal Component Examples - * - * This file contains example implementations of the modal components. - * These are for reference only and should not be imported in production code. - */ - -"use client"; - -import { useState } from "react"; -import Modal from "./Modal"; -import ModalHeader from "./ModalHeader"; -import ModalBody from "./ModalBody"; -import ModalFooter from "./ModalFooter"; -import ConfirmDialog from "./ConfirmDialog"; -import Button from "@/ui/common/buttons/Button"; -import Input from "@/ui/common/forms/Input"; - -// Example 1: Basic Modal -export function BasicModalExample() { - const [isOpen, setIsOpen] = useState(false); - - return ( - <> - - - setIsOpen(false)} size="md"> - setIsOpen(false)} /> - -

This is a basic modal with header, body, and footer.

-
- - - -
- - ); -} - -// Example 2: Form Modal -export function FormModalExample() { - const [isOpen, setIsOpen] = useState(false); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - - const handleSubmit = () => { - console.log("Submitted:", { name, email }); - setIsOpen(false); - }; - - return ( - <> - - - setIsOpen(false)} size="lg"> - setIsOpen(false)} /> - -
- - -
-
- - -
-
- - - - -
- - ); -} - -// Example 3: Danger Confirmation -export function DeleteConfirmExample() { - const [isOpen, setIsOpen] = useState(false); - - const handleDelete = async () => { - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 1000)); - console.log("Item deleted"); - setIsOpen(false); - }; - - return ( - <> - - - setIsOpen(false)} - onConfirm={handleDelete} - title="Delete Item" - message="Are you sure you want to delete this item? This action cannot be undone." - variant="danger" - confirmText="Delete" - cancelText="Cancel" - /> - - ); -} - -// Example 4: Warning Dialog -export function WarningDialogExample() { - const [isOpen, setIsOpen] = useState(false); - - return ( - <> - - - setIsOpen(false)} - onConfirm={() => { - console.log("Leaving without saving"); - setIsOpen(false); - }} - title="Unsaved Changes" - message={ -
-

You have unsaved changes that will be lost.

-

Do you want to continue without saving?

-
- } - variant="warning" - confirmText="Leave Without Saving" - cancelText="Go Back" - /> - - ); -} - -// Example 5: Non-dismissible Modal -export function ProcessingModalExample() { - const [isProcessing, setIsProcessing] = useState(false); - - const startProcessing = () => { - setIsProcessing(true); - setTimeout(() => setIsProcessing(false), 3000); - }; - - return ( - <> - - - {}} - closeOnBackdrop={false} - closeOnEscape={false} - size="sm" - > - - -
-
-

Please wait...

-
- - - - ); -} - -// Example 6: Large Scrollable Content -export function ScrollableModalExample() { - const [isOpen, setIsOpen] = useState(false); - - const longContent = Array.from({ length: 30 }, (_, i) => ( -

- This is paragraph {i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. -

- )); - - return ( - <> - - - setIsOpen(false)} size="xl"> - setIsOpen(false)} /> - {longContent} - - - - - - ); -} - -// Example 7: Multiple Size Variants -export function SizeVariantsExample() { - const [openSize, setOpenSize] = useState<"xs" | "sm" | "md" | "lg" | "xl" | null>(null); - - return ( - <> -
- {(["xs", "sm", "md", "lg", "xl"] as const).map((size) => ( - - ))} -
- - {openSize && ( - setOpenSize(null)} size={openSize}> - setOpenSize(null)} /> - -

This is a {openSize} sized modal.

-
- - - -
- )} - - ); -} - -// Example 8: Custom Styled Modal -export function CustomStyledModalExample() { - const [isOpen, setIsOpen] = useState(false); - - return ( - <> - - - setIsOpen(false)} - size="lg" - className="bg-gradient-to-br from-purple-50 to-pink-50" - > - 🎉 Special Offer!} - onClose={() => setIsOpen(false)} - /> - -

Limited Time Deal

-

- Get 50% off your first month when you sign up today! -

-
    -
  • Full access to all features
  • -
  • Priority support
  • -
  • Cancel anytime
  • -
-
- - - - -
- - ); -} diff --git a/ui/common/modals/index.ts b/ui/common/modals/index.ts deleted file mode 100644 index 23af0fdf..00000000 --- a/ui/common/modals/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Modal Components - * - * A complete modal system with base components and pre-built dialogs. - * - * ## Components - * - * - **Modal**: Base modal with backdrop, animations, and size variants - * - **ModalHeader**: Header section with title and optional close button - * - **ModalBody**: Scrollable content area - * - **ModalFooter**: Footer section for action buttons - * - **ConfirmDialog**: Pre-built confirmation dialog - * - * @example Basic Modal - * ```tsx - * import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/ui/common/modals'; - * import { Button } from '@/ui/common/buttons'; - * - * function MyModal({ isOpen, onClose }) { - * return ( - * - * - * - *

This is a basic modal.

- *
- * - * - * - *
- * ); - * } - * ``` - * - * @example Confirmation Dialog - * ```tsx - * import { ConfirmDialog } from '@/ui/common/modals'; - * - * function DeleteConfirm({ isOpen, onClose, onDelete }) { - * return ( - * - * ); - * } - * ``` - */ - -// Base modal components -export { default as Modal } from "./Modal"; -export type { ModalProps } from "./Modal"; - -export { default as ModalHeader } from "./ModalHeader"; -export type { ModalHeaderProps } from "./ModalHeader"; - -export { default as ModalBody } from "./ModalBody"; -export type { ModalBodyProps } from "./ModalBody"; - -export { default as ModalFooter } from "./ModalFooter"; -export type { ModalFooterProps } from "./ModalFooter"; - -// Pre-built dialogs -export { default as ConfirmDialog } from "./ConfirmDialog"; -export type { ConfirmDialogProps } from "./ConfirmDialog"; - -export { default as TutorialModal } from "./TutorialModal"; -export type { TutorialModalProps, TutorialStep } from "./TutorialModal"; diff --git a/ui/links/AGENT.md b/ui/links/AGENT.md new file mode 100644 index 00000000..aa8840cd --- /dev/null +++ b/ui/links/AGENT.md @@ -0,0 +1,175 @@ +# /ui/links - Social OAuth Verification + +## Purpose +OAuth-based social link verification. Users connect social accounts (X, GitHub, Discord, LinkedIn) +to prove ownership. **Requires ZVS address verification first** - only wallet-verified profiles +can verify social links. + +## Prerequisite: ZVS Verification +Before connecting social accounts, users must verify their Zcash address via ZVS (wallet signing). +This ensures only legitimate profile owners can claim social identities. + +See `/lib/verification/AGENT.md` for the ZVS flow. + +## OAuth Flow + +### 1. Initiation +User clicks the gray "Not Authenticated" badge on a social link in the **profile card front**. + +```typescript +import { connectSocial } from "./connect"; + +await connectSocial("twitter", { + profileId: 123, + returnPath: window.location.pathname +}); +// → Redirects to X OAuth consent screen +// → On success, returns to same page +``` + +### 2. Callback Handling +After OAuth redirect, `ProfileCard` handles the callback via `useConnectCallback`. + +```typescript +import { useConnectCallback } from "./useConnectCallback"; + +useConnectCallback({ + profileId: 123, + onConnected: async (link) => { + // link = { url, provider, handle, username, avatarUrl } + // Immediately persist via upsertVerifiedLink server action + await upsertVerifiedLink(profileId, link.url); + // Update local state + router.refresh() + }, + onError: (error) => console.error(error) +}); +``` + +### 3. Persistence +Verified links are persisted immediately on OAuth callback via `upsertVerifiedLink` server action — no OTP save required. + +## Key Files + +| File | Purpose | +|------|---------| +| `providers.ts` | Provider configs, handle extraction, URL building, `detectProviderFromUrl`, `extractHandleFromUrl` | +| `connect.ts` | Initiates OAuth via Supabase | +| `useConnectCallback.ts` | Client hook for handling OAuth redirect (consumed by `ProfileCard`) | +| `verifyLink.ts` | Server action: validates OAuth identity server-side, upserts verified link with `platform` column | + +## Supported Providers + +| Provider | Key | Handle Source | Profile URL | +|----------|-----|---------------|-------------| +| X / Twitter | `twitter` | `username`, `screen_name` | `https://x.com/{handle}` | +| GitHub | `github` | `login` | `https://github.com/{handle}` | +| Discord | `discord` | `id` | `https://discord.com/users/{id}` | +| LinkedIn | `linkedin_oidc` | `vanityName` | `https://linkedin.com/in/{handle}` | + +## Provider Configuration + +```typescript +import { PROVIDERS, getProviderByKey, detectProviderFromUrl, extractHandleFromUrl } from "./providers"; + +const twitter = PROVIDERS.twitter; +twitter.getHandle(identityData); // Extract handle from OAuth response +twitter.buildUrl(handle); // Build profile URL +twitter.getAvatarUrl?.(identityData); // Extract avatar (optional) +twitter.getUsername?.(identityData); // Extract display name (optional) + +detectProviderFromUrl(url); // URL → provider key (e.g. "twitter") +extractHandleFromUrl(url); // URL → handle string +``` + +## Avatar Fetching ("Use Avatar" button) + +Avatar fetching is handled inline in `ProfileEditor` via public APIs — no OAuth session required: +- **GitHub**: `https://github.com/{handle}.png` +- **X / Twitter**: `https://unavatar.io/x/{handle}` +- **Discord / LinkedIn**: Not supported (no public avatar API) + +The button appears on verified links in the profile editor. It extracts the handle from +the link URL and sets `profile_image_url` directly. + +## Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PREREQUISITE: ZVS Address Verification (wallet signing) │ +│ User must have zcasher.verified = true │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 1. INITIATE (from ProfileCard front) │ +│ User clicks gray "Not Authenticated" badge on a link │ +│ → detectProviderFromUrl(link.url) → provider key │ +│ → connectSocial(provider, { profileId, returnPath }) │ +│ → supabase.auth.signInWithOAuth() │ +│ → Redirect to provider consent screen │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2. USER AUTHORIZES │ +│ User grants permission on provider │ +│ → Provider redirects back with auth code │ +│ → Supabase exchanges for token, creates session │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 3. CALLBACK (in ProfileCard) │ +│ useConnectCallback() detects auth state change │ +│ → Find provider identity in session.user.identities[] │ +│ → provider.getHandle(identity_data) → handle │ +│ → provider.buildUrl(handle) → canonical URL │ +│ → onConnected({ url, provider, handle, ... }) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 4. PERSIST IMMEDIATELY │ +│ → upsertVerifiedLink(profileId, url) server action │ +│ → INSERT/UPDATE zcasher_links SET is_verified = true, │ +│ platform = derived from provider key │ +│ → Update local linksArray state │ +│ → router.refresh() to sync server state │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Database Schema + +```sql +zcasher_links { + id SERIAL PRIMARY KEY + zcasher_id INTEGER REFERENCES zcasher(id) + url TEXT NOT NULL + platform TEXT -- "X", "GitHub", "Discord", "LinkedIn", "Other" + is_verified BOOLEAN DEFAULT false + label TEXT -- optional, used for Discord usernames + created_at TIMESTAMP + updated_at TIMESTAMP +} +``` + +## Security Notes + +- **Wallet-Profile Verification Required**: Server checks `profile.address_verified` before allowing link verification +- **OAuth via Supabase**: Token exchange handled by Supabase Auth, no tokens stored in app +- **Identity Extraction**: Handle extracted from `session.user.identities[]` after OAuth +- **No Client Trust**: Link verification always validated server-side, never trust client claims + +## Adding a New Provider + +1. Add config to `PROVIDERS` in `providers.ts`: + ```typescript + mastodon: { + key: "mastodon", + label: "Mastodon", + buildUrl: (handle) => `https://mastodon.social/@${handle}`, + getHandle: (data) => (data?.username as string) || null, + } + ``` + +2. Enable provider in Supabase Auth dashboard + +3. Update `detectProviderFromUrl` in `providers.ts` to match the new provider's URLs + +4. Optionally add avatar support in `ProfileEditor.fetchAvatarUrl()` if a public avatar API exists diff --git a/ui/links/connect.ts b/ui/links/connect.ts new file mode 100644 index 00000000..217e1a43 --- /dev/null +++ b/ui/links/connect.ts @@ -0,0 +1,43 @@ +// ui/links/connect.ts +// Initiates OAuth flow for social verification + +import { supabase } from "@/lib/supabase/supabase-client"; +import { PROVIDERS, ProviderKey } from "./providers"; + +const PENDING_CONNECT_KEY = "pendingConnect"; + +export interface PendingConnect { + provider: ProviderKey; + profileId: number; +} + +export function getPendingConnect(): PendingConnect | null { + const raw = sessionStorage.getItem(PENDING_CONNECT_KEY); + if (!raw) return null; + sessionStorage.removeItem(PENDING_CONNECT_KEY); + return JSON.parse(raw); +} + +export async function connectSocial( + provider: ProviderKey, + { profileId, returnPath }: { profileId: number; returnPath: string } +): Promise { + if (!PROVIDERS[provider]) throw new Error(`Unknown provider: ${provider}`); + + const pending = { provider, profileId }; + console.log("[connectSocial] storing pending:", pending); + sessionStorage.setItem(PENDING_CONNECT_KEY, JSON.stringify(pending)); + + const { error } = await supabase.auth.signInWithOAuth({ + provider: PROVIDERS[provider].key as any, + options: { + redirectTo: new URL(returnPath, window.location.origin).toString(), + skipBrowserRedirect: false, + }, + }); + + if (error) { + sessionStorage.removeItem(PENDING_CONNECT_KEY); + throw error; + } +} diff --git a/ui/links/providers.ts b/ui/links/providers.ts new file mode 100644 index 00000000..babb6ae8 --- /dev/null +++ b/ui/links/providers.ts @@ -0,0 +1,113 @@ +// ui/links/providers.ts +// Single source of truth for OAuth providers and handle extraction + +export interface Provider { + key: string; + label: string; + buildUrl: (handle: string) => string; + getHandle: (identityData: Record) => string | null; + getUsername?: (identityData: Record) => string | null; + getAvatarUrl?: (identityData: Record) => string | null; +} + +export const PROVIDERS: Record = { + twitter: { + key: "twitter", + label: "X / Twitter", + buildUrl: (handle) => `https://x.com/${handle}`, + getHandle: (data) => + (data?.username as string) ?? + (data?.screen_name as string) ?? + (data?.user_name as string) ?? + (data?.preferred_username as string) ?? + null, + getAvatarUrl: (data) => { + const url = data?.profile_image_url_https as string | undefined; + if (!url) return null; + // Upgrade to original size + return url + .replace(/_(normal|bigger|mini)(\.[a-z0-9]+)(\?.*)?$/i, "$2$3") + .replace(/([?&])name=normal\b/i, "$1name=original"); + }, + }, + github: { + key: "github", + label: "GitHub", + buildUrl: (handle) => `https://github.com/${handle}`, + getHandle: (data) => (data?.user_name as string) ?? null, + getAvatarUrl: (data) => (data?.avatar_url as string) ?? null, + }, + discord: { + key: "discord", + label: "Discord", + buildUrl: (id) => `https://discord.com/users/${id}`, + getHandle: (data) => (data?.id as string) ?? null, + getUsername: (data) => { + const username = data?.username as string | undefined; + const discriminator = data?.discriminator as string | undefined; + if (!username) return null; + if (discriminator && discriminator !== "0") { + return `${username}#${discriminator}`; + } + return username; + }, + getAvatarUrl: (data) => { + const id = data?.id as string | undefined; + const avatar = data?.avatar as string | undefined; + if (!id || !avatar) return null; + return `https://cdn.discordapp.com/avatars/${id}/${avatar}.png?size=4096`; + }, + }, + linkedin_oidc: { + key: "linkedin_oidc", + label: "LinkedIn", + buildUrl: (handle) => `https://linkedin.com/in/${handle}`, + getHandle: (data) => + (data?.vanityName as string) ?? + (data?.preferred_username as string) ?? + null, + }, +} as const; + +export type ProviderKey = keyof typeof PROVIDERS; + +export function getProviderByKey(key: string): Provider | null { + return PROVIDERS[key] ?? Object.values(PROVIDERS).find((p) => p.key === key) ?? null; +} + +/** + * Detect provider key from a URL. + */ +export function detectProviderFromUrl(url: string): string | null { + const normalized = url.toLowerCase(); + if (/(?:x\.com|twitter\.com)\//.test(normalized)) return "twitter"; + if (/github\.com\//.test(normalized)) return "github"; + if (/(?:discord\.com|discordapp\.com)\/users\//.test(normalized)) return "discord"; + if (/linkedin\.com\/in\//.test(normalized)) return "linkedin_oidc"; + return null; +} + +/** + * Extract handle from a social URL. + */ +export function extractHandleFromUrl(url: string): string | null { + const normalized = url.replace(/\/$/, ""); + + // Twitter/X + let m = normalized.match(/(?:x\.com|twitter\.com)\/([^/?#]+)/i); + if (m) return m[1]; + + // GitHub + m = normalized.match(/github\.com\/([^/?#]+)/i); + if (m) return m[1]; + + // Discord + m = normalized.match(/(?:discord\.com|discordapp\.com)\/users\/([^/?#]+)/i); + if (m) return decodeURIComponent(m[1]); + + // LinkedIn + m = normalized.match(/linkedin\.com\/in\/([^/?#]+)/i); + if (m) return m[1]; + + return null; +} diff --git a/ui/links/useConnectCallback.ts b/ui/links/useConnectCallback.ts new file mode 100644 index 00000000..e852401b --- /dev/null +++ b/ui/links/useConnectCallback.ts @@ -0,0 +1,80 @@ +"use client"; + +// ui/links/useConnectCallback.ts +// Handles OAuth callback via onAuthStateChange + sessionStorage + +import { useEffect } from "react"; +import { supabase } from "@/lib/supabase/supabase-client"; +import { getProviderByKey } from "./providers"; +import { getPendingConnect } from "./connect"; + +export interface ConnectedLink { + url: string; + provider: string; + handle: string; + username?: string; + avatarUrl?: string | null; + accessToken: string; +} + +interface UseConnectCallbackOptions { + profileId: number; + onConnected?: (link: ConnectedLink) => void; + onError?: (error: string) => void; +} + +export function useConnectCallback({ + profileId, + onConnected, + onError, +}: UseConnectCallbackOptions): void { + useEffect(() => { + const { data: { subscription } } = supabase.auth.onAuthStateChange( + (event, session) => { + console.log("[useConnectCallback] auth event:", event); + const pending = getPendingConnect(); + console.log("[useConnectCallback] pending:", pending); + if (!pending) { console.log("[useConnectCallback] no pending connect, skipping"); return; } + if (pending.profileId !== profileId) { console.log("[useConnectCallback] profileId mismatch:", pending.profileId, "vs", profileId); return; } + if (!session) { console.log("[useConnectCallback] no session"); return; } + + const provider = getProviderByKey(pending.provider); + if (!provider) { + onError?.(`Unknown provider: ${pending.provider}`); + return; + } + + console.log("[useConnectCallback] identities:", session.user.identities?.map(i => i.provider)); + const identity = session.user.identities?.find( + (i) => i.provider === provider.key + ); + if (!identity) { + onError?.(`No ${provider.label} identity found in session`); + return; + } + + const data = identity.identity_data as Record; + console.log("[useConnectCallback] identity_data keys:", Object.keys(data)); + const handle = provider.getHandle(data); + console.log("[useConnectCallback] handle:", handle); + if (!handle) { + onError?.(`Could not get handle from ${provider.label}`); + return; + } + + const result = { + url: provider.buildUrl(handle), + provider: provider.key, + handle, + username: provider.getUsername?.(data) ?? handle, + avatarUrl: provider.getAvatarUrl?.(data) ?? null, + accessToken: session.access_token, + }; + console.log("[useConnectCallback] success:", result); + onConnected?.(result); + } + ); + + return () => subscription.unsubscribe(); + }, [profileId, onConnected, onError]); +} diff --git a/ui/links/verifyLink.ts b/ui/links/verifyLink.ts new file mode 100644 index 00000000..2ce35625 --- /dev/null +++ b/ui/links/verifyLink.ts @@ -0,0 +1,87 @@ +"use server"; + +// ui/links/verifyLink.ts +// Server action: auto-persist a verified social link +// Validates the OAuth session server-side before marking a link as verified. + +import { createSupabaseServerClient } from "@/lib/supabase/supabase-server"; +import { getProviderByKey, detectProviderFromUrl, extractHandleFromUrl } from "./providers"; + +const PROVIDER_TO_PLATFORM: Record = { + twitter: "X", + github: "GitHub", + discord: "Discord", + linkedin_oidc: "LinkedIn", +}; + +export async function upsertVerifiedLink( + profileId: number, + url: string, + accessToken: string +): Promise<{ ok: boolean; error?: string }> { + const supabase = createSupabaseServerClient(); + if (!supabase) return { ok: false, error: "Supabase client not available" }; + + // Validate the OAuth session: verify the access token and extract identities + const { data: { user }, error: authError } = await supabase.auth.getUser(accessToken); + if (authError || !user) return { ok: false, error: "Invalid auth session" }; + + // Match the claimed URL to a provider and verify the identity + const providerKey = detectProviderFromUrl(url); + if (!providerKey) return { ok: false, error: "Unsupported provider URL" }; + + const provider = getProviderByKey(providerKey); + if (!provider) return { ok: false, error: "Unknown provider" }; + + const identity = user.identities?.find((i) => i.provider === provider.key); + if (!identity) return { ok: false, error: "No matching OAuth identity in session" }; + + const identityData = identity.identity_data as Record; + const oauthHandle = provider.getHandle(identityData); + if (!oauthHandle) return { ok: false, error: "Could not extract handle from OAuth identity" }; + + // Verify the claimed URL matches the authenticated identity + const claimedHandle = extractHandleFromUrl(url); + if (!claimedHandle || oauthHandle.toLowerCase() !== claimedHandle.toLowerCase()) { + return { ok: false, error: "URL does not match authenticated identity" }; + } + + // Only address-verified profiles can authenticate social links + const { data: profile, error: profileError } = await supabase + .from("zcashers") + .select("address_verified") + .eq("id", profileId) + .single(); + + if (profileError) return { ok: false, error: profileError.message }; + if (!profile?.address_verified) return { ok: false, error: "Address must be verified first" }; + + // Use the canonical URL built from the OAuth handle (not the client-provided URL) + const verifiedUrl = provider.buildUrl(oauthHandle); + + const { data: existing, error: findError } = await supabase + .from("zcasher_links") + .select("id") + .eq("zcasher_id", profileId) + .eq("url", verifiedUrl) + .maybeSingle(); + + if (findError) return { ok: false, error: findError.message }; + + const platform = PROVIDER_TO_PLATFORM[providerKey] ?? "Other"; + + if (existing) { + const { error } = await supabase + .from("zcasher_links") + .update({ is_verified: true, platform, updated_at: new Date().toISOString() }) + .eq("id", existing.id); + if (error) return { ok: false, error: error.message }; + } else { + const { error } = await supabase + .from("zcasher_links") + .insert({ zcasher_id: profileId, url: verifiedUrl, is_verified: true, platform, created_at: new Date().toISOString() }); + if (error) return { ok: false, error: error.message }; + } + + return { ok: true }; +} diff --git a/ui/messaging/AGENT.md b/ui/messaging/AGENT.md new file mode 100644 index 00000000..e4f5cfd7 --- /dev/null +++ b/ui/messaging/AGENT.md @@ -0,0 +1,80 @@ +# /ui/messaging - Memo Composer + +## Purpose +Components for composing Zcash transaction memos. +Used when sending payments with messages attached. + +## Components + +### MemoComposer.tsx +Main memo input with character limit: +```tsx + +``` + +Features: +- Character counter +- Emoji picker integration +- UTF-8 aware length calculation + +### useEmojiAutocomplete.ts +Emoji autocomplete hook: +```typescript +const { + suggestions, + query, + select, + isOpen +} = useEmojiAutocomplete(inputRef); +``` + +Triggered by `:` character (e.g., `:smile:`). +Uses `emojilib` for emoji lookup. + +## Zcash Memo Field + +### Constraints +- **Max 512 bytes** after encoding +- UTF-8 encoded +- Stored in shielded transaction +- Only sender and recipient can read + +### Encoding +Memos are base64url encoded when constructing URIs: +```typescript +const encoded = btoa(unescape(encodeURIComponent(memo))); +// Used in: zcash:u1...?memo={encoded} +``` + +## Privacy Features +- Memos are encrypted in shielded transactions +- Only visible to transaction participants +- Blockchain observers cannot read content + +## Use Cases +1. **Payment messages** - "Thanks for dinner!" +2. **OTP verification** - `{"otp":"123456"}` +3. **Profile edits** - `{"otp":"...","edits":{...}}` +4. **Thread posts** - Message content + verification + +## Character Counting +UTF-8 characters vary in byte size: +```typescript +function getByteLength(str: string): number { + return new Blob([str]).size; +} +// "Hello" = 5 bytes +// "你好" = 6 bytes +// "🎉" = 4 bytes +``` + +## Testing Harness +- Test byte limit enforcement +- Verify emoji insertion +- Check encoding roundtrip +- Test max length edge cases diff --git a/ui/messaging/MemoComposer.tsx b/ui/messaging/MemoComposer.tsx index f8c9ebac..8398b8aa 100644 --- a/ui/messaging/MemoComposer.tsx +++ b/ui/messaging/MemoComposer.tsx @@ -5,7 +5,7 @@ import AmountAndWallet from "@/ui/verification/AmountAndWallet"; import HelpMessage from "@/ui/verification/HelpMessage"; import QrUriBlock from "@/ui/verification/QrUriBlock"; import { buildZcashUri } from "@/lib/zcash/zcashUtils"; -import { withFieldBorderState } from "@/ui/styles/fields"; +import { withFieldBorderState } from "@/ui/common/forms/styles"; interface MemoCounterProps { text: string; diff --git a/ui/ns-directory/useNsProfiles.ts b/ui/ns-directory/useNsProfiles.ts index 34611d2f..81c40791 100644 --- a/ui/ns-directory/useNsProfiles.ts +++ b/ui/ns-directory/useNsProfiles.ts @@ -14,7 +14,7 @@ export default function useNsProfiles( ): UseNsProfilesReturn { const hasInitial = initialProfiles !== null; - const [profiles, setProfiles] = useState(initialProfiles || []); + const [profiles, setProfiles] = useState(initialProfiles ?? []); const [loading, setLoading] = useState(!hasInitial); useEffect(() => { @@ -54,10 +54,10 @@ export default function useNsProfiles( address_verified: false, verified_links_count: 0, ...newProfile, - id: newProfile.id || 0, - name: newProfile.name || "", - address: newProfile.address || "", - links: newProfile.links || [], + id: newProfile.id ?? 0, + name: newProfile.name ?? "", + address: newProfile.address ?? "", + links: newProfile.links ?? [], }; setProfiles((prev) => [...prev, enriched]); diff --git a/ui/profile/AGENT.md b/ui/profile/AGENT.md new file mode 100644 index 00000000..76dba312 --- /dev/null +++ b/ui/profile/AGENT.md @@ -0,0 +1,85 @@ +# /ui/profile - Profile Components + +## Purpose +Components for displaying and editing Zcash user profiles. +The primary UI for the zcash.me identity system. + +## Components + +### Display +| Component | File | Purpose | +|-----------|------|---------| +| `ProfileCard` | ProfileCard.tsx | Main profile display card | +| `ProfileCardContent` | ProfileCardContent.tsx | Card body rendering | +| `ProfileHeader` | ProfileHeader.tsx | Navigation with profile count | +| `ProfileAvatar` | ProfileAvatar.tsx | Avatar image with fallback | +| `ProfileLinkRow` | ProfileLinkRow.tsx | Individual link display | +| `VerifiedBadge` | VerifiedBadge.tsx | Checkmark for verified items | +| `CopyButton` | CopyButton.tsx | Copy address/link to clipboard | + +### Editing +| Component | File | Purpose | +|-----------|------|---------| +| `ProfileEditor` | ProfileEditor.tsx | Full profile edit interface | +| `ProfileField` | ProfileField.tsx | Single editable field | +| `editorModals` | editorModals.tsx | `RedirectModal` (OAuth redirect spinner), `AvatarPreviewModal` | + +### Search +| Component | File | Purpose | +|-----------|------|---------| +| `ProfileSearchDropdown` | ProfileSearchDropdown.tsx | Search results dropdown | + +## Zcash-Specific Features + +### Address Display +```tsx + +// Shows Zcash address prominently +// QR code for easy wallet scanning +// Copy button for address +``` + +### Verification Badge +```tsx + +// Green checkmark if address proven via blockchain +``` + +### Link Verification +Each link can be verified independently: +```tsx + +// Shows verification status per link +``` + +## State Management + +### store.ts (Zustand) +Profile editing state - colocated with components: +```typescript +import { useEditsStore } from "@/ui/profile/store"; + +const { form, setForm, initializeForm } = useEditsStore(); +``` + +State includes: +- `form` - Current form values +- `original` - Original values for comparison +- `deletedFields` - Track field deletions +Note: Profile edits are submitted directly to the backend after OTP verification via ZVS (Zcash Verification Service). The old `pendingEdits` system that encoded changes in the Zcash memo has been removed. + +## Hooks + +### useProfileLinks.ts +Manages link state for editing: +- Add/remove links +- Reorder links +- Track verification status + +## Testing Harness +- Components receive profile data via props +- Mock profile objects for unit tests +- Use design-system page for visual testing + +## Types +See `/lib/profile/types.ts` for `Profile` and `ProfileLink` interfaces. diff --git a/ui/profile/AuthExplainerModal.tsx b/ui/profile/AuthExplainerModal.tsx deleted file mode 100644 index 9d493643..00000000 --- a/ui/profile/AuthExplainerModal.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from "@/ui/common"; - -interface AuthExplainerModalProps { - isOpen: boolean; - canAuthenticate: boolean; - authPending?: boolean; - authRedirectOpen?: boolean; - providerLabel?: string; - onClose: () => void; - onAuthenticate: () => void; -} - -export default function AuthExplainerModal({ - isOpen, - canAuthenticate, - authPending, - authRedirectOpen, - providerLabel, - onClose, - onAuthenticate, -}: AuthExplainerModalProps) { - const isPending = authPending || authRedirectOpen; - - return ( - - - -

- Ownership has not been confirmed for this link. We do not know if the person who added it actually owns it. -

- {canAuthenticate ? ( -

- If you own this account, authenticate it to prove ownership. -

- ) : ( -

- Only verified profiles can authenticate links. -

- )} -
- - - {canAuthenticate && ( - - )} - -
- ); -} diff --git a/ui/profile/CopyButton.tsx b/ui/profile/CopyButton.tsx deleted file mode 100644 index 76082afe..00000000 --- a/ui/profile/CopyButton.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from "react"; -import type { MouseEvent } from "react"; - -interface CopyButtonProps { - text: string; - label?: string; - copiedLabel?: string; - className?: string; - icon?: string; - copiedIcon?: string; - timeout?: number; - size?: "xs" | "sm" | "md"; -} - -export default function CopyButton({ - text, - label = "Copy", - copiedLabel = "Copied", - className = "", - icon = "⧉", - copiedIcon = "⮼", - timeout = 2000, - size = "sm", -}: CopyButtonProps) { - const [copied, setCopied] = useState(false); - - const sizeClasses = { - xs: "text-xs", - sm: "text-sm", - md: "text-base", - }; - - const handleCopy = (e: MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), timeout); - }; - - return ( - - ); -} diff --git a/ui/profile/ProfileCard.tsx b/ui/profile/ProfileCard.tsx index b3967816..7a39000f 100644 --- a/ui/profile/ProfileCard.tsx +++ b/ui/profile/ProfileCard.tsx @@ -1,666 +1,263 @@ "use client"; -import { useState, useEffect, useRef } from "react"; -import type { MouseEvent } from "react"; -import { - isNewProfile, - getProfileTrust, - getWarningConfig, - buildShareUrl, - getLastVerifiedLabel, -} from "@/lib/profile/profileUtils"; -import CopyButton from "@/ui/profile/CopyButton"; +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { getProfileTrust, getWarningConfig, getLastVerifiedLabel } from "@/lib/profile/profileUtils"; +import CopyButton from "@/ui/common/buttons/CopyButton"; import VerifiedBadge from "@/ui/profile/VerifiedBadge"; import VerifiedCardWrapper from "@/ui/profile/VerifiedCardWrapper"; import ReferRankBadgeMulti from "@/ui/ns-directory/ReferRankBadgeMulti"; import ProfileEditor from "@/ui/profile/ProfileEditor"; import ProfileAvatar from "@/ui/profile/ProfileAvatar"; import useProfileLinks from "@/ui/profile/useProfileLinks"; -import { - getAuthProviderForUrl, - getLinkAuthToken, - isLinkAuthPending, - startOAuthVerification, -} from "@/lib/profile/accountAuthFlow"; -import AuthExplainerModal from "@/ui/profile/AuthExplainerModal"; -import { useEditsStore } from "@/lib/stores/edits"; -import SubmitOtp from "@/ui/verification/SubmitOtp"; -import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; -import type { EnrichedProfileLink, Profile } from "@/lib/profile/types"; - +import VerifyProfileModal from "@/ui/verification/VerifyProfileModal"; +import { RedirectModal } from "@/ui/profile/editorModals"; +import { connectSocial } from "@/ui/links/connect"; +import { useConnectCallback } from "@/ui/links/useConnectCallback"; +import { upsertVerifiedLink } from "@/ui/links/verifyLink"; +import { detectProviderFromUrl } from "@/ui/links/providers"; +import { PROVIDERS } from "@/ui/links/providers"; +import { enrichLink } from "@/lib/profile/profileLinks"; +import ProfileCardListView from "./ProfileCardListView"; +import ProfileCardActions from "./ProfileCardActions"; +import ProfileCardWarning from "./ProfileCardWarning"; import ProfileLinkRow from "./ProfileLinkRow"; -import RedirectModal from "./RedirectModal"; +import { AnimatePresence, motion } from "framer-motion"; +import type { EnrichedProfileLink } from "@/lib/profile/types"; import type { ProfileCardProps, LinkRowClasses } from "./profileCardTypes"; import { formatUsername } from "./profileCardUtils"; export type { ProfileCardTextScale } from "./profileCardTypes"; -const getDisplayName = (profile: Partial) => - profile.display_name || profile.name || ""; +const AVATAR_SIZE = 120; +const AVATAR_SPACER = 64; +const CARD_TOP_MARGIN = 64; +const CARD_OFFSET_Y = 7; +const ACTION_BUTTONS_TOP = 16; +const ACTION_BUTTONS_HEIGHT = 36; +const AVATAR_OVERLAP_Y = Math.round(AVATAR_SIZE / 2 - (ACTION_BUTTONS_TOP + ACTION_BUTTONS_HEIGHT)); + +const RANK_PERIODS = ["alltime", "weekly", "monthly", "daily"] as const; + +const LINK_ROW_CLASSES: LinkRowClasses = { + row: "flex items-center gap-3 py-1 border-b border-gray-100 last:border-0 min-w-0", + left: "flex items-center gap-2 shrink-0", + leftLink: "flex items-center gap-2 shrink-0 hover:text-blue-600 transition-colors", + right: "flex items-center gap-2 ml-auto min-w-0 text-sm text-gray-600 justify-end flex-1", + icon: "w-4 h-4 rounded-xs opacity-80", + label: "font-medium text-gray-800 whitespace-nowrap", + domain: "flex-1 min-w-0 truncate text-right", + copyWrapper: "shrink-0", +}; export default function ProfileCard({ profile, - onSelect, - warning, fullView = false, duplicateNameCount = 0, onShowQR, - onEditorModeChange + onEditorModeChange, }: ProfileCardProps) { - const menuContainerRef = useRef(null); - const shouldReduceMotion = useReducedMotion(); - const [isOtpOpen, setIsOtpOpen] = useState(false); - const [authInfoOpen, setAuthInfoOpen] = useState(false); - const [authLink, setAuthLink] = useState(null); - const [authRedirectOpen, setAuthRedirectOpen] = useState(false); - const [authRedirectLabel, setAuthRedirectLabel] = useState("X.com"); + const router = useRouter(); + const [isVerifyOpen, setIsVerifyOpen] = useState(false); const [showStats, setShowStats] = useState(false); - const [showDetail, setShowDetail] = useState(false); - const [menuOpen, setMenuOpen] = useState(false); const [showBack, setShowBack] = useState(false); - const { pendingEdits, addLinkAuthToken } = useEditsStore(); - const { linksArray } = useProfileLinks({ profile }); - const tapProps = shouldReduceMotion - ? {} - : { - whileTap: { scale: 0.94, y: 1, filter: "brightness(0.95)" }, - transition: { type: "spring" as const, stiffness: 550, damping: 24, mass: 0.35 }, - }; - - const { verifiedAddress, verifiedLinks, canAuthenticateLinks } = getProfileTrust(profile); - const selectedAuthProvider = authLink ? getAuthProviderForUrl(authLink.url) : null; - const authToken = authLink ? getLinkAuthToken(authLink) : null; - const authPending = authToken && isLinkAuthPending(pendingEdits, authToken); - const totalLinks = profile.total_links ?? (Array.isArray(linksArray) ? linksArray.length : 0); - const hasDuplicateNames = duplicateNameCount > 1; - // Default to showing trust warnings unless caller explicitly disables via `warning={null}`. - const warningEnabled = warning !== null; - const warningConfig = getWarningConfig({ profile, warning: warningEnabled, verifiedAddress, verifiedLinks, totalLinks, hasDuplicateNames }); - const warningDefaultExpanded = warningConfig?.defaultExpanded; - const fullLinkRowClasses: LinkRowClasses = { - row: "flex items-center gap-3 py-1 border-b border-gray-100 last:border-0 min-w-0", - left: "flex items-center gap-2 shrink-0", - leftLink: "flex items-center gap-2 shrink-0 hover:text-blue-600 transition-colors", - right: "flex items-center gap-2 ml-auto min-w-0 text-sm text-gray-600 justify-end flex-1", - icon: "w-4 h-4 rounded-xs opacity-80", - label: "font-medium text-gray-800 whitespace-nowrap", - domain: "flex-1 min-w-0 truncate text-right", - copyWrapper: "shrink-0", - }; - - const hasAwards = - (profile?.rank_alltime ?? 0) > 0 || - (profile?.rank_weekly ?? 0) > 0 || - (profile?.rank_monthly ?? 0) > 0 || - (profile?.rank_daily ?? 0) > 0; - // Keep content position stable while the avatar overlaps the card edge. - const avatarTopSpacerPx = 64; - const baseCardTopMarginPx = 64; - const avatarSizePx = 120; - const cardOffsetYPx = 7; - const topActionButtonsTopPx = 16; - const topActionButtonsHeightPx = 36; - // Align avatar bottom with the bottom edge of the top action buttons. - const avatarOverlapOffsetYPx = Math.round( - (avatarSizePx / 2) - (topActionButtonsTopPx + topActionButtonsHeightPx) - ); - - useEffect(() => { - if (warningDefaultExpanded === undefined) return; - setShowDetail(!!warningDefaultExpanded); - }, [warningDefaultExpanded]); - - useEffect(() => { - onEditorModeChange?.(showBack); - }, [showBack, onEditorModeChange]); - - useEffect(() => { - if (!menuOpen) return; - - const handlePointerDown = (event: PointerEvent) => { - const menuContainer = menuContainerRef.current; - if (!menuContainer) return; - if (menuContainer.contains(event.target as Node)) return; - setMenuOpen(false); - }; - - document.addEventListener("pointerdown", handlePointerDown); - return () => { - document.removeEventListener("pointerdown", handlePointerDown); - }; - }, [menuOpen]); - - const handleAuthBadgeClick = (event: MouseEvent, link: EnrichedProfileLink) => { - event.stopPropagation(); - if (!link || link.is_verified) return; - setAuthLink(link); - setAuthInfoOpen(true); - }; - - const handleAuthenticateLink = () => { - if (!authLink) return; - if (!canAuthenticateLinks) return; - if (selectedAuthProvider) { - startOAuthVerification({ - providerKey: selectedAuthProvider.key, - profile, - url: authLink.url, - setShowRedirect: setAuthRedirectOpen, - setRedirectLabel: setAuthRedirectLabel, + const [showRedirect, setShowRedirect] = useState(false); + const [redirectLabel, setRedirectLabel] = useState(""); + const { linksArray, setLinksArray } = useProfileLinks({ profile }); + + const { verifiedAddress, verifiedLinks } = getProfileTrust(profile); + const totalLinks = profile.total_links ?? linksArray.length; + const warningConfig = getWarningConfig({ profile, warning: true, verifiedAddress, verifiedLinks, totalLinks, hasDuplicateNames: duplicateNameCount > 1 }); + const hasAwards = RANK_PERIODS.some((p) => (profile[`rank_${p}`] ?? 0) > 0); + const displayName = profile.display_name || profile.name || ""; + const isVerified = profile.address_verified || (profile.verified_links_count ?? 0) > 0; + + const handleVerifyClick = useCallback(async (link: EnrichedProfileLink) => { + if (!profile.address_verified) return; + const providerKey = detectProviderFromUrl(link.url || ""); + if (!providerKey || !PROVIDERS[providerKey]) return; + + setShowRedirect(true); + setRedirectLabel(PROVIDERS[providerKey].label); + + try { + await connectSocial(providerKey, { + profileId: profile.id, + returnPath: window.location.pathname, }); - return; + } catch { + setShowRedirect(false); + } + }, [profile.id]); + + const handleConnected = useCallback(async (link: { url: string; provider: string; handle: string; accessToken: string }) => { + setShowRedirect(false); + const result = await upsertVerifiedLink(profile.id, link.url, link.accessToken); + if (result.ok) { + setLinksArray((prev) => + prev.map((l) => + l.url === link.url ? enrichLink({ ...l, is_verified: true }) : l + ) + ); + router.refresh(); } - if (!authToken || authPending) return; - addLinkAuthToken(authToken); - setAuthInfoOpen(false); - }; + }, [profile.id, setLinksArray, router]); - if (!fullView) { - return ( - { - onSelect?.(profile); - requestAnimationFrame(() => - window.scrollTo({ top: 0, behavior: "smooth" }) - ); - }} - className="rounded-2xl p-3 border transition-all cursor-pointer shadow-xs border-gray-500 bg-transparent hover:bg-gray-100/10 hover:shadow-[0_0_4px_rgba(0,0,0,0.05)] mb-2" - > -
- + const handleConnectError = useCallback(() => { + setShowRedirect(false); + }, []); -
- - {getDisplayName(profile)} - {(profile.address_verified || (profile.verified_links_count ?? 0) > 0) && ( - - )} - {isNewProfile(profile) && ( - - NEW - - )} - - - /{formatUsername(profile)} - + useConnectCallback({ + profileId: profile.id, + onConnected: handleConnected, + onError: handleConnectError, + }); -
- {/* Badges */} - {(hasAwards) && ( -
- {(["alltime", "weekly", "monthly", "daily"] as const).map(period => { - const rank = profile[`rank_${period}`]; - const periodLabel = period === "alltime" ? "all" : period; - return rank && rank > 0 && ; - })} -
- )} -
-
-
- { - isOtpOpen && ( - setIsOtpOpen(false)} - profile={profile} - /> - ) - } -
+ useEffect(() => { onEditorModeChange?.(showBack); }, [showBack, onEditorModeChange]); - ); - } + if (!fullView) return ; return ( -
-
+
+
-
- - {/* FRONT SIDE */} -
- {/* Top buttons row (menu + share) */} + {/* Flip container */}
- {/* Menu button */} -
- { - e.stopPropagation(); - setMenuOpen((prev) => !prev); - }} - aria-expanded={menuOpen} - {...tapProps} - className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-300 bg-white/80 shadow-xs text-gray-600 hover:text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition-all" - title="More options" - > - - - - {/* Dropdown Menu */} + {/* FRONT */} +
+ {/* Actions row */}
- {!showStats ? ( - - ) : ( - - )} - - - - - - -
- -
- - {/* Share button (top-right) */} - { - const shareUrl = buildShareUrl(profile); - - if (navigator.share) { - try { - await navigator.share({ - title: `${getDisplayName(profile)} on Zcash.me`, - text: "Check out this Zcash profile:", - url: shareUrl, - }); - return; - } catch { - // User cancelled or failed - fall through to clipboard - } - } - await navigator.clipboard.writeText(shareUrl); - alert("Profile link copied to clipboard!"); - }} - {...tapProps} - className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-300 bg-white/80 shadow-xs text-gray-600 hover:text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition-all" - title={`Share ${getDisplayName(profile)}`} - > - - -
- - {/* Avatar: overlap the top edge so half sits above the card */} -
- -
- - {/* Spacer so content starts below the overlapping avatar */} - - {/* Awards section (animated, appears when Show Awards is active) */} - - {showStats && ( - - {(["alltime", "weekly", "monthly", "daily"] as const).map((period) => { - const rank = profile[`rank_${period}`]; - if (!rank || rank <= 0) return null; - const periodLabel = period === "alltime" ? "all" : period; - return ( - - ); - })} - - )} - + {/* Avatar */} +
+ +
+
+ + {/* Awards */} + + {showStats && ( + + {RANK_PERIODS.map((period) => { + const rank = profile[`rank_${period}`]; + if (!rank || rank <= 0) return null; + return ; + })} + + )} + + + {/* Identity */} +
+

+ {displayName} + {isVerified && } +

+
/{formatUsername(profile)}
+
- {/* Name & Username Layout */} -
-

- {getDisplayName(profile)} - {(profile.address_verified || (profile.verified_links_count ?? 0) > 0) && ( - + {/* Bio */} + {profile.bio?.trim() && ( +

{profile.bio}

)} -

-
- /{formatUsername(profile)} -
-
- - {/* Biography (only if present) */} - {profile.bio && profile.bio.trim() !== "" && ( -

- {profile.bio} -

- )} - {/* Dates */} -

- {profile.nearest_city_name && ( - <> + {/* Dates */} +

+ {profile.nearest_city_name && ( + <>Near {profile.nearest_city_name} + )} - Near {profile.nearest_city_name} - - - - - )} - - - Joined{" "} - {new Date( - profile.joined_at || profile.created_at || profile.since || new Date().toISOString() - ).toLocaleString("default", { - month: "short", - year: "numeric", - })} - - - - - - Verified{" "} - {getLastVerifiedLabel(profile.last_verified_at || profile.last_verified)} - -

- - {/* Address with integrated copy button and feedback */} - {profile.address ? ( -
-
- - {profile.address - ? `${profile.address.slice(0, 6)}...${profile.address.slice(-6)}` - : "—"} + Joined {new Date(profile.joined_at || profile.created_at || profile.since || new Date().toISOString()).toLocaleString("default", { month: "short", year: "numeric" })} - - {/* QR + Copy Buttons with animated label expansion */} -
- {/* QR Button */} - +
+ + +
+
+
+ ) : ( +

+ )} - {/* Copy Button */} - + {/* Links */} +
+
+
+ {linksArray.length > 0 + ? linksArray.map((link: EnrichedProfileLink) => ) + :

No contributed links yet.

} +
-
- ) : ( -

- )} - {/* Action tray */} -
- {/* Links tray only */} -
-
- {linksArray.length > 0 ? ( - linksArray.map((link: EnrichedProfileLink) => ( - - )) - ) : ( -

- No contributed links yet. -

- )} -
+ {/* Warning */} + {warningConfig && }
-
- {/* Warning */} - {warningConfig && ( + {/* BACK */}
-
- {warningConfig.summary} +
-
- -
-
- {warningConfig.details.map((line, index) => ( -
{line}
- ))} -
+ onClick={() => { (window as any).skipZcashFeedbackScroll = true; setShowBack(false); }} + title="Return to front" + aria-label="Return to front" + className="flex items-center justify-center w-9 h-9 rounded-full bg-blue-600 text-white text-sm hover:bg-blue-700 transition-all shadow-md" + >↺
+
- )} -
- - {/* BACK SIDE (auto-expand editable) */} -
-
-
- -
- -
- - - { - setAuthInfoOpen(false); - setAuthLink(null); - }} - onAuthenticate={handleAuthenticateLink} - /> - - {isOtpOpen && ( - setIsOtpOpen(false)} - profile={profile} - /> - )} + {isVerifyOpen && setIsVerifyOpen(false)} profile={profile} />} +
); } - diff --git a/ui/profile/ProfileCardActions.tsx b/ui/profile/ProfileCardActions.tsx new file mode 100644 index 00000000..ac1706b6 --- /dev/null +++ b/ui/profile/ProfileCardActions.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { buildShareUrl } from "@/lib/profile/profileUtils"; +import { motion, useReducedMotion } from "framer-motion"; +import type { Profile } from "@/lib/profile/types"; + +interface ProfileCardActionsProps { + profile: Profile; + hasAwards: boolean; + showStats: boolean; + onToggleStats: () => void; + onEdit: () => void; + onVerify: () => void; +} + +export default function ProfileCardActions({ + profile, + hasAwards, + showStats, + onToggleStats, + onEdit, + onVerify, +}: ProfileCardActionsProps) { + const menuRef = useRef(null); + const shouldReduceMotion = useReducedMotion(); + const [menuOpen, setMenuOpen] = useState(false); + const displayName = profile.display_name || profile.name || ""; + const tapProps = shouldReduceMotion + ? {} + : { whileTap: { scale: 0.94, y: 1, filter: "brightness(0.95)" }, transition: { type: "spring" as const, stiffness: 550, damping: 24, mass: 0.35 } }; + const dur = shouldReduceMotion ? "duration-100" : "duration-300 ease-in-out"; + + useEffect(() => { + if (!menuOpen) return; + const onPointer = (e: PointerEvent) => { + if (menuRef.current?.contains(e.target as Node)) return; + setMenuOpen(false); + }; + document.addEventListener("pointerdown", onPointer); + return () => document.removeEventListener("pointerdown", onPointer); + }, [menuOpen]); + + const menuItem = (label: string, onClick: () => void, disabled = false) => ( + + ); + + return ( + <> + {/* Menu */} +
+ { e.stopPropagation(); setMenuOpen((p) => !p); }} + aria-expanded={menuOpen} + {...tapProps} + className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-300 bg-white/80 shadow-xs text-gray-600 hover:text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition-all" + title="More options" + > + {"\u2630"} + +
+ {menuItem(showStats ? "⭔ Hide Awards" : "⭔ Show Awards", onToggleStats, !showStats && !hasAwards)} + {menuItem("↺ Edit Profile", onEdit)} + {menuItem("✓ Verify Profile", onVerify)} +
+
+ + {/* Share */} + { + const url = buildShareUrl(profile); + if (navigator.share) { + try { await navigator.share({ title: `${displayName} on Zcash.me`, text: "Check out this Zcash profile:", url }); return; } catch {} + } + await navigator.clipboard.writeText(url); + alert("Profile link copied to clipboard!"); + }} + {...tapProps} + className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-300 bg-white/80 shadow-xs text-gray-600 hover:text-blue-600 hover:border-blue-400 hover:bg-blue-50 transition-all" + title={`Share ${displayName}`} + > + Share + + + ); +} diff --git a/ui/profile/ProfileCardListView.tsx b/ui/profile/ProfileCardListView.tsx new file mode 100644 index 00000000..1a984663 --- /dev/null +++ b/ui/profile/ProfileCardListView.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { isNewProfile } from "@/lib/profile/profileUtils"; +import VerifiedBadge from "@/ui/profile/VerifiedBadge"; +import VerifiedCardWrapper from "@/ui/profile/VerifiedCardWrapper"; +import ReferRankBadgeMulti from "@/ui/ns-directory/ReferRankBadgeMulti"; +import ProfileAvatar from "@/ui/profile/ProfileAvatar"; +import type { Profile } from "@/lib/profile/types"; +import { formatUsername } from "./profileCardUtils"; + +const RANK_PERIODS = ["alltime", "weekly", "monthly", "daily"] as const; + +interface ProfileCardListViewProps { + profile: Profile; +} + +export default function ProfileCardListView({ profile }: ProfileCardListViewProps) { + const displayName = profile.display_name || profile.name || ""; + const isVerified = profile.address_verified || (profile.verified_links_count ?? 0) > 0; + + return ( + requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: "smooth" }))} + className="rounded-2xl p-3 border transition-all cursor-pointer shadow-xs border-gray-500 bg-transparent hover:bg-gray-100/10 hover:shadow-[0_0_4px_rgba(0,0,0,0.05)] mb-2" + > +
+ +
+ + {displayName} + {isVerified && } + {isNewProfile(profile) && ( + NEW + )} + + /{formatUsername(profile)} + +
+
+
+ ); +} + +function RankBadges({ profile }: { profile: Profile }) { + const hasAwards = RANK_PERIODS.some((p) => (profile[`rank_${p}`] ?? 0) > 0); + if (!hasAwards) return null; + + return ( +
+
+ {RANK_PERIODS.map((period) => { + const rank = profile[`rank_${period}`]; + if (!rank || rank <= 0) return null; + return ; + })} +
+
+ ); +} diff --git a/ui/profile/ProfileCardWarning.tsx b/ui/profile/ProfileCardWarning.tsx new file mode 100644 index 00000000..fbd81153 --- /dev/null +++ b/ui/profile/ProfileCardWarning.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useReducedMotion } from "framer-motion"; +import type { ProfileTrustWarning } from "@/lib/profile/types"; + +const TONE_CLASSES: Record = { + positive: { container: "text-green-700 bg-green-50 border-green-200", button: "text-green-700" }, + neutral: { container: "text-gray-700 bg-gray-50 border-gray-200", button: "text-gray-700" }, + yellow: { container: "text-yellow-900 bg-yellow-50 border-yellow-200", button: "text-yellow-900" }, + red: { container: "text-red-600 bg-red-50 border-red-200", button: "text-red-600" }, +}; + +interface ProfileCardWarningProps { + config: ProfileTrustWarning; +} + +export default function ProfileCardWarning({ config }: ProfileCardWarningProps) { + const shouldReduceMotion = useReducedMotion(); + const [expanded, setExpanded] = useState(false); + const dur = shouldReduceMotion ? "duration-100" : "duration-300 ease-in-out"; + const tone = TONE_CLASSES[config.tone]; + + useEffect(() => { + if (config.defaultExpanded !== undefined) setExpanded(!!config.defaultExpanded); + }, [config.defaultExpanded]); + + return ( +
+
+ {config.summary} + +
+
+
+ {config.details.map((line, i) =>
{line}
)} +
+
+
+ ); +} diff --git a/ui/profile/ProfileEditor.tsx b/ui/profile/ProfileEditor.tsx index f1ce7321..68a71f16 100644 --- a/ui/profile/ProfileEditor.tsx +++ b/ui/profile/ProfileEditor.tsx @@ -2,28 +2,77 @@ import { useState, useEffect, useMemo } from "react"; import type { MouseEvent } from "react"; import LinkInput from "@/ui/signup/LinkInput"; import SocialLinkInput from "@/ui/signup/SocialLinkInput"; -import { buildSocialUrl } from "@/lib/profile/usernameNormalizer"; +import { buildSocialUrl, normalizeSocialUsername, HOSTS } from "@/lib/profile/usernameNormalizer"; +import type { SocialPlatform } from "@/lib/profile/usernameNormalizer"; import { checkUsernameAvailabilityAction } from "@/lib/signup/createProfileAction"; import CitySearchDropdown from "@/ui/signup/CitySearchDropdown"; -import { - getAuthProviderForUrl, - getLinkAuthToken, - isLinkAuthPending, - startOAuthVerification, -} from "@/lib/profile/accountAuthFlow"; -import AuthExplainerModal from "@/ui/profile/AuthExplainerModal"; import HelpIcon from "@/ui/common/HelpIcon"; import ProfileField from "@/ui/profile/ProfileField"; -import { RedirectModal, AvatarReauthModal, AvatarPreviewModal } from "@/ui/profile/editorModals"; -import { parseSocialUrl, isValidImageUrl, applyProviderAvatar } from "@/lib/profile/providerAvatars"; +import { AvatarPreviewModal } from "@/ui/profile/editorModals"; import { isValidUrl } from "@/lib/validation/validators"; import { isUsernameVerified } from "@/lib/profile/profileUtils"; import { sanitizeUsernameInput } from "@/lib/profile/usernamePolicy"; -import useVerificationFlow from "@/ui/social/useVerificationFlow"; -import { useEditsStore, type ParsedLink, type FormState } from "@/lib/stores/edits"; +import { useEditsStore, type ParsedLink, type FormState } from "@/ui/profile/store"; import type { Profile, EnrichedProfileLink } from "@/lib/profile/types"; -import { Alert, Button } from "@/ui/common"; -import { withFieldBorderState } from "@/ui/styles/fields"; +import Alert from "@/ui/common/feedback/Alert"; +import Button from "@/ui/common/buttons/Button"; +import { withFieldBorderState } from "@/ui/common/forms/styles"; +import { PROVIDERS, detectProviderFromUrl, extractHandleFromUrl } from "@/ui/links/providers"; + +function detectPlatformFromUrl(rawUrl: string | null | undefined): string | null { + const trimmed = (rawUrl || "").trim(); + if (!trimmed) return null; + const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + const url = new URL(normalized); + const host = url.hostname.toLowerCase(); + for (const [platform, hosts] of Object.entries(HOSTS)) { + if ((hosts as string[]).includes(host)) return platform; + } + } catch { + return null; + } + return null; +} + +function parseSocialUrl(rawUrl: string | null | undefined): { + platform: string; + username: string; + otherUrl: string; +} { + const trimmed = (rawUrl || "").trim(); + if (!trimmed) return { platform: "X", username: "", otherUrl: "" }; + const platform = detectPlatformFromUrl(trimmed); + if (!platform) return { platform: "Other", username: "", otherUrl: trimmed }; + return { + platform, + username: normalizeSocialUsername(trimmed, platform as SocialPlatform), + otherUrl: "", + }; +} + +function isValidImageUrl(url: string | null | undefined): { + valid: boolean; + reason: string | null; +} { + if (!url) return { valid: true, reason: null }; + const trimmed = url.trim(); + const { valid } = isValidUrl(trimmed); + if (!valid) return { valid: false, reason: "Invalid URL format" }; + const hasImageExt = /\.(png|jpg)(\?.*)?$/i.test(trimmed); + let isGithubAvatar = false; + if (!hasImageExt) { + try { + const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const u = new URL(normalized); + isGithubAvatar = u.hostname.toLowerCase() === "avatars.githubusercontent.com"; + } catch { + isGithubAvatar = false; + } + } + if (!hasImageExt && !isGithubAvatar) return { valid: false, reason: "Image URL must end in .png or .jpg" }; + return { valid: true, reason: null }; +} const FIELD_CLASS = `w-full rounded-2xl border px-3 py-2 text-sm bg-transparent outline-hidden text-gray-800 placeholder-gray-400 ${withFieldBorderState("border-[#0a1126]/60")}`; @@ -31,7 +80,6 @@ const LINK_FIELD_CLASS = `rounded-2xl border px-3 py-2 text-sm bg-transparent outline-hidden text-gray-800 placeholder-gray-400 appearance-none ${withFieldBorderState("border-[#0a1126]/60")}`; const LINK_CONTAINER_CLASS = "rounded-2xl border border-[#0a1126]/60 p-3 bg-transparent"; -const VERIFY_HINT_CLASS = "text-xs text-gray-500 italic"; interface CharCounterProps { text: string; @@ -49,16 +97,23 @@ function CharCounter({ text }: CharCounterProps) { ); } -// Removed - now using store.setDeletedField directly - interface ProfileEditorProps { profile: Profile; links?: EnrichedProfileLink[]; } -interface AvatarPrompt { - provider: string; - url: string; +async function fetchAvatarUrl(url: string): Promise { + const handle = extractHandleFromUrl(url); + if (!handle) return null; + const providerKey = detectProviderFromUrl(url); + switch (providerKey) { + case "github": + return `https://github.com/${encodeURIComponent(handle)}.png`; + case "twitter": + return `https://unavatar.io/x/${encodeURIComponent(handle)}`; + default: + return null; + } } const escapeRegex = (value: string) => @@ -68,36 +123,15 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { const { form, deletedFields, - pendingEdits, setForm, updateField, setDeletedField, initializeForm, - addLinkAuthToken, - removeLinkAuthToken, } = useEditsStore(); - const pendingProfileEdits = pendingEdits?.profile || {}; - const pendingDeleted = Array.isArray(pendingProfileEdits?.d) - ? pendingProfileEdits.d - : []; - const hasPendingField = (key: string, token: string) => - Boolean(pendingProfileEdits?.[key]) || pendingDeleted.includes(token); - const hasPendingLinks = - Array.isArray(pendingEdits?.l) && pendingEdits.l.length > 0; - const [showRedirect, setShowRedirect] = useState(false); - const [redirectLabel, setRedirectLabel] = useState("X.com"); - const [avatarPrompt, setAvatarPrompt] = useState(null); const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false); - const [authInfoOpen, setAuthInfoOpen] = useState(false); - const [authInfoLink, setAuthInfoLink] = useState(null); - const providerKeyByLabel: Record = { - Discord: "discord", - X: "twitter", - GitHub: "github", - }; // Display value for city search input (local UI state) - const [nearestCityDisplay, setNearestCityDisplay] = useState(profile.nearest_city_name || ""); + const [nearestCityDisplay, setNearestCityDisplay] = useState(profile.nearest_city_name ?? ""); // Normalize incoming DB links const originalLinks = useMemo(() => { @@ -121,20 +155,12 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { }); }, [profile, links]); - // Initialize form from profile and links + // Initialize form from profile and links (only when profile ID changes) useEffect(() => { initializeForm(profile, originalLinks); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [profile.id]); // Only re-initialize if profile ID changes - // Update links when originalLinks changes (e.g., after verification) - useEffect(() => { - setForm((prev) => ({ - ...prev, - links: originalLinks.map((l) => ({ ...l })), - })); - }, [originalLinks, setForm]); - - const [imageUrlValid, setImageUrlValid] = useState(true); const [imageUrlReason, setImageUrlReason] = useState(null); @@ -146,11 +172,11 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { const originals = useMemo( () => ({ - address: profile.address || "", - name: profile.name || "", - display_name: profile.display_name || "", - bio: profile.bio || "", - profile_image_url: profile.profile_image_url || "", + address: profile.address ?? "", + name: profile.name ?? "", + display_name: profile.display_name ?? "", + bio: profile.bio ?? "", + profile_image_url: profile.profile_image_url ?? "", }), [profile] ); @@ -159,22 +185,22 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { if (typeof profile.id !== "number") return ""; return `-${profile.id}`; }, [profile.address_verified, profile.id]); - const [usernameInput, setUsernameInput] = useState(form.name || ""); + const [usernameInput, setUsernameInput] = useState(form.name ?? ""); const [usernameConflict, setUsernameConflict] = useState(null); const [usernameTouched, setUsernameTouched] = useState(false); const [usernameStatus, setUsernameStatus] = useState<"idle" | "checking" | "available" | "taken">("idle"); - const [lastValidUsername, setLastValidUsername] = useState(form.name || ""); + const [lastValidUsername, setLastValidUsername] = useState(form.name ?? ""); const displayedUsername = `${usernameInput}${usernameLockedSuffix}`; useEffect(() => { - setUsernameInput(form.name || ""); + setUsernameInput(form.name ?? ""); }, [form.name]); useEffect(() => { setUsernameTouched(false); setUsernameConflict(null); setUsernameStatus("idle"); - setLastValidUsername(profile.name || ""); + setLastValidUsername(profile.name ?? ""); }, [profile.id]); useEffect(() => { @@ -185,7 +211,7 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { } const candidate = sanitizeUsernameInput(usernameInput); - const originalNameRaw = originals.name || ""; + const originalNameRaw = originals.name ?? ""; if (!candidate) { setUsernameConflict(null); @@ -232,38 +258,14 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { }; }, [usernameInput, usernameTouched, profile.id, originals.name, form.name, lastValidUsername]); - // Verification flow hook - useVerificationFlow(profile.id, setShowRedirect); - - const startOAuth = (providerKey: string, url: string) => - startOAuthVerification({ - providerKey, - profile, - url, - setShowRedirect, - setRedirectLabel, - }); - - const authInfoProvider = authInfoLink ? getAuthProviderForUrl(authInfoLink.url) : null; - const authInfoToken = authInfoLink ? getLinkAuthToken(authInfoLink) : null; - const authInfoPending = - authInfoToken && isLinkAuthPending(pendingEdits, authInfoToken); - - // Profile field diffs and link tokens are now auto-computed in the store - - // Handlers const handleChange = (field: string, value: string) => updateField(field as keyof FormState, value); - const avatarCallbacks = { - setAvatarPrompt, - setDeletedFields: (fn: (prev: Record) => Record) => { - const newFields = fn(deletedFields as unknown as Record); - Object.entries(newFields).forEach(([key, value]) => { - setDeletedField(key as keyof typeof deletedFields, value); - }); - }, - handleChange + const applyAvatar = async (url: string) => { + const avatarUrl = await fetchAvatarUrl(url); + if (!avatarUrl) return; + setDeletedField("profile_image_url", false); + handleChange("profile_image_url", avatarUrl); }; const handleLinkChange = (uid: string, value: string) => { @@ -276,8 +278,8 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { const handleSocialLinkChange = (uid: string, value: any) => { const nextUrl = value.platform === "Other" - ? (value.otherUrl || "").trim() - : buildSocialUrl(value.platform, (value.username || "").trim()) || ""; + ? (value.otherUrl ?? "").trim() + : buildSocialUrl(value.platform, (value.username ?? "").trim()) ?? ""; setForm((prev) => ({ ...prev, links: prev.links.map((l) => @@ -304,7 +306,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { ...prev, links: prev.links.filter((l) => l._uid !== uid) })); - // Note: Deletion token (-{id}) is automatically computed by the store }; const resetLinks = () => { @@ -318,7 +319,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { _uid: crypto.randomUUID(), } as ParsedLink], })); - // Note: Link tokens are automatically recomputed by the store }; const toggleAddress = (e?: MouseEvent) => { @@ -347,41 +347,11 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { return (
- - { setAuthInfoOpen(false); setAuthInfoLink(null); }} - onAuthenticate={() => { - if (!authInfoLink) return; - if (!profile.address_verified) return; - if (authInfoProvider) { startOAuth(authInfoProvider.key, authInfoLink.url); return; } - if (!authInfoToken || authInfoPending) return; - addLinkAuthToken(authInfoToken); - setAuthInfoOpen(false); - }} - /> setAvatarPreviewOpen(false)} /> - setAvatarPrompt(null)} - onReauth={() => { - if (!avatarPrompt?.url) return; - const provider = avatarPrompt.provider; - const url = avatarPrompt.url; - setAvatarPrompt(null); - const providerKey = providerKeyByLabel[provider]; - if (providerKey) startOAuth(providerKey, url); - }} - />
{/* Header */} @@ -396,8 +366,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Zcash Address" htmlFor="addr" helpText="Your Zcash address where verification codes are sent." - hasPending={hasPendingField("address", "a")} - pendingHint="Verify to apply edits" isDeleted={deletedFields.address} deleteDisabled={!profile.address_verified} onDelete={toggleAddress} @@ -422,9 +390,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Username" htmlFor="name" helpText="Your unique handle on Zcash.me." - hasPending={hasPendingField("name", "n")} - pendingHint={deletedFields.name ? "⚠ Verify to remove your profile from Zcash.me." : "Verify to apply changes"} - pendingHintClassName={deletedFields.name ? "text-xs text-red-600 italic" : undefined} isDeleted={deletedFields.name} deleteDisabled={!originals.name} onDelete={toggleNameDelete} @@ -488,7 +453,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Display Name" htmlFor="display_name" helpText="Your public display name." - hasPending={hasPendingField("display_name", "h")} isDeleted={deletedFields.display_name} deleteDisabled={!originals.display_name} onDelete={() => setDeletedField("display_name", !deletedFields.display_name)} @@ -497,7 +461,7 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { id="display_name" type="text" value={form.display_name} - placeholder={originals.display_name || "Enter display name"} + placeholder={originals.display_name ?? "Enter display name"} onChange={(e) => handleChange("display_name", e.target.value)} className={FIELD_CLASS} /> @@ -508,7 +472,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Biography" htmlFor="bio" helpText="Your current story arc in 100 characters or less." - hasPending={hasPendingField("bio", "b")} isDeleted={deletedFields.bio} deleteDisabled={!originals.bio} onDelete={() => setDeletedField("bio", !deletedFields.bio)} @@ -531,9 +494,8 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { { setDeletedField('nearest_city', !deletedFields.nearest_city); setNearestCityDisplay(""); @@ -542,18 +504,16 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { { if (typeof val === "string") { setNearestCityDisplay(val); - updateField('nearest_city_id', null); } else { - setNearestCityDisplay(val.fullLabel || ""); - updateField('nearest_city_id', val.id); - updateField('nearest_city_name', val.fullLabel || ""); + setNearestCityDisplay(val.fullLabel ?? ""); + updateField('nearest_city_name', val.fullLabel ?? ""); } }} /> @@ -564,7 +524,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) { label="Profile Image URL" htmlFor="pimg" helpText="Link to PNG or JPG. Search 'free image link host'." - hasPending={hasPendingField("profile_image_url", "i")} isDeleted={deletedFields.profile_image_url} deleteDisabled={!originals.profile_image_url} onDelete={() => setDeletedField("profile_image_url", !deletedFields.profile_image_url)} @@ -596,11 +555,6 @@ export default function ProfileEditor({ profile, links }: ProfileEditorProps) {
- {hasPendingLinks && ( - - Verify to apply changes - - )}
- ) : isVerified ? ( + {isVerified ? (
- {showDiscordAvatarAction && ( - - )} - {showXAvatarAction && ( - - )} - {showGithubAvatarAction && ( + {isOAuthProvider && ( )}
- ) : !canAuthenticate ? ( - hasLinkInput ? ( - - Apply edits to enable authentication - - ) : null - ) : ( - - )} + ) : null}
diff --git a/ui/profile/ProfileSearchDropdown.tsx b/ui/profile/ProfileSearchDropdown.tsx index 22db4173..90c07664 100644 --- a/ui/profile/ProfileSearchDropdown.tsx +++ b/ui/profile/ProfileSearchDropdown.tsx @@ -4,7 +4,7 @@ import type { Profile } from "@/lib/profile/types"; import { getUsernameWithDiscriminator } from "@/lib/profile/profileUtils"; import VerifiedBadge from "@/ui/profile/VerifiedBadge"; import ProfileAvatar from "@/ui/profile/ProfileAvatar"; -import { withFieldBorderState } from "@/ui/styles/fields"; +import { withFieldBorderState } from "@/ui/common/forms/styles"; function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); diff --git a/ui/profile/RedirectModal.tsx b/ui/profile/RedirectModal.tsx deleted file mode 100644 index 9f405e2a..00000000 --- a/ui/profile/RedirectModal.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import type { RedirectModalProps } from "./profileCardTypes"; - -export default function RedirectModal({ isOpen, label }: RedirectModalProps) { - if (!isOpen) return null; - return ( -
-
-
- - - - -
-

Redirecting to {label}

-

- Please authorize the app to verify your profile. -

-
-
- ); -} diff --git a/ui/profile/VerifiedBadge.tsx b/ui/profile/VerifiedBadge.tsx index cc5b1fac..5e8aed60 100644 --- a/ui/profile/VerifiedBadge.tsx +++ b/ui/profile/VerifiedBadge.tsx @@ -107,7 +107,7 @@ export default function VerifiedBadge({ }} aria-label={unverifiedLabel} className={`${baseClasses} leading-none group/badge inline-flex items-center justify-center rounded-full border text-xs font-medium transition-all duration-300 - text-gray-600 bg-gray-100 border-gray-300 shadow-xs px-[0.2rem] hover:px-[0.5rem] py-[0.1rem]`} + text-gray-600 bg-gray-100 border-gray-300 shadow-xs px-[0.2rem] hover:px-[0.5rem] py-[0.1rem]${onClick ? " cursor-pointer" : ""}`} style={{ fontFamily: "inherit" }} >
diff --git a/ui/profile/editorModals.tsx b/ui/profile/editorModals.tsx index 5e164099..d405414f 100644 --- a/ui/profile/editorModals.tsx +++ b/ui/profile/editorModals.tsx @@ -1,4 +1,7 @@ -import { Button, Modal, ModalBody, ModalHeader, ModalFooter, Spinner } from "@/ui/common"; +import Button from "@/ui/common/buttons/Button"; +import Modal from "@/ui/common/modals/Modal"; +import ModalBody from "@/ui/common/modals/ModalBody"; +import Spinner from "@/ui/common/feedback/Spinner"; interface RedirectModalProps { isOpen: boolean; @@ -27,34 +30,6 @@ export function RedirectModal({ isOpen, label }: RedirectModalProps) { ); } -interface AvatarReauthModalProps { - isOpen: boolean; - providerLabel: string; - onReauth: () => void; - onLater: () => void; -} - -export function AvatarReauthModal({ isOpen, providerLabel, onReauth, onLater }: AvatarReauthModalProps) { - return ( - - - -

- Please reauthenticate {providerLabel} to fetch your avatar, or do this later. -

-
- - - - -
- ); -} - interface AvatarPreviewModalProps { isOpen: boolean; src: string; diff --git a/ui/profile/profileCardTypes.ts b/ui/profile/profileCardTypes.ts index d9ac1430..d7b7d3d1 100644 --- a/ui/profile/profileCardTypes.ts +++ b/ui/profile/profileCardTypes.ts @@ -1,7 +1,7 @@ -import type { CSSProperties, MouseEvent } from "react"; -import type { Profile, EnrichedProfileLink, ProfileTrustWarning } from "@/lib/profile/types"; +import type { CSSProperties } from "react"; +import type { Profile, EnrichedProfileLink } from "@/lib/profile/types"; -export type Variant = "default" | "mobile" | "compact"; +export type Variant = "default" | "mobile"; export type LinkVariant = "default" | "simple"; export interface LinkRowClasses { @@ -25,8 +25,8 @@ export interface ProfileLinkRowProps { classes: LinkRowClasses; hideBadge?: boolean; badgeLabels?: { verified: string; unverified: string }; - badgeOnClick?: (event: MouseEvent, link: EnrichedProfileLink) => void; stopPropagation?: boolean; + onVerifyClick?: (link: EnrichedProfileLink) => void; } export interface ProfileCardTextScale { @@ -69,8 +69,6 @@ export interface RedirectModalProps { export interface ProfileCardProps { profile: Profile; - onSelect?: (profile: Profile) => void; - warning?: ProfileTrustWarning | null; fullView?: boolean; duplicateNameCount?: number; onShowQR?: () => void; diff --git a/ui/profile/store.ts b/ui/profile/store.ts new file mode 100644 index 00000000..3a4be483 --- /dev/null +++ b/ui/profile/store.ts @@ -0,0 +1,145 @@ +import { create } from 'zustand'; +import type { Profile } from '@/lib/profile/types'; + +export interface ParsedLink { + id: number | null; + url: string; + username?: string; + previewUrl?: string; + valid: boolean; + reason: string | null; + is_verified: boolean; + verification_expires_at?: string; + _uid: string; + platform?: "X" | "GitHub" | "Instagram" | "Reddit" | "LinkedIn" | "Discord" | "TikTok" | "Bluesky" | "Mastodon" | "Snapchat" | "Telegram" | "Other"; + otherUrl?: string; + label?: string; + icon?: string; + domain?: string; + handle?: string; +} + +export interface FormState { + address: string; + name: string; + display_name: string; + bio: string; + profile_image_url: string; + links: ParsedLink[]; + nearest_city_name: string; +} + +interface DeletedFields { + address: boolean; + name: boolean; + display_name: boolean; + bio: boolean; + profile_image_url: boolean; + nearest_city: boolean; +} + +interface EditsState { + form: FormState; + original: FormState; + deletedFields: DeletedFields; + sessionId: string; + + setForm: (form: FormState | ((prev: FormState) => FormState)) => void; + updateField: (field: keyof FormState, value: any) => void; + setDeletedField: (field: keyof DeletedFields, value: boolean) => void; + initializeForm: (profile: Profile, links: ParsedLink[]) => void; + reset: () => void; +} + +const emptyForm: FormState = { + address: '', + name: '', + display_name: '', + bio: '', + profile_image_url: '', + links: [], + nearest_city_name: '', +}; + +const emptyDeletedFields: DeletedFields = { + address: false, + name: false, + display_name: false, + bio: false, + profile_image_url: false, + nearest_city: false, +}; + +export const useEditsStore = create((set) => ({ + form: emptyForm, + original: emptyForm, + deletedFields: emptyDeletedFields, + sessionId: crypto.randomUUID(), + + setForm: (form) => + set((state) => ({ + form: typeof form === 'function' ? form(state.form) : form, + })), + + updateField: (field, value) => + set((state) => ({ + form: { ...state.form, [field]: value }, + })), + + setDeletedField: (field, value) => + set((state) => { + const newDeletedFields = { ...state.deletedFields, [field]: value }; + const newForm = { ...state.form }; + + if (field === 'nearest_city') { + if (value) { + newForm.nearest_city_name = ''; + } else { + newForm.nearest_city_name = state.original.nearest_city_name; + } + } else { + if (value) { + newForm[field] = '' as any; + } else { + newForm[field] = state.original[field] as any; + } + } + + return { + deletedFields: newDeletedFields, + form: newForm, + }; + }), + + initializeForm: (profile, links) => + set({ + form: { + address: profile.address ?? '', + name: profile.name ?? '', + display_name: profile.display_name ?? '', + bio: profile.bio ?? '', + profile_image_url: profile.profile_image_url ?? '', + links: links ?? [], + nearest_city_name: profile.nearest_city_name ?? '', + }, + original: { + address: profile.address ?? '', + name: profile.name ?? '', + display_name: profile.display_name ?? '', + bio: profile.bio ?? '', + profile_image_url: profile.profile_image_url ?? '', + links: links ?? [], + nearest_city_name: profile.nearest_city_name ?? '', + }, + deletedFields: emptyDeletedFields, + sessionId: crypto.randomUUID(), + }), + + reset: () => + set({ + form: emptyForm, + original: emptyForm, + deletedFields: emptyDeletedFields, + sessionId: crypto.randomUUID(), + }), +})); diff --git a/ui/profile/useProfileEvents.ts b/ui/profile/useProfileEvents.ts deleted file mode 100644 index dec61dce..00000000 --- a/ui/profile/useProfileEvents.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useState, type Dispatch, type SetStateAction } from "react"; - -interface UseProfileEventsResult { - showBack: boolean; - setShowBack: Dispatch>; -} - -export default function useProfileEvents(): UseProfileEventsResult { - const [showBack, setShowBack] = useState(false); - return { showBack, setShowBack }; -} diff --git a/ui/signup/AGENT.md b/ui/signup/AGENT.md new file mode 100644 index 00000000..c5086753 --- /dev/null +++ b/ui/signup/AGENT.md @@ -0,0 +1,92 @@ +# /ui/signup - Profile Creation Forms + +## Purpose +Multi-step form components for creating new Zcash profiles. +Guides users through username, address, bio, and link setup. + +## Components + +| Component | File | Purpose | +|-----------|------|---------| +| `AddUserForm` | AddUserForm.tsx | Main multi-step form | +| `StepContainer` | StepContainer.tsx | Step wrapper with progress | +| `ZcashAddressInput` | ZcashAddressInput.tsx | Address input + validation | +| `LinkInput` | LinkInput.tsx | Generic link input | +| `SocialLinkInput` | SocialLinkInput.tsx | Social media handle input | +| `CitySearchDropdown` | CitySearchDropdown.tsx | Location selection | + +## Signup Flow + +``` +┌─────────────────────────────────────┐ +│ Step 1: Basic Info │ +│ ┌─────────────────────────────┐ │ +│ │ Username: alice │ │ +│ │ Display Name: Alice Z │ │ +│ │ Short Bio: Zcash enthusiast │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ Step 2: Zcash Address │ +│ ┌─────────────────────────────┐ │ +│ │ u1qw3rty... │ ✓ │ +│ └─────────────────────────────┘ │ +│ ⚠️ Use a unified address for │ +│ maximum privacy │ +├─────────────────────────────────────┤ +│ Step 3: Links (Optional) │ +│ ┌─────────────────────────────┐ │ +│ │ Twitter: @alice │ │ +│ │ GitHub: alice │ │ +│ │ [+ Add Link] │ │ +│ └─────────────────────────────┘ │ +├─────────────────────────────────────┤ +│ Step 4: Location (Optional) │ +│ ┌─────────────────────────────┐ │ +│ │ City: San Francisco, CA │ ▼ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +## Zcash Address Validation + +`ZcashAddressInput` provides real-time validation: +```typescript + { ... }} +/> +``` + +- Shows address type (unified, sapling, transparent) +- Warns about transparent address privacy +- Blocks viewing keys +- Hints toward unified addresses + +## Username Validation +Uses `/lib/profile/usernamePolicy.ts`: +- 3-30 characters +- Alphanumeric + underscore only +- No reserved words +- Profanity filter + +## Server Action +Form submits to `createProfileAction`: +```typescript +import { createProfileAction } from '@/lib/signup/createProfileAction'; + +const result = await createProfileAction({ + username, + displayName, + bio, + address, + links, + cityId +}); +``` + +## Testing Harness +- Mock `createProfileAction` for form tests +- Test each step independently +- Validate address input edge cases +- Check city search dropdown behavior diff --git a/ui/signup/AddUserForm.tsx b/ui/signup/AddUserForm.tsx index 880ab8b2..b1e7713b 100644 --- a/ui/signup/AddUserForm.tsx +++ b/ui/signup/AddUserForm.tsx @@ -4,7 +4,7 @@ import ZcashAddressInput from "@/ui/signup/ZcashAddressInput"; import { createPortal } from "react-dom"; import type { Profile } from "@/lib/profile/types"; -import type { City } from "@/lib/directory/types"; +import type { City } from "@/lib/directory/searchCitiesAction"; import { validateZcashAddress } from "@/lib/zcash/zcashUtils"; import { useState, useEffect, useRef } from "react"; import type { SVGProps, FormEvent } from "react"; @@ -18,7 +18,7 @@ import { AnimatePresence } from "framer-motion"; import ProfileSearchDropdown from "@/ui/profile/ProfileSearchDropdown"; import CitySearchDropdown from "@/ui/signup/CitySearchDropdown"; import StepContainer from "@/ui/signup/StepContainer"; -import { FormField } from "@/ui/common"; +import FormField from "@/ui/common/forms/FormField"; function XIcon(props: SVGProps) { return ( @@ -34,7 +34,7 @@ import { normalizeSocialUsername, buildSocialUrl } from "@/lib/profile/usernameN import type { SocialPlatform } from "@/lib/profile/usernameNormalizer"; import { sanitizeUsernameInput, normalizeUsernameForSlug } from "@/lib/profile/usernamePolicy"; import SocialLinkInput from "@/ui/signup/SocialLinkInput"; -import { withFieldBorderState, withFieldFocusWithinBorderState } from "@/ui/styles/fields"; +import { withFieldBorderState, withFieldFocusWithinBorderState } from "@/ui/common/forms/styles"; interface Referrer { id: number; @@ -436,8 +436,9 @@ export default function AddUserForm({ display_name: displayName.trim() || undefined, bio: bio.trim() || undefined, address: address.trim(), - nearest_city_id: nearestCity?.id || undefined, - nearest_city_name: nearestCity?.city_ascii || nearestCity?.city || undefined, + nearest_city_name: nearestCity + ? [nearestCity.city_ascii || nearestCity.city, nearestCity.admin_name, nearestCity.country].filter(Boolean).join(", ") + : undefined, referred_by: typeof referrer === "object" ? referrer?.name || undefined : undefined, referred_by_zcasher_id: typeof referrer === "object" ? referrer?.id || undefined : undefined, is_ns: isNsSignup || undefined, diff --git a/ui/signup/CitySearchDropdown.tsx b/ui/signup/CitySearchDropdown.tsx index a429d479..0db5420d 100644 --- a/ui/signup/CitySearchDropdown.tsx +++ b/ui/signup/CitySearchDropdown.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { searchCitiesAction } from "@/lib/directory/searchCitiesAction"; -import type { City } from "@/lib/directory/types"; -import { withFieldBorderState } from "@/ui/styles/fields"; +import type { City } from "@/lib/directory/searchCitiesAction"; +import { withFieldBorderState } from "@/ui/common/forms/styles"; type CityWithFullLabel = City & { fullLabel?: string }; @@ -62,7 +62,7 @@ export default function CitySearchDropdown({ {results.length > 0 ? ( results.map((c) => (
{ onChange({ ...c, diff --git a/ui/signup/LinkInput.tsx b/ui/signup/LinkInput.tsx index 4a9fbc3b..17876e40 100644 --- a/ui/signup/LinkInput.tsx +++ b/ui/signup/LinkInput.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { isValidUrl } from "@/lib/validation/validators"; -import { Alert } from "@/ui/common"; -import { withFieldBorderState } from "@/ui/styles/fields"; +import Alert from "@/ui/common/feedback/Alert"; +import { withFieldBorderState } from "@/ui/common/forms/styles"; const BASE_FIELD_CLASS = "w-full rounded-2xl border px-3 py-1.5 text-sm font-mono bg-transparent outline-hidden text-gray-800 placeholder-gray-400"; diff --git a/ui/signup/SocialLinkInput.tsx b/ui/signup/SocialLinkInput.tsx index 1d5dd8de..579b3d36 100644 --- a/ui/signup/SocialLinkInput.tsx +++ b/ui/signup/SocialLinkInput.tsx @@ -3,8 +3,8 @@ import { normalizeSocialUsername, buildSocialUrl } from "@/lib/profile/usernameN import type { SocialPlatform } from "@/lib/profile/usernameNormalizer"; import { isValidUrl } from "@/lib/validation/validators"; import HelpIcon from "@/ui/common/HelpIcon"; -import { Alert } from "@/ui/common"; -import { withFieldBorderState } from "@/ui/styles/fields"; +import Alert from "@/ui/common/feedback/Alert"; +import { withFieldBorderState } from "@/ui/common/forms/styles"; const PLATFORM_OPTIONS = [ { key: "X", label: "X (Twitter)" }, diff --git a/ui/signup/ZcashAddressInput.tsx b/ui/signup/ZcashAddressInput.tsx index a2d8ebee..a4811208 100644 --- a/ui/signup/ZcashAddressInput.tsx +++ b/ui/signup/ZcashAddressInput.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { validateZcashAddress, getZcashAddressHint } from "@/lib/zcash/zcashUtils"; import FormField from "@/ui/common/forms/FormField"; -import { withFieldBorderState } from "@/ui/styles/fields"; +import { withFieldBorderState } from "@/ui/common/forms/styles"; interface ZcashAddressInputProps { value: string; diff --git a/ui/social/useVerificationFlow.ts b/ui/social/useVerificationFlow.ts deleted file mode 100644 index ac88fe33..00000000 --- a/ui/social/useVerificationFlow.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { useEffect } from "react"; -import type { Dispatch, SetStateAction } from "react"; -import { normalizeSocialUsername } from "@/lib/profile/usernameNormalizer"; -import { getSession, onAuthStateChange } from "@/lib/supabase/auth"; -import { updateLinkVerificationAction } from "@/lib/verification/updateLinkVerificationAction"; -import { - getXHandle, - getGithubHandle, - getDiscordId, - getDiscordUsername, - normalizeDiscordHandle, -} from "@/lib/profile/providerAvatars"; -import { useEditsStore } from "@/lib/stores/edits"; - -interface LinkedInData { - handle: string | null; - name?: string; - given_name?: string; - family_name?: string; - email?: string; -} - -export default function useVerificationFlow( - profileId: number, - setShowRedirect: Dispatch> -): void { - const { setForm } = useEditsStore(); - useEffect(() => { - const applyVerification = async (session: unknown) => { - if (!session) return; - - const params = new URLSearchParams(window.location.search); - const pIdParam = params.get("verify_pid"); - const urlParam = params.get("verify_url"); - - const pId = pIdParam || localStorage.getItem("verifying_profile_id"); - const url = urlParam || localStorage.getItem("verifying_link_url"); - - if (!pId || !url || String(pId) !== String(profileId)) return; - - const getLinkedInData = (s: any): LinkedInData => { - const identity = s?.user?.identities?.find?.((i: any) => i?.provider === "linkedin_oidc"); - const li = identity?.identity_data || {}; - const handle = li.vanityName || li.preferred_username || null; - return { - handle, - name: li.name, - given_name: li.given_name, - family_name: li.family_name, - email: li.email, - }; - }; - - let verifiedDiscordId: string | null = null; - let verifiedDiscordUrl: string | null = null; - const isXUrl = /^(https?:\/\/)?(www\.)?(x\.com|twitter\.com)\//i.test(url || ""); - const isLinkedInUrl = /^(https?:\/\/)?(www\.)?linkedin\.com\/in\//i.test(url || ""); - const isGithubUrl = /^(https?:\/\/)?(www\.)?github\.com\//i.test(url || ""); - const isDiscordUrl = /^(https?:\/\/)?(www\.)?(discord\.com|discordapp\.com)\/users\//i.test(url || ""); - - if (isXUrl) { - const xUsername = getXHandle(session as any); - const mx = (url || "").replace(/\/$/, "").match(/(?:x\.com|twitter\.com)\/([^/?#]+)/i); - const targetUsername = mx ? mx[1] : null; - const normalizedX = normalizeSocialUsername(xUsername || "", "X").toLowerCase(); - const normalizedTarget = normalizeSocialUsername(targetUsername || "", "X").toLowerCase(); - if (!normalizedX || !normalizedTarget || normalizedX !== normalizedTarget) { - alert(`Verification Mismatch: Logged in as @${xUsername}, but verifying link for @${targetUsername}`); - localStorage.removeItem("verifying_profile_id"); - localStorage.removeItem("verifying_link_url"); - return; - } - } - - if (isLinkedInUrl) { - const liData = getLinkedInData(session); - const ml = (url || "").replace(/\/$/, "").match(/linkedin\.com\/in\/([^/?#]+)/i); - const targetVanity = ml ? ml[1] : null; - const normalizedTarget = normalizeSocialUsername(targetVanity || "", "LinkedIn").toLowerCase(); - - let match = false; - const t = normalizedTarget || ""; - const normalizedHandle = normalizeSocialUsername(liData.handle || "", "LinkedIn").toLowerCase(); - - if (normalizedHandle && normalizedHandle.toLowerCase() === t) match = true; - - if (!match && liData.given_name && liData.family_name) { - const g = liData.given_name.toLowerCase(); - const f = liData.family_name.toLowerCase(); - if (g.length > 1 && f.length > 1 && t.includes(g) && t.includes(f)) match = true; - } - - if (!match && liData.email) { - const emailUser = liData.email.split("@")[0].toLowerCase(); - if (emailUser === t || emailUser.includes(t) || t.includes(emailUser)) match = true; - } - - if (!match) { - alert( - `Verification Mismatch\n\n` + - `Logged in as: ${liData.name || liData.email || "(unknown)"}\n` + - `Target Profile: ${targetVanity || "(unknown)"}\n\n` + - `Your LinkedIn login Name or Email does not clearly match the profile URL.\n` + - `Please ensure the URL contains your name or matches your email.` - ); - localStorage.removeItem("verifying_profile_id"); - localStorage.removeItem("verifying_link_url"); - - const cleanUrl = new URL(window.location.href); - cleanUrl.searchParams.delete("verify_pid"); - cleanUrl.searchParams.delete("verify_url"); - window.history.replaceState({}, "", cleanUrl.toString()); - return; - } - } - - if (isGithubUrl) { - const ghHandle = getGithubHandle(session as any); - const m = (url || "").replace(/\/$/, "").match(/github\.com\/([^/?#]+)/i); - const targetGh = m ? m[1] : (url || "").replace(/\/$/, "").split("/").pop(); - const normalizedGh = normalizeSocialUsername(ghHandle || "", "GitHub").toLowerCase(); - const normalizedTarget = normalizeSocialUsername(targetGh || "", "GitHub").toLowerCase(); - if (!normalizedGh || !normalizedTarget || normalizedGh !== normalizedTarget) { - alert(`Verification Mismatch: Logged in as ${ghHandle}, but verifying link for ${targetGh}`); - localStorage.removeItem("verifying_profile_id"); - localStorage.removeItem("verifying_link_url"); - return; - } - } - - if (isDiscordUrl) { - const discordId = getDiscordId(session as any); - const discordUsername = await getDiscordUsername(session as any); - const m = (url || "").replace(/\/$/, "").match(/(?:discord\.com|discordapp\.com)\/users\/([^/?#]+)/i); - const targetDiscord = m ? m[1] : (url || "").replace(/\/$/, "").split("/").pop(); - const targetDecoded = targetDiscord ? decodeURIComponent(targetDiscord) : null; - const targetNorm = normalizeDiscordHandle(targetDecoded); - const isNumericTarget = /^[0-9]+$/.test(targetNorm); - const usernameCandidates = [discordUsername] - .filter(Boolean) - .flatMap((name) => { - const normalized = normalizeDiscordHandle(name); - const base = normalized.replace(/#\d+$/, ""); - return [normalized, base].filter(Boolean); - }); - let match = false; - - if (isNumericTarget) { - match = !!discordId && String(discordId) === String(targetNorm); - } else { - match = !!targetNorm && usernameCandidates.includes(targetNorm); - } - - if (!match) { - alert(`Verification Mismatch: Logged in as ${discordUsername || discordId || "(unknown)"}, but verifying link for ${targetDecoded || "(unknown)"}`); - localStorage.removeItem("verifying_profile_id"); - localStorage.removeItem("verifying_link_url"); - return; - } - - if (discordId) { - verifiedDiscordId = String(discordId); - verifiedDiscordUrl = `https://discord.com/users/${verifiedDiscordId}`; - } - } - - try { - const normalizedUrl = url.replace(/\/$/, ""); - let handle = normalizedUrl.split("/").pop() || ""; - if (/(?:x\.com|twitter\.com)\//i.test(normalizedUrl)) { - const m = normalizedUrl.match(/(?:x\.com|twitter\.com)\/([^/?#]+)/i); - handle = m ? m[1] : handle; - } - if (/github\.com\//i.test(normalizedUrl)) { - const m = normalizedUrl.match(/github\.com\/([^/?#]+)/i); - handle = m ? m[1] : handle; - } - if (/discord(?:app)?\.com\/users\//i.test(normalizedUrl)) { - const m = normalizedUrl.match(/users\/([0-9]+)/i); - handle = m ? m[1] : handle; - } - if (/linkedin\.com\/in\//i.test(normalizedUrl)) { - const m = normalizedUrl.match(/linkedin\.com\/in\/([^/?#]+)/i); - handle = m ? m[1] : handle; - } - let hosts: string[] = []; - if (isXUrl) hosts = ["x.com", "twitter.com", "www.x.com", "www.twitter.com"]; - if (isLinkedInUrl) hosts = ["linkedin.com", "www.linkedin.com"]; - if (isGithubUrl) hosts = ["github.com", "www.github.com"]; - if (isDiscordUrl) hosts = ["discord.com", "www.discord.com", "discordapp.com", "www.discordapp.com"]; - const schemes = ["https://"]; - const variants: string[] = []; - for (const h of hosts) { - for (const s of schemes) { - const pathPrefix = isLinkedInUrl ? "/in/" : isDiscordUrl ? "/users/" : "/"; - variants.push(`${s}${h}${pathPrefix}${handle}`); - variants.push(`${s}${h}${pathPrefix}${handle}/`); - } - } - - const updatePayload: Record = { - is_verified: true, - updated_at: new Date().toISOString(), - }; - if (isDiscordUrl && verifiedDiscordUrl) { - updatePayload.url = verifiedDiscordUrl; - } - - const result = await updateLinkVerificationAction(profileId, handle, variants, updatePayload as any); - if (!result.ok) { - // Silent failure - } - } catch { - // Silent failure - } - - setForm((prev) => ({ - ...prev, - links: prev.links.map((l) => { - const u1 = (l.url || "").trim().replace(/\/$/, ""); - const u2 = (url || "").trim().replace(/\/$/, ""); - if (u1 !== u2) return l; - return { - ...l, - is_verified: true, - url: isDiscordUrl && verifiedDiscordUrl ? verifiedDiscordUrl : l.url, - }; - }), - })); - - setTimeout(() => { - localStorage.removeItem("verifying_profile_id"); - localStorage.removeItem("verifying_link_url"); - setShowRedirect(false); - const cleanUrl = new URL(window.location.href); - cleanUrl.searchParams.delete("verify_pid"); - cleanUrl.searchParams.delete("verify_url"); - window.history.replaceState({}, "", cleanUrl.toString()); - }, 1000); - }; - - setTimeout(() => { - getSession().then(({ data: { session } }) => { - if (session) applyVerification(session); - }); - }, 500); - - const { - data: { subscription }, - } = onAuthStateChange((event, session) => { - if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || (event === "INITIAL_SESSION" && session)) { - applyVerification(session); - } - }); - - const checkSession = () => { - getSession().then(({ data: { session } }) => { - if (session) applyVerification(session); - }); - }; - - checkSession(); - setTimeout(checkSession, 1000); - setTimeout(checkSession, 3000); - - return () => subscription.unsubscribe(); - }, [profileId, setShowRedirect, setForm]); -} diff --git a/ui/swap/AGENT.md b/ui/swap/AGENT.md new file mode 100644 index 00000000..36f78f6c --- /dev/null +++ b/ui/swap/AGENT.md @@ -0,0 +1,72 @@ +# /ui/swap - Swap Composer UI + +## Purpose +User interface for cryptocurrency swaps via Defuse Protocol OneClick. +Allows users to receive any token and convert to ZEC. + +## Components + +| Component | File | Purpose | +|-----------|------|---------| +| `SwapComposer` | SwapComposer.tsx | Main swap interface | +| `SwapCurrencyPair` | SwapCurrencyPair.tsx | From/To token selection | +| `SwapQuoteDisplay` | SwapQuoteDisplay.tsx | Quote details and rate | +| `SwapDepositDisplay` | SwapDepositDisplay.tsx | Deposit address & memo | +| `SwapAddressInput` | SwapAddressInput.tsx | Destination Zcash address | +| `SwapSlippageControl` | SwapSlippageControl.tsx | Slippage tolerance setting | + +## Swap Flow UI + +``` +┌─────────────────────────────────────┐ +│ From: [ETH ▼] [ 1.5 ] │ +│ ↓ │ +│ To: [ZEC ▼] [ ~245 ] │ +├─────────────────────────────────────┤ +│ Rate: 1 ETH = 163.33 ZEC │ +│ Slippage: [0.5%] [1%] [2%] │ +├─────────────────────────────────────┤ +│ Deposit to: 0x1234...5678 │ +│ [Copy Address] [Show QR] │ +├─────────────────────────────────────┤ +│ Your ZEC arrives at: │ +│ u1qw3r...xyz │ +└─────────────────────────────────────┘ +``` + +## Zcash Integration + +### Destination Address +- Must be valid Zcash address +- Unified addresses (u1...) preferred +- Validates using `/lib/zcash/zcashUtils.ts` + +### Privacy Note +- Swap deposits are on public chains (ETH, etc.) +- Final ZEC receipt can be to shielded address +- Users should understand privacy implications + +## State Management +Uses Zustand store at `/lib/stores/swap.ts`: +```typescript +const { fromToken, toToken, quote, deposit } = useSwapStore(); +``` + +## Quote Lifecycle +1. User selects tokens and amount +2. `SwapCurrencyPair` triggers quote fetch +3. `SwapQuoteDisplay` shows rate (expires in ~30s) +4. User confirms → deposit address generated +5. `SwapDepositDisplay` shows where to send + +## Testing Harness +- Mock OneClick SDK responses +- Test token selection +- Verify quote display formatting +- Check address validation errors + +## Error States +- Quote expired (refresh button) +- Insufficient liquidity +- Invalid destination address +- Network errors diff --git a/ui/swap/SwapAddressInput.tsx b/ui/swap/SwapAddressInput.tsx index df530638..44f4c015 100644 --- a/ui/swap/SwapAddressInput.tsx +++ b/ui/swap/SwapAddressInput.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormField } from "@/ui/common"; +import FormField from "@/ui/common/forms/FormField"; interface SwapAddressInputProps { label: string; diff --git a/ui/thread/AGENT.md b/ui/thread/AGENT.md new file mode 100644 index 00000000..8aa9d6f0 --- /dev/null +++ b/ui/thread/AGENT.md @@ -0,0 +1,89 @@ +# /ui/thread - Discussion Board UI + +## Purpose +Components for Zcash-verified discussion boards. Users post messages +by proving identity via blockchain transaction. + +## Components + +| Component | File | Purpose | +|-----------|------|---------| +| `ThreadBoard` | ThreadBoard.tsx | Main board container | +| `ThreadFeed` | ThreadFeed.tsx | Scrollable message list | +| `ThreadCard` | ThreadCard.tsx | Individual message card | +| `ThreadComposer` | ThreadComposer.tsx | Message input form | +| `ZcashVerificationComposer` | ZcashVerificationComposer.tsx | OTP-verified composer | +| `BoardHeader` | BoardHeader.tsx | Board title and info | +| `BoardSelector` | BoardSelector.tsx | Board selection dropdown | +| `SidebarNav` | SidebarNav.tsx | Navigation sidebar | +| `CreateBoardModal` | CreateBoardModal.tsx | New board creation | + +## Board Structure + +``` +┌─────────────────────────────────────────────────────┐ +│ ┌──────────┐ ┌────────────────────────────────────┐ │ +│ │ Boards │ │ General Discussion │ │ +│ │ ─────── │ │ ───────────────── │ │ +│ │ General │ │ ┌──────────────────────────────┐ │ │ +│ │ Tech │ │ │ alice.zcash.me 2h ago│ │ │ +│ │ Trading │ │ │ Just sent my first shielded │ │ │ +│ │ │ │ │ transaction! ✓ │ │ │ +│ │ [+] │ │ └──────────────────────────────┘ │ │ +│ │ │ │ ┌──────────────────────────────┐ │ │ +│ │ │ │ │ bob.zcash.me 5h ago│ │ │ +│ │ │ │ │ Welcome to the community! │ │ │ +│ │ │ │ └──────────────────────────────┘ │ │ +│ └──────────┘ │ │ │ +│ │ ┌──────────────────────────────┐ │ │ +│ │ │ Write a message... │ │ │ +│ │ │ [Verify & Post]│ │ │ +│ │ └──────────────────────────────┘ │ │ +│ └────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## Zcash Verification + +### Verified Posting +Users must verify each post via Zcash transaction: +1. Write message +2. Generate OTP +3. Send small tx with OTP in memo +4. Message posted after confirmation + +```tsx + postMessage(message)} +/> +``` + +### Anti-Spam +- Each post requires on-chain proof +- Small fee (~0.0001 ZEC) per post +- Links posts to verified profiles + +## State Management +Uses Zustand store at `/lib/stores/thread.ts`: +- Current board selection +- Message list +- Composer content + +## Types +See `/lib/thread/types.ts`: +```typescript +interface ThreadMessage { + id: string; + boardId: string; + authorId: string; + content: string; + createdAt: string; + verified: boolean; +} +``` + +## Testing Harness +- Mock thread actions for unit tests +- Test message rendering +- Verify composer validation +- Test board switching diff --git a/ui/thread/BoardHeader.tsx b/ui/thread/BoardHeader.tsx index fb147f63..5ee7310b 100644 --- a/ui/thread/BoardHeader.tsx +++ b/ui/thread/BoardHeader.tsx @@ -2,7 +2,7 @@ import { Board } from '@/lib/thread/types'; import { formatDistanceToNow } from '@/lib/thread/utils'; -import { Card } from '@/ui/common'; +import Card from '@/ui/common/layout/Card'; interface BoardHeaderProps { board: Board; diff --git a/ui/thread/BoardSelector.tsx b/ui/thread/BoardSelector.tsx index ca230a94..50c1c02a 100644 --- a/ui/thread/BoardSelector.tsx +++ b/ui/thread/BoardSelector.tsx @@ -2,7 +2,8 @@ import { Board } from '@/lib/thread/types'; import { useState } from 'react'; -import { Button, Card } from '@/ui/common'; +import Button from '@/ui/common/buttons/Button'; +import Card from '@/ui/common/layout/Card'; interface BoardSelectorProps { boards: Board[]; diff --git a/ui/thread/CreateBoardModal.tsx b/ui/thread/CreateBoardModal.tsx index dc2b14ee..a53797e3 100644 --- a/ui/thread/CreateBoardModal.tsx +++ b/ui/thread/CreateBoardModal.tsx @@ -1,7 +1,14 @@ 'use client'; import { useState } from 'react'; -import { Modal, ModalHeader, ModalBody, ModalFooter, FormField, Button, Input, TextArea } from '@/ui/common'; +import Modal from '@/ui/common/modals/Modal'; +import ModalHeader from '@/ui/common/modals/ModalHeader'; +import ModalBody from '@/ui/common/modals/ModalBody'; +import ModalFooter from '@/ui/common/modals/ModalFooter'; +import FormField from '@/ui/common/forms/FormField'; +import Button from '@/ui/common/buttons/Button'; +import Input from '@/ui/common/forms/Input'; +import TextArea from '@/ui/common/forms/TextArea'; interface CreateBoardModalProps { isOpen: boolean; diff --git a/ui/thread/SidebarNav.tsx b/ui/thread/SidebarNav.tsx index f20cef2a..463d7c90 100644 --- a/ui/thread/SidebarNav.tsx +++ b/ui/thread/SidebarNav.tsx @@ -2,7 +2,7 @@ import { Board } from '@/lib/thread/types'; import { useRouter, usePathname } from 'next/navigation'; -import { Button } from '@/ui/common'; +import Button from '@/ui/common/buttons/Button'; interface SidebarNavProps { boards: Board[]; diff --git a/ui/thread/ThreadCard.tsx b/ui/thread/ThreadCard.tsx index f0c8d2fb..fd6be021 100644 --- a/ui/thread/ThreadCard.tsx +++ b/ui/thread/ThreadCard.tsx @@ -3,7 +3,8 @@ import Image from 'next/image'; import { ThreadMessage } from '@/lib/thread/types'; import { formatDistanceToNow } from '@/lib/thread/utils'; -import { Card, Badge } from '@/ui/common'; +import Card from '@/ui/common/layout/Card'; +import Badge from '@/ui/common/feedback/Badge'; interface ThreadCardProps { message: ThreadMessage; diff --git a/ui/thread/ZcashVerificationComposer.tsx b/ui/thread/ZcashVerificationComposer.tsx index b4874777..cfaf6473 100644 --- a/ui/thread/ZcashVerificationComposer.tsx +++ b/ui/thread/ZcashVerificationComposer.tsx @@ -1,7 +1,9 @@ 'use client'; import { useState, useRef, useEffect } from 'react'; -import { Card, Button, Input } from '@/ui/common'; +import Card from '@/ui/common/layout/Card'; +import Button from '@/ui/common/buttons/Button'; +import Input from '@/ui/common/forms/Input'; import { OtpInput } from '@/ui/verification/OtpInput'; interface ZcashVerificationComposerProps { diff --git a/ui/thread/index.ts b/ui/thread/index.ts deleted file mode 100644 index a9130556..00000000 --- a/ui/thread/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { ThreadCard } from './ThreadCard'; -export { ThreadFeed } from './ThreadFeed'; -export { ThreadComposer } from './ThreadComposer'; -export { BoardHeader } from './BoardHeader'; -export { SidebarNav } from './SidebarNav'; -export { CreateBoardModal } from './CreateBoardModal'; -export { BoardSelector } from './BoardSelector'; -export { ThreadBoard } from './ThreadBoard'; diff --git a/ui/verification/AGENT.md b/ui/verification/AGENT.md new file mode 100644 index 00000000..58e350c6 --- /dev/null +++ b/ui/verification/AGENT.md @@ -0,0 +1,92 @@ +# /ui/verification - ZVS Verification UI + +## Purpose +User interface for ZVS (Zcash Verification System) based identity verification. +Users prove Zcash address ownership by sending a transaction with a deterministic OTP. + +## ZVS Verification Flow +1. User clicks "Generate QR" → calls `generateMemoAction` server action +2. Server creates memo + URI, registers memo in in-memory store, returns both to client +3. Client stores memo + URI in React state, displays QR +4. User sends transaction to ZVS address with memo +5. Backend wallet receives tx, computes OTP from memo, sends ZEC back with OTP +6. User enters OTP in UI +7. Client calls `confirmOtpAction` with memo + OTP +8. Server checks memo is server-issued, verifies OTP, marks profile as verified +9. On 5th failed attempt, server invalidates memo and returns a new one + +**Important:** Verification must be completed in one session. If user refreshes or navigates away, they must generate a new QR and send again. + +## Components + +| Component | File | Purpose | +|-----------|------|---------| +| `ProfileVerification` | ProfileVerification.tsx | Main verification flow — server-side QR generation + OTP input | +| `VerifyProfileModal` | VerifyProfileModal.tsx | Modal wrapper for ProfileVerification | +| `OtpInput` | OtpInput.tsx | 6-digit code input field | +| `QrUriBlock` | QrUriBlock.tsx | QR code with zcash: URI | +| `AmountAndWallet` | AmountAndWallet.tsx | Amount input + generate QR button | +| `HelpMessage` | HelpMessage.tsx | Contextual help text | + +## Verification Flow UI + +``` +┌─────────────────────────────────────┐ +│ Generate QR (calls server action) │ +│ ┌─────────────┐ │ +│ │ QR CODE │ Amount: 0.003 ZEC │ +│ │ │ │ +│ └─────────────┘ │ +│ Memo: zvs/1234567890123456,u1... │ +├─────────────────────────────────────┤ +│ Enter OTP │ +│ ┌───┬───┬───┬───┬───┬───┐ │ +│ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ [Submit]│ +│ └───┴───┴───┴───┴───┴───┘ │ +│ "4 attempts remaining" │ +└─────────────────────────────────────┘ +``` + +## Memo Format +``` +zvs/{session_id},{user_address} +``` +- `session_id`: 16 ASCII digits (generated server-side) +- `user_address`: User's Zcash unified address + +## State Management + +### Local React State (ProfileVerification) +```typescript +const [currentMemo, setCurrentMemo] = useState(""); // Memo from server +const [currentUri, setCurrentUri] = useState(""); // zcash: URI from server +const [qrVisible, setQrVisible] = useState(false); // Show QR? +const [otp, setOtp] = useState(""); // User's OTP input +const [isGenerating, setIsGenerating] = useState(false); // Server call in progress? +``` + +### store.ts (Zustand) - for cross-component state +```typescript +import { useMessagingStore } from "@/ui/verification/store"; + +const { + verify, // { amount, zId, sessionId, userAddress } + verifyQrEnabled, // QR visible? + verificationError, + setVerify, + setVerifyQrEnabled, + resetVerification +} = useMessagingStore(); +``` + +## Exhaustion Handling +When the server returns `status: "exhausted"` with `newMemo` + `newUri`: +- Client swaps `currentMemo` and `currentUri` to the new values +- OTP input is cleared +- QR code updates automatically +- User must send a new transaction with the new memo + +## No DB Persistence +- Memo is stored in client React state only (no database) +- Server tracks memos in an in-memory store (for attempt counting + validation) +- If user leaves the page, they must start over diff --git a/ui/verification/AmountAndWallet.tsx b/ui/verification/AmountAndWallet.tsx index ae4f3337..c822037c 100644 --- a/ui/verification/AmountAndWallet.tsx +++ b/ui/verification/AmountAndWallet.tsx @@ -3,8 +3,8 @@ import { useEffect, useState, useRef } from "react"; import { motion, useReducedMotion } from "framer-motion"; import { getRateAction } from "@/lib/rates/getRateAction"; -import { INLINE_SELECTOR_TRIGGER_CLASSES, OUTLINE_ACTION_BUTTON_CLASSES } from "@/ui/styles/interactive"; -import { withFieldBorderState } from "@/ui/styles/fields"; +import { INLINE_SELECTOR_TRIGGER_CLASSES, OUTLINE_ACTION_BUTTON_CLASSES } from "@/ui/common/buttons/styles"; +import { withFieldBorderState } from "@/ui/common/forms/styles"; interface Currency { symbol: string; @@ -74,6 +74,7 @@ interface AmountAndWalletProps { openWalletLabel?: string; showOpenWallet?: boolean; showUsdPill?: boolean; + disabled?: boolean; // Token selector props (optional) asset?: string; @@ -94,6 +95,7 @@ export default function AmountAndWallet({ openWalletLabel = "Open in Wallet", showOpenWallet = true, showUsdPill = false, + disabled = false, // Token selector props (optional) asset = "ZEC", assetOptions = [], @@ -637,8 +639,9 @@ export default function AmountAndWallet({ {showOpenWallet && ( {openWalletLabel} diff --git a/ui/verification/InlineOtpForm.tsx b/ui/verification/InlineOtpForm.tsx deleted file mode 100644 index 0d1f9084..00000000 --- a/ui/verification/InlineOtpForm.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { useOtpFlow, OtpStep } from "./useOtpFlow"; -import { OtpInput } from "./OtpInput"; -import { confirmOtpAction } from "@/lib/verification/confirmOtpAction"; -import type { Profile } from "@/lib/profile/types"; -import { Button, FormField } from "@/ui/common"; - -interface SuccessData { - status: string; - message: string; -} - -interface InlineOtpFormProps { - profile: Partial; - onSuccess?: (data: SuccessData) => void; -} - -export default function InlineOtpForm({ profile, onSuccess }: InlineOtpFormProps) { - const otpFlow = useOtpFlow(confirmOtpAction, { - onSuccess: (data) => { - if (onSuccess && data?.status) { - onSuccess({ status: data.status, message: "OTP accepted. Page will refresh shortly." }); - } - }, - }); - - const zid = profile?.id; - - return ( -
- {otpFlow.step === OtpStep.ENTRY && ( - -
- { - if (zid) otpFlow.submit(zid); - }} - placeholder="Paste your OTP" - hideLabel={true} - className="flex-1" - /> - -
-
- )} - {otpFlow.step === OtpStep.CHECKING && ( -
Checking your code...
- )} - {otpFlow.step === OtpStep.RESULT && ( -
-
- {otpFlow.message} -
- {otpFlow.status !== "ok" && ( - - )} -
- )} -
- ); -} diff --git a/ui/verification/ProfileVerification.tsx b/ui/verification/ProfileVerification.tsx index 70a359bf..3222ea16 100644 --- a/ui/verification/ProfileVerification.tsx +++ b/ui/verification/ProfileVerification.tsx @@ -1,335 +1,323 @@ -import { useEffect, useMemo, useState } from "react"; -import type { Profile, PendingEdits } from "@/lib/profile/types"; +import { useMemo, useState, useCallback } from "react"; +import type { Profile } from "@/lib/profile/types"; +import type { ProfileEditsPayload } from "@/lib/api/types"; import QrUriBlock from "@/ui/verification/QrUriBlock"; import AmountAndWallet from "@/ui/verification/AmountAndWallet"; - -import SubmitOtp from "@/ui/verification/SubmitOtp"; -import InlineOtpForm from "@/ui/verification/InlineOtpForm"; -import { buildZcashUri, buildZcashEditMemo } from "@/lib/zcash/zcashUtils"; - -import useVerificationPolling from "@/ui/verification/useVerificationPolling"; -import ProgressStep from "@/ui/verification/ProgressStep"; -import { useMessagingStore } from "@/lib/stores/messaging"; -import { Alert } from "@/ui/common"; - -const SIGNIN_ADDR = "u1lff6xhc9p2c3aefrms5624aqd5mdlys87xcu0u0g3rynnjfs4g5nf0u5q8sczex3jctc2xesauktvdr9gd77zauaejje3zrdpj4uppssdmzzu33lfkzc9y0hlq7rt94kt4rqpq6d4h8a0px597htclme3pav3wft4k94u4pqqn3h4dmdp8wcvvumgqak5ynwy7qm6e797t356ud38we"; +import { OtpInput } from "@/ui/verification/OtpInput"; +import { generateMemoAction } from "@/lib/verification/generateMemoAction"; +import { confirmOtpAction } from "@/lib/verification/confirmOtpAction"; +import Alert from "@/ui/common/feedback/Alert"; +import Button from "@/ui/common/buttons/Button"; +import { useEditsStore } from "@/ui/profile/store"; const MIN_SIGNIN_AMOUNT = 0.001; -const DEFAULT_SIGNIN_AMOUNT = (MIN_SIGNIN_AMOUNT * 3).toFixed(3); +const DEFAULT_SIGNIN_AMOUNT = "0.003"; interface ProfileVerificationProps { profile: Profile; - pendingEdits: PendingEdits; } export default function ProfileVerification({ profile, - pendingEdits, }: ProfileVerificationProps) { - const verify = useMessagingStore(state => state.verify); - const verifyQrEnabled = useMessagingStore(state => state.verifyQrEnabled); - const pollStatus = useMessagingStore(state => state.pollStatus); - const pollOtpPhase = useMessagingStore(state => state.pollOtpPhase); - const otpInlineSuccess = useMessagingStore(state => state.otpInlineSuccess); - const pollDebug = useMessagingStore(state => state.pollDebug); - const setVerify = useMessagingStore(state => state.setVerify); - const setVerifyQrEnabled = useMessagingStore(state => state.setVerifyQrEnabled); - const resetVerificationPolling = useMessagingStore(state => state.resetVerificationPolling); - - // Compute verification memo reactively from pending edits - const memo = useMemo(() => { - const zId = verify.zId ?? profile.id ?? null; - if (!zId) return ""; - - const profileEdits = pendingEdits.profile ?? {}; - const linkTokens = pendingEdits.l ?? []; - const hasEdits = Object.keys(profileEdits).length > 0 || linkTokens.length > 0; - const profileDiff = hasEdits ? { ...profileEdits, l: linkTokens } : {}; - return buildZcashEditMemo(profileDiff, String(zId), verify.requestId ?? null); - }, [profile.id, verify.zId, verify.requestId, pendingEdits]); - - const amount = verify?.amount ?? DEFAULT_SIGNIN_AMOUNT; - - const [isOtpOpen, setIsOtpOpen] = useState(false); - const [showFooterHelp, setShowFooterHelp] = useState(false); - - const { - startPolling, - progressSteps, - progressState, - progressPercent, - progressBarClass, - statusLine, - otpPhaseSteps, - showOtpPhaseLine, - progressExplainer, - handleInlineOtpSuccess, - } = useVerificationPolling(); - - const explainerText = useMemo(() => { - const profileEdits = pendingEdits?.profile ?? {}; - const deleted = Array.isArray(profileEdits?.d) ? profileEdits.d : []; - const changedFields: string[] = []; - - const hasField = (key: string, token: string) => - Boolean(profileEdits?.[key as keyof typeof profileEdits]) || deleted.includes(token); - - if (hasField("name", "n")) changedFields.push("username"); - if (hasField("display_name", "h")) changedFields.push("display name"); - if (hasField("bio", "b")) changedFields.push("bio"); - if (hasField("profile_image_url", "i")) - changedFields.push("profile image"); - if (profileEdits?.c) changedFields.push("nearest city"); - - const hasLinks = - Array.isArray(pendingEdits?.l) && pendingEdits.l.length > 0; - if (hasLinks) changedFields.push("links"); - - if (hasField("address", "a")) changedFields.push("address"); - - if (changedFields.length === 0) { - return "Waiting for edits, if any."; + // Get edits from store + const { form, original } = useEditsStore(); + + // Build edits payload from store (only include changed fields) + const buildEditsPayload = useCallback((): ProfileEditsPayload | undefined => { + const edits: ProfileEditsPayload = {}; + let hasChanges = false; + + // Compare scalar fields + if (form.name !== original.name) { + edits.name = form.name; + hasChanges = true; + } + if (form.display_name !== original.display_name) { + edits.display_name = form.display_name; + hasChanges = true; + } + if (form.bio !== original.bio) { + edits.bio = form.bio; + hasChanges = true; + } + if (form.profile_image_url !== original.profile_image_url) { + edits.profile_image_url = form.profile_image_url; + hasChanges = true; + } + if (form.nearest_city_name !== original.nearest_city_name) { + edits.nearest_city_name = form.nearest_city_name; + hasChanges = true; + } + + // Handle links - compare by id and url + const formLinkIds = new Set(form.links.map((l) => l.id)); + const linkEdits: ProfileEditsPayload["links"] = []; + + // Find deleted links (in original but not in form) + for (const origLink of original.links) { + if (origLink.id && !formLinkIds.has(origLink.id)) { + linkEdits.push({ id: origLink.id, url: origLink.url, platform: origLink.platform, _delete: true }); + } + } + + // Find new and updated links + for (const formLink of form.links) { + if (!formLink.id) { + // New link + linkEdits.push({ url: formLink.url, label: formLink.label, platform: formLink.platform }); + } else { + // Check if updated + const origLink = original.links.find((l) => l.id === formLink.id); + if (origLink && (origLink.url !== formLink.url || origLink.label !== formLink.label)) { + linkEdits.push({ id: formLink.id, url: formLink.url, label: formLink.label, platform: formLink.platform }); + } + } + } + + if (linkEdits.length > 0) { + edits.links = linkEdits; + hasChanges = true; } - const last = changedFields[changedFields.length - 1]; - const prefix = changedFields.slice(0, -1); - const list = - changedFields.length === 1 - ? last - : changedFields.length === 2 - ? `${prefix[0]} and ${last}` - : `${prefix.join(", ")}, and ${last}`; + return hasChanges ? edits : undefined; + }, [form, original]); - return `Contains requested changes to ${list}.`; - }, [pendingEdits]); + // Local UI state + const [amount, setAmount] = useState(DEFAULT_SIGNIN_AMOUNT); + const [qrVisible, setQrVisible] = useState(false); + const [otp, setOtp] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + const [otpResult, setOtpResult] = useState<{ ok: boolean; message: string } | null>(null); - useEffect(() => { - resetVerificationPolling(); - }, [pendingEdits, resetVerificationPolling]); + // Memo + URI returned from the server + const [currentMemo, setCurrentMemo] = useState(""); + const [currentUri, setCurrentUri] = useState(""); - const { validAmount, error, verifyUri } = useMemo(() => { + // Validate amount + const { validAmount, amountError } = useMemo(() => { const cleaned = (amount ?? "").trim(); const raw = cleaned.replace(/[^\d.]/g, ""); const num = parseFloat(raw); const validMin = !Number.isNaN(num) && num >= MIN_SIGNIN_AMOUNT; - const uri = buildZcashUri( - SIGNIN_ADDR, - raw, - memo && memo !== "N/A" ? memo : "" - ); + return { validAmount: validMin, - error: validMin + amountError: validMin ? "" : `Authentication requires at least ${MIN_SIGNIN_AMOUNT} ZEC`, - verifyUri: uri }; - }, [amount, memo]); - - useEffect(() => { - if (pollStatus === "matched") setShowFooterHelp(false); - }, [pollStatus]); - - const handleGenerateQr = () => { - if (!verifyUri || error) return; - const zid = verify?.zId ?? profile?.id; - if (!zid) return; - setVerifyQrEnabled(true); - void startPolling(String(zid)); - }; + }, [amount]); + + // Generate QR — calls the server to create memo + URI + const handleGenerateQr = useCallback(async () => { + if (!validAmount || !profile?.id) return; + + setError(""); + setOtpResult(null); + setOtp(""); + setIsGenerating(true); + + try { + const result = await generateMemoAction( + profile.id, + amount.replace(/[^\d.]/g, "") + ); + + if (result.ok && result.memo && result.uri) { + setCurrentMemo(result.memo); + setCurrentUri(result.uri); + setQrVisible(true); + } else { + setError(result.error ?? "Failed to generate QR code."); + } + } catch { + setError("Failed to generate QR code. Please try again."); + } finally { + setIsGenerating(false); + } + }, [validAmount, profile?.id, amount]); + + // Handle OTP submission + const handleSubmitOtp = useCallback(async () => { + if (!otp.trim() || !profile.id || !currentMemo) return; + + setIsSubmitting(true); + setOtpResult(null); + setError(""); + + try { + // Build edits payload from store + const edits = buildEditsPayload(); + const response = await confirmOtpAction(profile.id, otp.trim(), currentMemo, edits); + + if (response.ok) { + const message = edits + ? "Verification successful! Changes saved. Refreshing..." + : "Verification successful! Refreshing..."; + setOtpResult({ ok: true, message }); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + // Check if the memo was exhausted and a new one was issued + const data = response.data as Record | undefined; + if (data?.status === "exhausted" && data.newMemo && data.newUri) { + setCurrentMemo(data.newMemo as string); + setCurrentUri(data.newUri as string); + setOtp(""); + } + + setOtpResult({ + ok: false, + message: response.error || "Invalid verification code.", + }); + } + } catch { + setOtpResult({ ok: false, message: "An error occurred. Please try again." }); + } finally { + setIsSubmitting(false); + } + }, [otp, profile.id, currentMemo, buildEditsPayload]); - const handleCopyDebug = () => { - if (!pollDebug) return; - void navigator.clipboard.writeText(pollDebug).catch(() => {}); - }; + // Handle OTP input change - strip non-digits + const handleOtpChange = useCallback((value: string) => { + setOtp(value.replace(/\D/g, "")); + }, []); return ( - <> -
- - - {/* Header */} -
-

- - - To verify, send from {" "} - window.scrollTo({ top: 0, behavior: "smooth" })} - > - {profile?.name ?? "Your profile"} - +
+ {/* Header */} +
+

+ + To verify, send from{" "} + window.scrollTo({ top: 0, behavior: "smooth" })} + > + {profile?.name ?? "Your profile"} + +

+
- {/* removed amount requirement + help from header */} -

-
- - {/* Memo Editor */} -
-
+ {/* Memo Display - shown after generation */} + {currentMemo && ( +
+
- {explainerText} + Memo (do not modify)