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