Cards.tsx raw
1 import {
2 ArrowUpRightIcon,
3 CheckIcon,
4 ClockIcon,
5 CreditCardIcon,
6 FingerprintIcon,
7 InfoIcon,
8 LinkIcon,
9 PlusIcon,
10 ShieldCheckIcon,
11 XIcon,
12 ZapIcon,
13 } from "lucide-react";
14 import React from "react";
15 import { Link, useNavigate } from "react-router";
16 import twoFiatLogo from "src/assets/cards/2fiat.png";
17 import freedomiaLogo from "src/assets/cards/freedomia.png";
18 import redotpayLogo from "src/assets/cards/redotpay.png";
19 import solvocardLogo from "src/assets/cards/solvocard.png";
20 import bringinLogo from "src/assets/suggested-apps/bringin.png";
21 import wavespaceLogo from "src/assets/suggested-apps/wave-space.png";
22 import AppHeader from "src/components/AppHeader";
23 import { AlbyIcon } from "src/components/icons/Alby";
24 import { AppleIcon } from "src/components/icons/Apple";
25 import { GooglePayIcon } from "src/components/icons/GooglePay";
26 import { VisaLogo } from "src/components/icons/VisaLogo";
27 import { Avatar, AvatarFallback, AvatarImage } from "src/components/ui/avatar";
28 import { Badge } from "src/components/ui/badge";
29 import { Button } from "src/components/ui/button";
30 import {
31 Dialog,
32 DialogContent,
33 DialogDescription,
34 DialogHeader,
35 DialogTitle,
36 } from "src/components/ui/dialog";
37 import { FieldError } from "src/components/ui/field";
38 import { Input } from "src/components/ui/input";
39 import { Label } from "src/components/ui/label";
40 import {
41 Select,
42 SelectContent,
43 SelectItem,
44 SelectTrigger,
45 SelectValue,
46 } from "src/components/ui/select";
47 import {
48 Table,
49 TableBody,
50 TableCell,
51 TableHead,
52 TableHeader,
53 TableRow,
54 } from "src/components/ui/table";
55 import { ToggleGroup, ToggleGroupItem } from "src/components/ui/toggle-group";
56 import {
57 Tooltip,
58 TooltipContent,
59 TooltipTrigger,
60 } from "src/components/ui/tooltip";
61 import { localStorageKeys } from "src/constants";
62 import { useAlbyMe } from "src/hooks/useAlbyMe";
63 import { useInfo } from "src/hooks/useInfo";
64 import { cn } from "src/lib/utils";
65 import { sendEvent } from "src/utils/sendEvent";
66
67 type Region = "Global" | "US" | "EU" | "UK" | "LATAM" | "Africa" | "Asia";
68
69 type FeatureFilter =
70 | "ApplePay"
71 | "GooglePay"
72 | "Self-custody"
73 | "Lightning-native"
74 | "No KYC";
75
76 type Provider = {
77 id: string;
78 name: string;
79 url: string;
80 logo: string;
81 initials: string;
82 networks: ("Visa" | "Mastercard")[];
83 cardType: "Physical" | "Virtual" | "Both";
84 regions: Region[];
85 applePay: boolean;
86 googlePay: boolean;
87 selfCustody: boolean;
88 lightningNative: boolean;
89 kyc: boolean;
90 timeToGet: string;
91 cardCost: string;
92 fees: string;
93 /**
94 * If set, the provider connects via its own NWC flow in the in-hub app store
95 * (the row's action links to /appstore/<id>) rather than through the
96 * stablecoin top-up Connect-card dialog.
97 */
98 appStoreId?: string;
99 };
100
101 const providers: Provider[] = [
102 {
103 id: "redotpay",
104 name: "RedotPay",
105 url: "https://wap.redotpay.com/en/invite/?referralId=qr1a5",
106 logo: redotpayLogo,
107 initials: "RP",
108 networks: ["Visa"],
109 cardType: "Virtual",
110 regions: ["Global"],
111 applePay: true,
112 googlePay: true,
113 selfCustody: false,
114 lightningNative: false,
115 kyc: true,
116 timeToGet: "<10 minutes",
117 cardCost: "$10",
118 fees: "~2.2% + FX",
119 appStoreId: "bitcoin-card-topup",
120 },
121 {
122 id: "2fiat",
123 name: "2fiat",
124 url: "https://2fiat.com/getalby",
125 logo: twoFiatLogo,
126 initials: "2F",
127 networks: ["Mastercard"],
128 cardType: "Virtual",
129 regions: ["Global"],
130 applePay: true,
131 googlePay: true,
132 selfCustody: false,
133 lightningNative: true,
134 kyc: false,
135 timeToGet: "Instant",
136 cardCost: "$50",
137 fees: "~6.8% top-up + $0.50",
138 appStoreId: "2fiat",
139 },
140 {
141 id: "freedomia",
142 name: "Freedomia",
143 url: "https://www.freedomia.io/a/getalby",
144 logo: freedomiaLogo,
145 initials: "FR",
146 networks: ["Visa"],
147 cardType: "Virtual",
148 regions: ["Global"],
149 applePay: false,
150 googlePay: true,
151 selfCustody: false,
152 lightningNative: true,
153 kyc: false,
154 timeToGet: "Instant",
155 cardCost: "$5–30 / mo",
156 fees: "1.3–4.3%",
157 appStoreId: "bitcoin-card-topup",
158 },
159 {
160 id: "solvocard",
161 name: "Solvocard",
162 url: "https://www.solvocard.com/auth/sign-up?ref=77CEM87Q",
163 logo: solvocardLogo,
164 initials: "SC",
165 networks: ["Visa", "Mastercard"],
166 cardType: "Virtual",
167 regions: ["Global"],
168 applePay: true,
169 googlePay: true,
170 selfCustody: false,
171 lightningNative: false,
172 kyc: false,
173 timeToGet: "Instant",
174 cardCost: "$99",
175 fees: "4% deposit + $1 card topup",
176 appStoreId: "bitcoin-card-topup",
177 },
178 {
179 id: "bringin",
180 name: "Bringin",
181 url: "https://bringin.app",
182 logo: bringinLogo,
183 initials: "BR",
184 networks: ["Visa"],
185 cardType: "Both",
186 regions: ["EU"],
187 // Direct Apple Pay isn't live — usable today via Curve only, so don't
188 // claim it. Google/Samsung/Fitbit/Garmin Pay are direct.
189 applePay: false,
190 googlePay: true,
191 selfCustody: false,
192 lightningNative: true,
193 kyc: true,
194 timeToGet: "Minutes",
195 // Their cards page advertises a €3.49/mo subscription that bundles
196 // both cards. Worth re-confirming during checkout — sources disagree.
197 cardCost: "€3.49 / mo",
198 fees: "1% + 0.5%",
199 appStoreId: "bringin",
200 },
201 {
202 id: "wavespace",
203 name: "wavecard by wave.space",
204 url: "https://app.wave.space/?utm_source=albyhub&affiliate=AlbyHub",
205 logo: wavespaceLogo,
206 initials: "WS",
207 networks: ["Visa"],
208 cardType: "Both",
209 // Card is accepted worldwide, but issuance requires EEA residency.
210 regions: ["EU"],
211 applePay: false,
212 googlePay: true,
213 selfCustody: false,
214 lightningNative: true,
215 kyc: true,
216 timeToGet: "Minutes",
217 cardCost: "€2.99 / €29.99",
218 fees: "1% + 0.5%",
219 appStoreId: "wavespace",
220 },
221 ];
222
223 const howItWorksSteps = [
224 {
225 icon: CreditCardIcon,
226 title: "Get a card",
227 description:
228 "Pick a debit card that fits your region. Physical or virtual, Apple Pay or Google Pay ready.",
229 },
230 {
231 icon: LinkIcon,
232 title: "Get a top-up link",
233 description:
234 "Connect your card once and save the top-up link to your phone. Open it anytime to fund your card.",
235 },
236 {
237 icon: ZapIcon,
238 title: "Top up in seconds",
239 description:
240 "Pay from your Lightning balance. Your card is funded in seconds, ready to spend.",
241 },
242 ];
243
244 const regionFilters: { label: string; value: Region | "All" }[] = [
245 { label: "All", value: "All" },
246 { label: "Global", value: "Global" },
247 { label: "US", value: "US" },
248 { label: "EU", value: "EU" },
249 { label: "UK", value: "UK" },
250 { label: "LATAM", value: "LATAM" },
251 { label: "Africa", value: "Africa" },
252 { label: "Asia", value: "Asia" },
253 ];
254
255 export function Cards() {
256 const { data: albyMe } = useAlbyMe();
257 const { data: info } = useInfo();
258 const cardholderName = (
259 albyMe?.name ||
260 info?.nodeAlias ||
261 "Alby Hub User"
262 ).toUpperCase();
263
264 const [region, setRegion] = React.useState<Region | "All">("All");
265 const [features, setFeatures] = React.useState<FeatureFilter[]>([]);
266 const [connectOpen, setConnectOpen] = React.useState(false);
267 const [heroDismissed, setHeroDismissed] = React.useState(
268 () => localStorage.getItem(localStorageKeys.cardsHeroDismissed) === "true"
269 );
270
271 const dismissHero = React.useCallback(() => {
272 setHeroDismissed(true);
273 localStorage.setItem(localStorageKeys.cardsHeroDismissed, "true");
274 }, []);
275
276 const filtered = providers.filter((p) => {
277 if (
278 region !== "All" &&
279 !p.regions.includes(region) &&
280 !(region !== "Global" && p.regions.includes("Global"))
281 ) {
282 return false;
283 }
284 for (const f of features) {
285 if (f === "ApplePay" && !p.applePay) {
286 return false;
287 }
288 if (f === "GooglePay" && !p.googlePay) {
289 return false;
290 }
291 if (f === "Self-custody" && !p.selfCustody) {
292 return false;
293 }
294 if (f === "Lightning-native" && !p.lightningNative) {
295 return false;
296 }
297 if (f === "No KYC" && p.kyc) {
298 return false;
299 }
300 }
301 return true;
302 });
303
304 return (
305 <>
306 <AppHeader
307 title="Cards"
308 pageTitle="Cards"
309 contentRight={
310 <Button size="sm" onClick={() => setConnectOpen(true)}>
311 <PlusIcon className="size-4" />
312 Connect card
313 </Button>
314 }
315 />
316
317 <ConnectCardDialog
318 open={connectOpen}
319 onOpenChange={setConnectOpen}
320 providers={providers}
321 />
322
323 {/* Hero — AI-style with 3-step strip */}
324 {!heroDismissed && (
325 <div className="overflow-hidden">
326 <div className="bg-card text-card-foreground rounded-xl overflow-hidden relative border border-border">
327 <button
328 onClick={dismissHero}
329 className="absolute top-4 right-4 z-10 text-muted-foreground hover:text-foreground transition-colors"
330 aria-label="Dismiss"
331 >
332 <XIcon className="w-5 h-5" />
333 </button>
334 <div className="flex flex-col lg:flex-row min-h-[360px]">
335 {/* Left */}
336 <div className="flex-1 p-8 lg:p-12 flex flex-col justify-center">
337 <p className="text-muted-foreground text-sm font-medium tracking-widest uppercase mb-4">
338 Cards + Bitcoin
339 </p>
340 <h2 className="text-4xl lg:text-5xl font-bold tracking-tight leading-[1.1] mb-4">
341 Got your card ready?
342 <br />
343 <span className="text-primary">Connect it.</span>
344 </h2>
345 <p className="text-muted-foreground text-lg max-w-md">
346 Spend Bitcoin anywhere. Top up your debit card from your
347 Lightning balance in under a minute.
348 </p>
349 </div>
350
351 {/* Right — card visual */}
352 <div className="flex-1 p-6 pt-12 lg:p-8 lg:pt-14 flex items-center justify-center">
353 <div className="relative w-full max-w-sm aspect-[1.6/1]">
354 <div className="absolute inset-0 rounded-2xl bg-primary text-primary-foreground shadow-2xl overflow-hidden">
355 <div className="relative p-6 h-full flex flex-col justify-between">
356 <div className="flex items-start justify-between">
357 <span className="text-xs font-bold tracking-wider">
358 ALBY HUB
359 </span>
360 <AlbyIcon className="size-7" />
361 </div>
362
363 <div className="font-mono text-lg leading-none flex items-baseline gap-3 whitespace-nowrap">
364 <span>••••</span>
365 <span>••••</span>
366 <span>••••</span>
367 <span>4421</span>
368 </div>
369
370 <div className="flex items-end justify-between gap-3">
371 <div className="min-w-0">
372 <p className="text-[9px] tracking-widest uppercase opacity-60 leading-none mb-1">
373 Cardholder
374 </p>
375 <p className="text-xs font-semibold tracking-wider truncate">
376 {cardholderName}
377 </p>
378 </div>
379 <VisaLogo className="h-5 w-auto" />
380 </div>
381 </div>
382 </div>
383 </div>
384 </div>
385 </div>
386
387 {/* Three steps */}
388 <div className="border-t border-border grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-border">
389 {howItWorksSteps.map((item) => {
390 const Icon = item.icon;
391 return (
392 <div key={item.title} className="p-6 lg:p-8">
393 <Icon className="w-5 h-5 text-primary mb-2" />
394 <h3 className="font-semibold text-lg mb-1">{item.title}</h3>
395 <p className="text-muted-foreground text-sm">
396 {item.description}
397 </p>
398 </div>
399 );
400 })}
401 </div>
402 </div>
403 </div>
404 )}
405
406 {/* Filter bar */}
407 <section className="rounded-xl border border-border bg-card overflow-hidden">
408 <div className="px-5 py-4 flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-x-5 gap-y-3">
409 <div className="flex items-center gap-3 shrink-0">
410 <p className="text-xs font-medium text-muted-foreground">Region</p>
411 <Select
412 value={region}
413 onValueChange={(v) => setRegion(v as Region | "All")}
414 >
415 <SelectTrigger className="h-8 w-40">
416 <SelectValue />
417 </SelectTrigger>
418 <SelectContent>
419 {regionFilters.map((r) => (
420 <SelectItem key={r.value} value={r.value}>
421 {r.label}
422 </SelectItem>
423 ))}
424 </SelectContent>
425 </Select>
426 </div>
427
428 <div className="flex items-center gap-3">
429 <p className="text-xs font-medium text-muted-foreground">Filter</p>
430 <ToggleGroup
431 type="multiple"
432 value={features}
433 onValueChange={(v) => setFeatures(v as FeatureFilter[])}
434 variant="outline"
435 size="sm"
436 spacing={1}
437 className="flex-wrap flex-1 min-w-0 *:data-[state=on]:bg-primary *:data-[state=on]:text-primary-foreground *:data-[state=on]:border-primary"
438 >
439 {providers.some((p) => p.applePay) && (
440 <ToggleGroupItem value="ApplePay" aria-label="Apple Pay">
441 <AppleIcon />
442 Apple Pay
443 </ToggleGroupItem>
444 )}
445 {providers.some((p) => p.googlePay) && (
446 <ToggleGroupItem value="GooglePay" aria-label="Google Pay">
447 <GooglePayIcon />
448 Google Pay
449 </ToggleGroupItem>
450 )}
451 {providers.some((p) => p.selfCustody) && (
452 <ToggleGroupItem value="Self-custody" aria-label="Self-custody">
453 <ShieldCheckIcon />
454 Self-custody
455 </ToggleGroupItem>
456 )}
457 {providers.some((p) => p.lightningNative) && (
458 <ToggleGroupItem
459 value="Lightning-native"
460 aria-label="Lightning-native"
461 >
462 <ZapIcon />
463 Lightning
464 </ToggleGroupItem>
465 )}
466 {providers.some((p) => !p.kyc) && (
467 <ToggleGroupItem value="No KYC" aria-label="No KYC">
468 <FingerprintIcon />
469 No KYC
470 </ToggleGroupItem>
471 )}
472 </ToggleGroup>
473 </div>
474 </div>
475
476 {(region !== "All" || features.length > 0) && (
477 <div className="bg-muted/30 px-5 flex items-center justify-between gap-3 min-h-12">
478 <p className="text-xs text-muted-foreground">
479 Showing{" "}
480 <span className="font-semibold text-foreground">
481 {filtered.length}
482 </span>{" "}
483 of {providers.length} providers
484 </p>
485 <Button
486 variant="ghost"
487 size="sm"
488 onClick={() => {
489 setRegion("All");
490 setFeatures([]);
491 }}
492 className="h-7 text-xs"
493 >
494 <XIcon className="size-3" />
495 Clear filters
496 </Button>
497 </div>
498 )}
499 </section>
500
501 {/* Provider table (desktop) / card list (mobile) */}
502 <section>
503 <div className="hidden md:block rounded-xl border border-border bg-card overflow-x-auto">
504 <Table className="min-w-[720px]">
505 <TableHeader>
506 <TableRow className="bg-muted/40 hover:bg-muted/40">
507 <TableHead className="w-[260px]">Provider</TableHead>
508 <TableHead>Type</TableHead>
509 <TableHead>Regions</TableHead>
510 <TableHead className="text-center">Mobile</TableHead>
511 <TableHead>KYC</TableHead>
512 <TableHead>Time to get</TableHead>
513 <TableHead>Card cost</TableHead>
514 <TableHead>Fees</TableHead>
515 </TableRow>
516 </TableHeader>
517 <TableBody>
518 {filtered.length === 0 && (
519 <TableRow>
520 <TableCell
521 colSpan={9}
522 className="text-center text-muted-foreground py-10"
523 >
524 No providers match these filters.
525 </TableCell>
526 </TableRow>
527 )}
528 {filtered.map((p) => (
529 <ProviderRow key={p.id} provider={p} />
530 ))}
531 </TableBody>
532 </Table>
533 </div>
534
535 {/* Mobile: stacked cards instead of a sideways-scrolling table */}
536 <div className="md:hidden flex flex-col gap-3">
537 {filtered.length === 0 && (
538 <div className="rounded-xl border border-border bg-card text-center text-sm text-muted-foreground py-10">
539 No providers match these filters.
540 </div>
541 )}
542 {filtered.map((p) => (
543 <ProviderCard key={p.id} provider={p} />
544 ))}
545 </div>
546
547 <p className="text-xs text-muted-foreground mt-3">
548 Alby Hub does not issue or operate these cards. Availability, fees,
549 and KYC are set by each provider — values here are approximate and may
550 change.
551 </p>
552 </section>
553 </>
554 );
555 }
556
557 function openProvider(provider: Provider) {
558 sendEvent("debit_card_url_clicked", {
559 name: provider.name,
560 url: provider.url,
561 });
562 window.open(provider.url, "_blank", "noopener,noreferrer");
563 }
564
565 function ProviderRow({ provider }: { provider: Provider }) {
566 return (
567 <TableRow
568 className="[&_td]:py-3 cursor-pointer hover:bg-accent/30 transition-colors"
569 onClick={() => openProvider(provider)}
570 >
571 <TableCell>
572 <div className="flex items-center gap-3 group">
573 <Avatar className="size-9 rounded-lg">
574 <AvatarImage
575 src={provider.logo}
576 alt={provider.name}
577 className="rounded-lg object-contain bg-secondary p-1"
578 />
579 <AvatarFallback className="rounded-lg bg-secondary text-secondary-foreground text-xs font-semibold">
580 {provider.initials}
581 </AvatarFallback>
582 </Avatar>
583 <div className="min-w-0">
584 <div className="flex items-center gap-1.5">
585 <span className="font-medium group-hover:underline truncate">
586 {provider.name}
587 </span>
588 {provider.selfCustody && (
589 <Tooltip>
590 <TooltipTrigger asChild>
591 <span>
592 <ShieldCheckIcon className="size-3 text-positive-foreground" />
593 </span>
594 </TooltipTrigger>
595 <TooltipContent>Self-custodial</TooltipContent>
596 </Tooltip>
597 )}
598 {provider.lightningNative && (
599 <Tooltip>
600 <TooltipTrigger asChild>
601 <span>
602 <ZapIcon className="size-3 text-primary" />
603 </span>
604 </TooltipTrigger>
605 <TooltipContent>Lightning-native</TooltipContent>
606 </Tooltip>
607 )}
608 </div>
609 <p className="text-xs text-muted-foreground">
610 {provider.networks.join(" / ")}
611 </p>
612 </div>
613 </div>
614 </TableCell>
615 <TableCell>
616 <span className="flex items-center text-xs text-muted-foreground">
617 {provider.cardType}
618 </span>
619 </TableCell>
620 <TableCell>
621 <div className="flex flex-wrap items-center gap-1">
622 {provider.regions.map((r) => (
623 <Badge
624 key={r}
625 variant="secondary"
626 className="text-[10px] font-medium px-1.5 py-0"
627 >
628 {r}
629 </Badge>
630 ))}
631 </div>
632 </TableCell>
633 <TableCell className="text-center">
634 <div className="flex items-center justify-center gap-2 text-muted-foreground">
635 <Tooltip>
636 <TooltipTrigger asChild>
637 <span className={cn(provider.applePay ? "" : "opacity-20")}>
638 <AppleIcon />
639 </span>
640 </TooltipTrigger>
641 <TooltipContent>
642 {provider.applePay ? "Apple Pay supported" : "No Apple Pay"}
643 </TooltipContent>
644 </Tooltip>
645 <Tooltip>
646 <TooltipTrigger asChild>
647 <span className={cn(provider.googlePay ? "" : "opacity-20")}>
648 <GooglePayIcon />
649 </span>
650 </TooltipTrigger>
651 <TooltipContent>
652 {provider.googlePay ? "Google Pay supported" : "No Google Pay"}
653 </TooltipContent>
654 </Tooltip>
655 </div>
656 </TableCell>
657 <TableCell>
658 <div className="flex items-center">
659 <KycBadge kyc={provider.kyc} />
660 </div>
661 </TableCell>
662 <TableCell>
663 <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
664 <ClockIcon className="size-3" />
665 {provider.timeToGet}
666 </span>
667 </TableCell>
668 <TableCell>
669 <div className="flex items-center text-xs text-muted-foreground">
670 {provider.cardCost}
671 </div>
672 </TableCell>
673 <TableCell>
674 <div className="flex items-center text-xs text-muted-foreground">
675 {provider.fees}
676 </div>
677 </TableCell>
678 </TableRow>
679 );
680 }
681
682 function ProviderCard({ provider }: { provider: Provider }) {
683 return (
684 <div className="rounded-xl border border-border bg-card p-5">
685 <div className="flex items-center gap-3">
686 <Avatar className="size-10 rounded-lg shrink-0">
687 <AvatarImage
688 src={provider.logo}
689 alt={provider.name}
690 className="rounded-lg object-contain bg-secondary p-1"
691 />
692 <AvatarFallback className="rounded-lg bg-secondary text-secondary-foreground text-xs font-semibold">
693 {provider.initials}
694 </AvatarFallback>
695 </Avatar>
696 <div className="min-w-0 flex-1">
697 <div className="flex items-center gap-1.5">
698 <span className="font-medium truncate">{provider.name}</span>
699 {provider.selfCustody && (
700 <ShieldCheckIcon className="size-3 shrink-0 text-positive-foreground" />
701 )}
702 {provider.lightningNative && (
703 <ZapIcon className="size-3 shrink-0 text-primary" />
704 )}
705 </div>
706 <p className="text-xs text-muted-foreground">
707 {provider.networks.join(" / ")} · {provider.cardType}
708 </p>
709 </div>
710 </div>
711
712 <div className="mt-4 flex flex-wrap items-center gap-1.5">
713 {provider.regions.map((r) => (
714 <Badge
715 key={r}
716 variant="secondary"
717 className="text-[10px] font-medium px-1.5 py-0"
718 >
719 {r}
720 </Badge>
721 ))}
722 {(provider.applePay || provider.googlePay) && (
723 <span className="flex items-center gap-1.5 ml-1 text-muted-foreground">
724 {provider.applePay && <AppleIcon />}
725 {provider.googlePay && <GooglePayIcon />}
726 </span>
727 )}
728 </div>
729
730 <div className="mt-5 pt-5 border-t border-border/60 grid grid-cols-2 gap-x-4 gap-y-4">
731 <CardFact label="KYC">
732 <KycBadge kyc={provider.kyc} />
733 </CardFact>
734 <CardFact label="Time to get">
735 <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
736 <ClockIcon className="size-3" />
737 {provider.timeToGet}
738 </span>
739 </CardFact>
740 <CardFact label="Card cost">
741 <span className="text-xs text-muted-foreground">
742 {provider.cardCost}
743 </span>
744 </CardFact>
745 <CardFact label="Fees">
746 <span className="text-xs text-muted-foreground">{provider.fees}</span>
747 </CardFact>
748 </div>
749
750 <Button
751 variant="outline"
752 size="sm"
753 className="mt-6 w-full"
754 onClick={() => openProvider(provider)}
755 >
756 Visit
757 <ArrowUpRightIcon className="size-4" />
758 </Button>
759 </div>
760 );
761 }
762
763 function CardFact({
764 label,
765 children,
766 }: {
767 label: string;
768 children: React.ReactNode;
769 }) {
770 return (
771 <div className="flex flex-col gap-1">
772 <p className="text-[10px] uppercase tracking-wider text-muted-foreground">
773 {label}
774 </p>
775 {children}
776 </div>
777 );
778 }
779
780 function ConnectCardDialog({
781 open,
782 onOpenChange,
783 providers,
784 }: {
785 open: boolean;
786 onOpenChange: (open: boolean) => void;
787 providers: Provider[];
788 }) {
789 const navigate = useNavigate();
790 const [showOtherCardForm, setShowOtherCardForm] = React.useState(false);
791 const [otherCardName, setOtherCardName] = React.useState("");
792 const [otherCardNameError, setOtherCardNameError] = React.useState("");
793
794 // The dialog is controlled and opened programmatically (no DialogTrigger),
795 // so onOpenChange never fires with true — reset the form here instead.
796 React.useEffect(() => {
797 if (open) {
798 setShowOtherCardForm(false);
799 setOtherCardName("");
800 setOtherCardNameError("");
801 }
802 }, [open]);
803
804 const handleOpenChange = (o: boolean) => {
805 if (o) {
806 setShowOtherCardForm(false);
807 setOtherCardName("");
808 setOtherCardNameError("");
809 }
810 onOpenChange(o);
811 };
812
813 const handleOtherCardSubmit = (e: React.FormEvent) => {
814 e.preventDefault();
815 const cardName = otherCardName.trim();
816 if (!cardName) {
817 setOtherCardNameError("Enter a card name");
818 return;
819 }
820 sendEvent("debit_card_connect", { name: cardName });
821 onOpenChange(false);
822 navigate(
823 `/apps/new?app=bitcoin-card-topup&name=${encodeURIComponent(`${cardName} - Bitcoin Card Topup`)}`
824 );
825 };
826
827 if (showOtherCardForm) {
828 return (
829 <Dialog open={open} onOpenChange={handleOpenChange}>
830 <DialogContent className="sm:max-w-md">
831 <DialogHeader>
832 <DialogTitle>Name your card</DialogTitle>
833 <DialogDescription>
834 We'll use it to label your top-up connection.
835 </DialogDescription>
836 </DialogHeader>
837
838 <form
839 onSubmit={handleOtherCardSubmit}
840 className="flex flex-col gap-4"
841 >
842 <div className="grid gap-1.5">
843 <Label htmlFor="other-card-name">Card name</Label>
844 <Input
845 autoFocus
846 type="text"
847 id="other-card-name"
848 value={otherCardName}
849 onChange={(e) => {
850 setOtherCardName(e.target.value);
851 setOtherCardNameError("");
852 }}
853 placeholder="e.g. Moon"
854 required
855 autoComplete="off"
856 aria-invalid={!!otherCardNameError || undefined}
857 aria-describedby={
858 otherCardNameError ? "other-card-name-error" : undefined
859 }
860 />
861 <FieldError id="other-card-name-error">
862 {otherCardNameError}
863 </FieldError>
864 </div>
865 <div className="flex justify-end gap-2">
866 <Button
867 type="button"
868 variant="secondary"
869 onClick={() => setShowOtherCardForm(false)}
870 >
871 Back
872 </Button>
873 <Button type="submit">Continue</Button>
874 </div>
875 </form>
876 </DialogContent>
877 </Dialog>
878 );
879 }
880
881 return (
882 <Dialog open={open} onOpenChange={handleOpenChange}>
883 <DialogContent className="sm:max-w-md">
884 <DialogHeader>
885 <DialogTitle>Pick your card provider</DialogTitle>
886 <DialogDescription>
887 We'll take you to the setup guide for the one you choose.
888 </DialogDescription>
889 </DialogHeader>
890
891 <div className="grid grid-cols-1 gap-2">
892 {providers.map((p) => {
893 if (!p.appStoreId) {
894 return null;
895 }
896 // The generic card topup app pre-configures itself from a `provider`
897 // query param; pass the selected provider so its preset is applied.
898 const to =
899 p.appStoreId === "bitcoin-card-topup"
900 ? `/apps/new?app=${p.appStoreId}&provider=${encodeURIComponent(p.id)}&name=${encodeURIComponent(`${p.name} - Bitcoin Card Topup`)}`
901 : `/apps/new?app=${p.appStoreId}`;
902 return (
903 <Link
904 key={p.id}
905 to={to}
906 onClick={() => {
907 sendEvent("debit_card_connect", { name: p.name });
908 onOpenChange(false);
909 }}
910 className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-accent/40 transition-colors"
911 >
912 <Avatar className="size-10 rounded-lg shrink-0">
913 <AvatarImage
914 src={p.logo}
915 alt={p.name}
916 className="rounded-lg object-contain bg-secondary p-1"
917 />
918 <AvatarFallback className="rounded-lg bg-secondary text-secondary-foreground text-xs font-semibold">
919 {p.initials}
920 </AvatarFallback>
921 </Avatar>
922 <div className="min-w-0 flex-1">
923 <p className="font-medium truncate">{p.name}</p>
924 <p className="text-xs text-muted-foreground">
925 {p.networks.join(" / ")} · {p.cardType}
926 </p>
927 </div>
928 <ArrowUpRightIcon className="size-4 text-muted-foreground" />
929 </Link>
930 );
931 })}
932
933 <Button
934 type="button"
935 variant="ghost"
936 onClick={() => setShowOtherCardForm(true)}
937 className="h-auto w-full justify-start gap-3 whitespace-normal rounded-lg border border-dashed border-border p-3 text-left text-base font-normal hover:bg-accent/40 dark:hover:bg-accent/40"
938 >
939 <div className="flex items-center justify-center size-10 rounded-lg shrink-0 bg-secondary text-secondary-foreground">
940 <CreditCardIcon className="size-5" />
941 </div>
942 <div className="min-w-0 flex-1">
943 <p className="font-medium truncate">Other card</p>
944 <p className="text-xs text-muted-foreground">
945 Any crypto card not listed
946 </p>
947 </div>
948 <ArrowUpRightIcon className="size-4 text-muted-foreground" />
949 </Button>
950 </div>
951 </DialogContent>
952 </Dialog>
953 );
954 }
955
956 function KycBadge({ kyc }: { kyc: Provider["kyc"] }) {
957 if (!kyc) {
958 return (
959 <span className="inline-flex items-center gap-1 text-xs text-positive-foreground">
960 <CheckIcon className="size-3" />
961 No
962 <Tooltip>
963 <TooltipTrigger asChild>
964 <span className="text-muted-foreground hover:text-foreground cursor-help">
965 <InfoIcon className="size-3" />
966 </span>
967 </TooltipTrigger>
968 <TooltipContent>
969 No identity verification required. These cards typically operate via
970 a single merchant-of-record account — privacy-friendly, but
971 operationally fragile, and the program can be paused or shut down
972 without notice.
973 </TooltipContent>
974 </Tooltip>
975 </span>
976 );
977 }
978 return (
979 <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
980 Yes
981 <Tooltip>
982 <TooltipTrigger asChild>
983 <span className="text-muted-foreground hover:text-foreground cursor-help">
984 <InfoIcon className="size-3" />
985 </span>
986 </TooltipTrigger>
987 <TooltipContent>
988 Identity verification required. What you'll need depends on your
989 passport and country — it can be as little as a passport and selfie,
990 or also include a tax number, proof of address, and other details.
991 </TooltipContent>
992 </Tooltip>
993 </span>
994 );
995 }
996