feat: add cards directory page with provider listings
Adds a dedicated Cards screen that surfaces crypto debit card providers users can top up from their Alby Hub balance, with region and feature filters and a fees comparison table. Co-Authored-By: Claude <noreply@anthropic.com>
BIN
frontend/src/assets/cards/2fiat.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/src/assets/cards/bitnob.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
frontend/src/assets/cards/bitrefill.png
Normal file
|
After Width: | Height: | Size: 470 B |
BIN
frontend/src/assets/cards/cryptocom.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/src/assets/cards/fold.png
Normal file
|
After Width: | Height: | Size: 755 B |
BIN
frontend/src/assets/cards/freedomia.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/src/assets/cards/gnosispay.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/src/assets/cards/metamask.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/src/assets/cards/moon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/src/assets/cards/redotpay.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/src/assets/cards/wirex.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
|
|
@ -2,6 +2,7 @@ import {
|
|||
BotIcon,
|
||||
BoxIcon,
|
||||
ChevronsUpDownIcon,
|
||||
CreditCardIcon,
|
||||
CircleHelpIcon,
|
||||
HandCoinsIcon,
|
||||
HomeIcon,
|
||||
|
|
@ -105,6 +106,11 @@ export function AppSidebar() {
|
|||
title: "AI & Agents",
|
||||
url: "/ai",
|
||||
icon: BotIcon,
|
||||
},
|
||||
{
|
||||
title: "Cards",
|
||||
url: "/cards",
|
||||
icon: CreditCardIcon,
|
||||
badge: "NEW",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
28
frontend/src/components/icons/GooglePay.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function GooglePayIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.5 8a5.5 5.5 0 1 1-2.4-4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 8h5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/icons/VisaLogo.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function VisaLogo(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 750 471"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
aria-label="Visa"
|
||||
>
|
||||
<path d="M278.197 334.228l32.71-202.319h52.298l-32.74 202.319zM520.084 137.092c-10.37-3.99-26.62-8.27-46.91-8.27-51.71 0-88.13 26.71-88.45 65.01-.29 28.31 26.06 44.1 45.95 53.51 20.41 9.65 27.27 15.81 27.18 24.42-.14 13.18-16.13 19.21-31.06 19.21-20.78 0-31.83-2.97-48.89-10.27l-6.69-3.13-7.29 43.84c12.13 5.46 34.55 10.19 57.83 10.43 54.99 0 90.71-26.4 91.11-67.31.21-22.41-13.71-39.46-43.86-53.51-18.27-9.11-29.45-15.18-29.33-24.41 0-8.19 9.55-16.94 30.18-16.94 17.24-.27 29.72 3.59 39.45 7.58l4.72 2.3 7.13-42.45zM653.929 131.909h-40.42c-12.53 0-21.89 3.5-27.4 16.32l-77.67 186h54.93s8.98-24.34 11.01-29.69c6 0 59.36.08 86.99.08 1.57 6.92 6.41 29.61 6.41 29.61h48.54l-42.39-202.32zm-64.42 130.13c4.33-11.34 20.85-54.31 20.85-54.31-.31.52 4.3-11.4 6.94-18.79l3.54 16.97s10.02 46.18 12.11 55.6h-43.45v0.53zM233.585 131.909l-51.21 137.97-5.46-27.16c-9.53-31.32-39.21-65.25-72.4-82.24l46.83 173.59 55.34-.06 82.37-202.1h-55.47" />
|
||||
<path d="M132.221 131.909H47.851l-.67 4.13c65.66 16.06 109.11 54.93 127.13 101.65l-18.34-89.16c-3.17-12.46-12.35-16.25-23.71-16.62" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/ui/toggle-group.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
import { toggleVariants } from "src/components/ui/toggleVariants";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
24
frontend/src/components/ui/toggle.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { Toggle as TogglePrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
import { toggleVariants } from "src/components/ui/toggleVariants";
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle };
|
||||
23
frontend/src/components/ui/toggleVariants.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { cva } from "class-variance-authority";
|
||||
|
||||
export const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2",
|
||||
sm: "h-8 min-w-8 px-1.5",
|
||||
lg: "h-10 min-w-10 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
@ -5,6 +5,7 @@ export const localStorageKeys = {
|
|||
authToken: "authToken",
|
||||
supportAlbySidebarHintHiddenUntil: "supportAlbySidebarHintHiddenUntil",
|
||||
aiHeroDismissed: "aiHeroDismissed",
|
||||
cardsHeroDismissed: "cardsHeroDismissed",
|
||||
};
|
||||
|
||||
export const ONCHAIN_DUST_SATS = 1000;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import Unlock from "src/screens/Unlock";
|
|||
import { Welcome } from "src/screens/Welcome";
|
||||
import AlbyAuthRedirect from "src/screens/alby/AlbyAuthRedirect";
|
||||
import { AI } from "src/screens/ai/AI";
|
||||
import { Cards } from "src/screens/cards/Cards";
|
||||
import { AlbyEarn } from "src/screens/alby/AlbyEarn";
|
||||
import SupportAlby from "src/screens/alby/SupportAlby";
|
||||
import AppDetails from "src/screens/apps/AppDetails";
|
||||
|
|
@ -474,6 +475,11 @@ const routes: RouteObject[] = [
|
|||
element: <AI />,
|
||||
handle: { crumb: () => "AI & Agents" },
|
||||
},
|
||||
{
|
||||
path: "cards",
|
||||
element: <Cards />,
|
||||
handle: { crumb: () => "Cards" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
686
frontend/src/screens/cards/Cards.tsx
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
import {
|
||||
ArrowUpRightIcon,
|
||||
CheckIcon,
|
||||
FingerprintIcon,
|
||||
GlobeIcon,
|
||||
ShieldCheckIcon,
|
||||
SmartphoneIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import twoFiatLogo from "src/assets/cards/2fiat.png";
|
||||
import bitnobLogo from "src/assets/cards/bitnob.png";
|
||||
import bitrefillLogo from "src/assets/cards/bitrefill.png";
|
||||
import cryptoComLogo from "src/assets/cards/cryptocom.png";
|
||||
import foldLogo from "src/assets/cards/fold.png";
|
||||
import freedomiaLogo from "src/assets/cards/freedomia.png";
|
||||
import gnosisPayLogo from "src/assets/cards/gnosispay.png";
|
||||
import metamaskLogo from "src/assets/cards/metamask.png";
|
||||
import moonLogo from "src/assets/cards/moon.png";
|
||||
import redotpayLogo from "src/assets/cards/redotpay.png";
|
||||
import wirexLogo from "src/assets/cards/wirex.png";
|
||||
import AppHeader from "src/components/AppHeader";
|
||||
import ExternalLink from "src/components/ExternalLink";
|
||||
import { AlbyIcon } from "src/components/icons/Alby";
|
||||
import { AppleIcon } from "src/components/icons/Apple";
|
||||
import { GooglePayIcon } from "src/components/icons/GooglePay";
|
||||
import { VisaLogo } from "src/components/icons/VisaLogo";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "src/components/ui/avatar";
|
||||
import { Badge } from "src/components/ui/badge";
|
||||
import { Button } from "src/components/ui/button";
|
||||
import { localStorageKeys } from "src/constants";
|
||||
import { useAlbyMe } from "src/hooks/useAlbyMe";
|
||||
import { useInfo } from "src/hooks/useInfo";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "src/components/ui/table";
|
||||
import { ToggleGroup, ToggleGroupItem } from "src/components/ui/toggle-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "src/components/ui/tooltip";
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
type Region = "Global" | "US" | "EU" | "UK" | "LATAM" | "Africa" | "Asia";
|
||||
|
||||
type FeatureFilter =
|
||||
| "ApplePay"
|
||||
| "GooglePay"
|
||||
| "Self-custody"
|
||||
| "Lightning-native"
|
||||
| "No KYC"
|
||||
| "Experimental";
|
||||
|
||||
type Provider = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
initials: string;
|
||||
network: "Visa" | "Mastercard";
|
||||
cardType: "Physical" | "Virtual";
|
||||
regions: Region[];
|
||||
applePay: boolean;
|
||||
googlePay: boolean;
|
||||
selfCustody: boolean;
|
||||
lightningNative: boolean;
|
||||
kyc: "Full" | "Light" | "None";
|
||||
topUpVia: string;
|
||||
fees: string;
|
||||
experimental?: boolean;
|
||||
};
|
||||
|
||||
const providers: Provider[] = [
|
||||
{
|
||||
id: "redotpay",
|
||||
name: "RedotPay",
|
||||
url: "https://url.hub.so/redotpay",
|
||||
logo: redotpayLogo,
|
||||
initials: "RP",
|
||||
network: "Visa",
|
||||
cardType: "Physical",
|
||||
regions: ["Global", "EU", "LATAM", "Asia"],
|
||||
applePay: true,
|
||||
googlePay: true,
|
||||
selfCustody: false,
|
||||
lightningNative: false,
|
||||
kyc: "Light",
|
||||
topUpVia: "USDT (TRC20)",
|
||||
fees: "~1.5% + FX",
|
||||
},
|
||||
{
|
||||
id: "metamask",
|
||||
name: "MetaMask Card",
|
||||
url: "https://card.metamask.io",
|
||||
logo: metamaskLogo,
|
||||
initials: "MM",
|
||||
network: "Mastercard",
|
||||
cardType: "Physical",
|
||||
regions: ["Global", "EU", "LATAM"],
|
||||
applePay: true,
|
||||
googlePay: true,
|
||||
selfCustody: true,
|
||||
lightningNative: false,
|
||||
kyc: "Light",
|
||||
topUpVia: "USDC on Linea",
|
||||
fees: "~1% + FX",
|
||||
},
|
||||
{
|
||||
id: "gnosispay",
|
||||
name: "Gnosis Pay",
|
||||
url: "https://gnosispay.com",
|
||||
logo: gnosisPayLogo,
|
||||
initials: "GP",
|
||||
network: "Visa",
|
||||
cardType: "Physical",
|
||||
regions: ["EU", "UK"],
|
||||
applePay: true,
|
||||
googlePay: true,
|
||||
selfCustody: true,
|
||||
lightningNative: false,
|
||||
kyc: "Full",
|
||||
topUpVia: "EURe / GBPe on Gnosis Chain",
|
||||
fees: "0% top-up · FX 2%",
|
||||
},
|
||||
{
|
||||
id: "bitnob",
|
||||
name: "Bitnob",
|
||||
url: "https://bitnob.com",
|
||||
logo: bitnobLogo,
|
||||
initials: "BN",
|
||||
network: "Mastercard",
|
||||
cardType: "Virtual",
|
||||
regions: ["Africa", "LATAM"],
|
||||
applePay: false,
|
||||
googlePay: false,
|
||||
selfCustody: false,
|
||||
lightningNative: true,
|
||||
kyc: "Light",
|
||||
topUpVia: "Direct Lightning",
|
||||
fees: "Free top-up · FX 1%",
|
||||
},
|
||||
{
|
||||
id: "bitrefill",
|
||||
name: "Bitrefill Virtual Visa",
|
||||
url: "https://bitrefill.com",
|
||||
logo: bitrefillLogo,
|
||||
initials: "BR",
|
||||
network: "Visa",
|
||||
cardType: "Virtual",
|
||||
regions: ["Global"],
|
||||
applePay: false,
|
||||
googlePay: false,
|
||||
selfCustody: false,
|
||||
lightningNative: true,
|
||||
kyc: "None",
|
||||
topUpVia: "Direct Lightning",
|
||||
fees: "~3% spread",
|
||||
},
|
||||
{
|
||||
id: "moon",
|
||||
name: "Moon",
|
||||
url: "https://paywithmoon.com",
|
||||
logo: moonLogo,
|
||||
initials: "MN",
|
||||
network: "Visa",
|
||||
cardType: "Virtual",
|
||||
regions: ["Global"],
|
||||
applePay: false,
|
||||
googlePay: false,
|
||||
selfCustody: false,
|
||||
lightningNative: true,
|
||||
kyc: "None",
|
||||
topUpVia: "Direct Lightning",
|
||||
fees: "~3–5% spread",
|
||||
},
|
||||
{
|
||||
id: "cryptocom",
|
||||
name: "Crypto.com Visa",
|
||||
url: "https://crypto.com/cards",
|
||||
logo: cryptoComLogo,
|
||||
initials: "CC",
|
||||
network: "Visa",
|
||||
cardType: "Physical",
|
||||
regions: ["Global", "US", "EU"],
|
||||
applePay: true,
|
||||
googlePay: true,
|
||||
selfCustody: false,
|
||||
lightningNative: false,
|
||||
kyc: "Full",
|
||||
topUpVia: "BTC / USDC deposit",
|
||||
fees: "0% top-up · FX 0.5%",
|
||||
},
|
||||
{
|
||||
id: "fold",
|
||||
name: "Fold Card",
|
||||
url: "https://foldapp.com",
|
||||
logo: foldLogo,
|
||||
initials: "FD",
|
||||
network: "Visa",
|
||||
cardType: "Physical",
|
||||
regions: ["US"],
|
||||
applePay: true,
|
||||
googlePay: true,
|
||||
selfCustody: false,
|
||||
lightningNative: false,
|
||||
kyc: "Full",
|
||||
topUpVia: "On-chain BTC",
|
||||
fees: "Free + swap fee",
|
||||
},
|
||||
{
|
||||
id: "wirex",
|
||||
name: "Wirex",
|
||||
url: "https://wirexapp.com",
|
||||
logo: wirexLogo,
|
||||
initials: "WX",
|
||||
network: "Visa",
|
||||
cardType: "Physical",
|
||||
regions: ["EU", "UK", "Asia"],
|
||||
applePay: true,
|
||||
googlePay: true,
|
||||
selfCustody: false,
|
||||
lightningNative: false,
|
||||
kyc: "Full",
|
||||
topUpVia: "BTC deposit",
|
||||
fees: "~1% + FX",
|
||||
},
|
||||
{
|
||||
id: "2fiat",
|
||||
name: "2fiat",
|
||||
url: "https://2fiat.com",
|
||||
logo: twoFiatLogo,
|
||||
initials: "2F",
|
||||
network: "Visa",
|
||||
cardType: "Virtual",
|
||||
regions: ["Global"],
|
||||
applePay: false,
|
||||
googlePay: false,
|
||||
selfCustody: false,
|
||||
lightningNative: false,
|
||||
kyc: "Light",
|
||||
topUpVia: "Crypto deposit",
|
||||
fees: "~3% top-up",
|
||||
experimental: true,
|
||||
},
|
||||
{
|
||||
id: "freedomia",
|
||||
name: "Freedomia",
|
||||
url: "https://freedomia.io",
|
||||
logo: freedomiaLogo,
|
||||
initials: "FR",
|
||||
network: "Visa",
|
||||
cardType: "Virtual",
|
||||
regions: ["Global"],
|
||||
applePay: false,
|
||||
googlePay: false,
|
||||
selfCustody: false,
|
||||
lightningNative: false,
|
||||
kyc: "None",
|
||||
topUpVia: "Crypto deposit",
|
||||
fees: "~3% top-up",
|
||||
experimental: true,
|
||||
},
|
||||
];
|
||||
|
||||
const regionFilters: { label: string; value: Region | "All" }[] = [
|
||||
{ label: "All", value: "All" },
|
||||
{ label: "Global", value: "Global" },
|
||||
{ label: "US", value: "US" },
|
||||
{ label: "EU", value: "EU" },
|
||||
{ label: "UK", value: "UK" },
|
||||
{ label: "LATAM", value: "LATAM" },
|
||||
{ label: "Africa", value: "Africa" },
|
||||
{ label: "Asia", value: "Asia" },
|
||||
];
|
||||
|
||||
export function Cards() {
|
||||
const { data: albyMe } = useAlbyMe();
|
||||
const { data: info } = useInfo();
|
||||
const cardholderName = (
|
||||
albyMe?.name ||
|
||||
info?.nodeAlias ||
|
||||
"Alby Hub User"
|
||||
).toUpperCase();
|
||||
|
||||
const [region, setRegion] = React.useState<Region | "All">("All");
|
||||
const [features, setFeatures] = React.useState<FeatureFilter[]>([]);
|
||||
const [heroDismissed, setHeroDismissed] = React.useState(
|
||||
() => localStorage.getItem(localStorageKeys.cardsHeroDismissed) === "true"
|
||||
);
|
||||
|
||||
const dismissHero = React.useCallback(() => {
|
||||
setHeroDismissed(true);
|
||||
localStorage.setItem(localStorageKeys.cardsHeroDismissed, "true");
|
||||
}, []);
|
||||
|
||||
const showExperimental = features.includes("Experimental");
|
||||
|
||||
const filtered = providers.filter((p) => {
|
||||
if (p.experimental && !showExperimental) {
|
||||
return false;
|
||||
}
|
||||
if (region !== "All" && !p.regions.includes(region)) {
|
||||
return false;
|
||||
}
|
||||
for (const f of features) {
|
||||
if (f === "ApplePay" && !p.applePay) {
|
||||
return false;
|
||||
}
|
||||
if (f === "GooglePay" && !p.googlePay) {
|
||||
return false;
|
||||
}
|
||||
if (f === "Self-custody" && !p.selfCustody) {
|
||||
return false;
|
||||
}
|
||||
if (f === "Lightning-native" && !p.lightningNative) {
|
||||
return false;
|
||||
}
|
||||
if (f === "No KYC" && p.kyc !== "None") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const experimentalCount = providers.filter((p) => p.experimental).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader title="Cards" pageTitle="Cards" />
|
||||
|
||||
{/* Compact hero */}
|
||||
{!heroDismissed && (
|
||||
<section className="relative overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/15 via-primary/5 to-transparent pointer-events-none" />
|
||||
<button
|
||||
onClick={dismissHero}
|
||||
className="absolute top-4 right-4 z-10 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
<div className="relative grid grid-cols-1 md:grid-cols-[1fr_auto] gap-6 items-center p-6 md:p-8">
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-2xl md:text-3xl font-bold tracking-tight leading-tight">
|
||||
Pay with Bitcoin{" "}
|
||||
<span className="text-primary">at any checkout</span>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm md:text-base">
|
||||
Connect a debit card to your hub once, then top it up from
|
||||
Lightning in one tap. No address copy-paste, no wrong-network
|
||||
mistakes, no surprises at the till.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-4 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<GlobeIcon className="size-3.5 text-primary" />
|
||||
{providers.length} cards worldwide
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<SmartphoneIcon className="size-3.5 text-primary" />
|
||||
Apple Pay & Google Pay
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldCheckIcon className="size-3.5 text-primary" />
|
||||
Self-custody
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block md:pr-2 lg:pr-4">
|
||||
<div className="relative w-72 aspect-[1.6/1]">
|
||||
<div className="absolute inset-0 rounded-2xl bg-primary text-primary-foreground shadow-xl overflow-hidden">
|
||||
{/* subtle highlight */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-transparent to-black/10 pointer-events-none" />
|
||||
<div className="relative p-5 h-full flex flex-col justify-between">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-xs font-bold tracking-wider">
|
||||
ALBY HUB
|
||||
</span>
|
||||
<AlbyIcon className="size-7" />
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-lg leading-none flex items-baseline gap-3 whitespace-nowrap">
|
||||
<span>••••</span>
|
||||
<span>••••</span>
|
||||
<span>••••</span>
|
||||
<span>4421</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[9px] tracking-widest uppercase opacity-60 leading-none mb-1">
|
||||
Cardholder
|
||||
</p>
|
||||
<p className="text-xs font-semibold tracking-wider truncate">
|
||||
{cardholderName}
|
||||
</p>
|
||||
</div>
|
||||
<VisaLogo className="h-5 w-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Filter bar */}
|
||||
<section className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<div className="px-5 py-4 flex flex-wrap items-center gap-x-5 gap-y-3 overflow-x-auto">
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Region</p>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={region}
|
||||
onValueChange={(v) => v && setRegion(v as Region | "All")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="*:data-[state=on]:bg-primary *:data-[state=on]:text-primary-foreground *:data-[state=on]:border-primary"
|
||||
>
|
||||
{regionFilters.map((r) => (
|
||||
<ToggleGroupItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Filter</p>
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
value={features}
|
||||
onValueChange={(v) => setFeatures(v as FeatureFilter[])}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={1}
|
||||
className="*:data-[state=on]:bg-primary *:data-[state=on]:text-primary-foreground *:data-[state=on]:border-primary"
|
||||
>
|
||||
<ToggleGroupItem value="ApplePay" aria-label="Apple Pay">
|
||||
<AppleIcon />
|
||||
Apple Pay
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="GooglePay" aria-label="Google Pay">
|
||||
<GooglePayIcon />
|
||||
Google Pay
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Self-custody" aria-label="Self-custody">
|
||||
<ShieldCheckIcon />
|
||||
Self-custody
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Lightning-native"
|
||||
aria-label="Lightning-native"
|
||||
>
|
||||
<ZapIcon />
|
||||
Lightning
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="No KYC" aria-label="No KYC">
|
||||
<FingerprintIcon />
|
||||
No KYC
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="Experimental" aria-label="Experimental">
|
||||
<SparklesIcon />
|
||||
Experimental ({experimentalCount})
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 px-5 flex items-center justify-between gap-3 min-h-12">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{filtered.length}
|
||||
</span>{" "}
|
||||
of {providers.length} providers
|
||||
</p>
|
||||
{(region !== "All" || features.length > 0) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRegion("All");
|
||||
setFeatures([]);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Provider table */}
|
||||
<section>
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableHead className="w-[260px]">Provider</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Regions</TableHead>
|
||||
<TableHead className="text-center">Mobile pay</TableHead>
|
||||
<TableHead>KYC</TableHead>
|
||||
<TableHead>Top up via</TableHead>
|
||||
<TableHead>Fees</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className="text-center text-muted-foreground py-10"
|
||||
>
|
||||
No providers match these filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filtered.map((p) => (
|
||||
<ProviderRow key={p.id} provider={p} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Alby Hub does not issue or operate these cards. Availability, fees,
|
||||
and KYC are set by each provider — values here are approximate and may
|
||||
change.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({ provider }: { provider: Provider }) {
|
||||
return (
|
||||
<TableRow className="[&_td]:py-3">
|
||||
<TableCell>
|
||||
<ExternalLink
|
||||
to={provider.url}
|
||||
className="flex items-center gap-3 group"
|
||||
>
|
||||
<Avatar className="size-9 rounded-lg">
|
||||
<AvatarImage
|
||||
src={provider.logo}
|
||||
alt={provider.name}
|
||||
className="rounded-lg object-contain bg-secondary p-1"
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg bg-secondary text-secondary-foreground text-xs font-semibold">
|
||||
{provider.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium group-hover:underline truncate">
|
||||
{provider.name}
|
||||
</span>
|
||||
{provider.experimental && (
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0">
|
||||
Experimental
|
||||
</Badge>
|
||||
)}
|
||||
{provider.selfCustody && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<ShieldCheckIcon className="size-3 text-positive-foreground" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Self-custodial</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{provider.lightningNative && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<ZapIcon className="size-3 text-primary" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Lightning-native</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{provider.network}</p>
|
||||
</div>
|
||||
</ExternalLink>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center text-xs text-muted-foreground">
|
||||
{provider.cardType}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{provider.regions.map((r) => (
|
||||
<Badge
|
||||
key={r}
|
||||
variant="secondary"
|
||||
className="text-[10px] font-medium px-1.5 py-0"
|
||||
>
|
||||
{r}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(provider.applePay ? "" : "opacity-20")}>
|
||||
<AppleIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{provider.applePay ? "Apple Pay supported" : "No Apple Pay"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(provider.googlePay ? "" : "opacity-20")}>
|
||||
<GooglePayIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{provider.googlePay ? "Google Pay supported" : "No Google Pay"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<KycBadge kyc={provider.kyc} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
{provider.topUpVia}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
{provider.fees}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<ExternalLink
|
||||
to={provider.url}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex"
|
||||
>
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function KycBadge({ kyc }: { kyc: Provider["kyc"] }) {
|
||||
if (kyc === "None") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-positive-foreground">
|
||||
<CheckIcon className="size-3" />
|
||||
None
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{kyc}
|
||||
</span>
|
||||
);
|
||||
}
|
||||