mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
feat(cards): provider directory routed through the standard app-install flow (#2366)
* 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>
* feat(cards): connect-card flow with NWC top-up link
Iterates on the cards directory page (#ae087d0b) to wire up the full
connect-card flow described with the bitcoin-card-topup PWA at
card.albylabs.com.
- AI-style hero with three steps: Get a card → Connect it → 1-click top-ups
- Trimmed provider list to RedotPay / 2fiat / Freedomia (the providers
we've validated end-to-end)
- New "Time to get" column so users see physical vs virtual at a glance
- Renamed "Add card" → "Connect card" everywhere; submit mints a real
NWC connection via createApp (same pattern as the AI page) with
scopes for the topup app (get_info / get_balance / list_transactions /
lookup_invoice / make_invoice / pay_invoice / notifications)
- CardCreatedDialog shown once at creation with QR code + bookmarkable
top-up link of the form
https://card.albylabs.com/#label=...&address=0x...&chainId=42161¤cy=USDC&nwc=<pairing-uri>
Includes prominent "save this link — you won't see it again" warning
(Alert with warning variant) and "scan with your phone's camera app
(this is a URL, not a Lightning invoice)" caption under the QR
- Saved card tiles link to /apps/:appId so users get back to the NWC
connection detail; no separate top-up affordance from the hub
- useUserCards hook persists cards in localStorage with appId pointing
at the NWC connection; ready to migrate to deriving the list from
/api/apps filtered by metadata.app_store_app_id = "bitcoin-card-topup"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cards): derive cards from /api/apps, polish
Replaces the localStorage card store with a derivation from /api/apps
filtered by metadata.app_store_app_id = "bitcoin-card-topup". Card
provider/destination/chain/currency now ride on the NWC app's metadata,
so cards survive across devices, reloads and DB backups, and "forget
card" flows through the existing /apps/:id delete affordance.
- Gate /cards through DefaultRedirect so a locked hub redirects to
/unlock (matches /wallet, /apps, etc.)
- Hub-generated top-up link now uses #?... prefix so bitcoin-connect's
parser branches to the URLSearchParams path (works in all cases vs.
the bare #... which mis-parsed for some users)
- Remove EmptyCards empty state (the Connect card button in the page
header is the sole entry point)
- Drop the "Experimental" filter and badge — too small a catalog for
it to be useful, and the toggle behavior confused users
- Replace "Top up via" column with "Card cost" (more decision-relevant)
- Verified all provider data from each provider's site; updated
- RedotPay: KYC Full (not Light), regions add US/UK, fees ~2.2% + FX
- 2fiat: Mastercard (not Visa), Apple Pay + Google Pay supported, KYC
None (not Light), card cost $50, fees ~6.8%
- Freedomia: Google Pay supported, card cost $5–30/mo subscription,
fees 1.3–4.3%
- Add a hoverable info-icon tooltip on the "None" KYC badge so privacy-
focused users keep the signal they want while cautious users see the
merchant-of-record-fragility caveat
- Field label "Destination address" → "Top-up address" (matches what
the user pastes from their provider)
- Connect dialog copy de-emdashed
- Various smaller copy/layout tweaks
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(cards): tidy provider tiles, filter, and column header
- Switch Freedomia tile bg to bg-orange-500 to match the brand's
orange F logo
- Drop the upscaled watermark logo from card-tile backgrounds for a
cleaner surface
- Hide filter toggles whose criterion no provider satisfies, so the
filter bar only offers actionable options
- Rename "Mobile pay" column header to "Mobile" — the icons already
identify Apple/Google Pay
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(cards): drop unused provider logos
We trimmed the catalog to RedotPay/2fiat/Freedomia in an earlier
commit; the other 8 PNGs were left behind. Remove them to keep the
asset directory tight.
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(cards): downgrade RedotPay KYC from Full to Light
RedotPay only requires ID verification — no proof of address, employer
details, or source-of-funds questions. "Full" overstates it and may
deter users from even trying.
Adds a Light KYC tooltip alongside the existing None one so the
distinction is visible at a glance.
* chore(cards): tighten copy and mobile layout
- CardCreatedDialog: drop the "regular URL, not a Lightning invoice"
caveat and replace with actionable "save it as an app to your
homescreen" guidance.
- Hide the QR + scan instruction on mobile — the user is already on
their phone, so scanning their own screen is nonsense.
- Provider table: switch wrapper from overflow-hidden to overflow-x-auto
and set a 720px min-width so 9 columns scroll horizontally on narrow
viewports instead of cramming/clipping.
* feat(cards): list Bringin and wavecard
Both already ship as suggested apps with their own NWC pairing flows, so
the table row's action arrow links to /appstore/<id> rather than the
stablecoin connect-card dialog. Marked via a new optional appStoreId
flag on Provider; the Connect-card dropdown filters these out so the
top-up form never offers a chain/currency for cards that don't need one.
Reuses the existing suggested-apps logos to avoid duplicating assets.
* chore(cards): retitle 3rd hero step to highlight top-up speed
"Top up in seconds — your card is funded in under a minute, ready to
spend on the go" lands the speed promise harder than "1-click top-ups"
did, and frames the value as on-the-go spending rather than mechanics.
* feat(cards): route app-store cards through the Connect dialog
Bringin and wavecard are now in the provider dropdown. Picking either
hides the address/network/currency fields and replaces the primary
button with an "Open setup guide" link to /appstore/<id>, where their
own NWC pairing flow lives. Keeps a single entry point for "connect a
card" without forcing the stablecoin form onto cards that don't use it.
Also tightens the hero subtitle to lead with the speed promise.
* chore(cards): link app-store cards straight to /apps/new
Skip the app-store detail page — /apps/new?app=<id> drops users into
the connection flow with the right app preselected, which is what they
actually wanted.
* chore(cards): correct Bringin and wavecard table data
Verified against bringin.app/bitcoin-debit-cards and wave.space/card:
- Both offer physical *and* virtual cards (added "Both" to cardType).
- wave.space is EEA-only for issuance (card itself is accepted globally)
— regions changed from Global to EU.
- Direct Apple Pay isn't live for either; both currently work via Curve,
which is too indirect to claim native Apple Pay support.
- Issuance is not free: Bringin charges a €3.49/mo subscription that
bundles both cards; wave.space charges €2.99 virtual / €29.99 physical
one-time.
- Conversion fees are 1% + ~0.5% LP spread for both, not flat ~1%.
- Bringin URL fixed to bringin.app (they migrated from bringin.xyz);
wave.space URL points at the card landing page.
Doesn't change the dialog routing — picking Bringin/wavecard still drops
the user into /apps/new?app=<id> for the NWC pairing.
* chore(cards): tighten created-dialog copy
- Title and subtitle now describe the link instead of warning.
- Alert highlights the device-specific action ("save it on the phone
you'll top up from") and drops the redundant secret-recovery prose.
- QR caption mentions bookmark as an alternative to home-screen install.
* refactor(cards): route every provider through the standard app flow
- Add a bitcoin-card-topup app store entry pointing to card.albylabs.com
with a "visit + add to home screen + enter card details" install guide.
- Wire RedotPay and Freedomia to bitcoin-card-topup, and 2fiat to its
existing app store entry. Bringin and wavespace already had theirs.
- Drop the custom Connect card dialog, address/network/currency form,
and the one-shot CardCreatedDialog with embedded NWC link. Card config
is now collected inside the topup app itself.
- Drop the "Your card connections" section and the useUserCards hook —
connected cards show up in the standard /apps list like any other app.
* feat(cards): add Connect card picker dialog + clickable rows
- Bring back the Connect card button in the header; opens a lightweight
provider-picker dialog where each tile routes straight to
/apps/new?app=<appStoreId>.
- Drop the table's action column entirely; the whole provider row is now
clickable and opens the provider's website in a new tab. This separates
discovery (row click → learn more) from action (Connect card → setup).
* feat(cards): add Other card option + broaden install wording
- Add an "Other card" tile to the Connect card picker (dashed border,
generic credit-card icon). Routes to /apps/new?app=bitcoin-card-topup
so anyone holding a USDC/USDT card not in the listed providers can
still set up the topup flow.
- Reword the install guide from "phone you'll top up from" to "device" —
the topup app works equally well on a tablet or any browser.
* chore(cards): fix bitcoin-card-topup logo + broaden copy to "any crypto"
- Swap the placeholder 2fiat logo for the Alby logo (alby.png).
- Drop USDC/USDT specifics from the app description, extended
description, and the picker dialog's "Other card" tile copy. From the
user's perspective the topup app just takes a crypto card.
* chore(cards): use bitcoin-card-topup's own PWA icon as the app logo
Copy bitcoin-card-topup/public/shortcut-icon.png (the topup PWA's home-
screen icon) into suggested-apps/ and point the bitcoin-card-topup app
store entry at it, replacing the Alby-logo placeholder.
* fix(apps): align Connect-to-app header logo size with the appstore page
NewApp.tsx rendered the app logo at w-12 h-12 (48px) while
AppStoreDetailHeader uses w-14 h-14 (56px) — the logo visually jumps
between /apps/new?app=<id> and /appstore/<id>. Unify on w-14 h-14.
* fix(appstore): use AppHeader's standard icon/description slots
AppStoreDetailHeader was rendering the logo + title + description inside
a custom flex container nested into AppHeader's title prop, which gave
the icon a different vertical alignment than every other AppHeader use.
Pass them via the icon and description props instead so the header
layout stays consistent with /apps/new and the rest of the app.
* chore(cards): use Freedomia affiliate URL
* chore: add affiliate links
* fix: always show global cards
* fix: make 2fiat lightning native
* chore: update card instructions
* chore: optimize bitcoin card topup app image
* chore: improve bitcoin card topup copy
* chore: add card events, fix http service event endpoint url
* chore(cards): drop redundant 'Get a card' section heading
* chore(cards): tighten hero subtext + 3-step copy, hide filter footer when inactive
- Hero subtext drops the "one click" claim that's no longer accurate
post-refactor and the em-dash.
- 3-step descriptions reworded for length and fix "Setup" -> "Set up";
bottom step matches the "in seconds" heading instead of saying
"under a minute".
- "Showing N of M providers" footer is hidden unless a filter is
active — silent when there's nothing to clarify.
* chore(cards): apply Alby button gradient + brand stroke to hero card
Use the same vertical white→cream highlight and 1px #ffdf6f stroke that
alby.css applies to default primary buttons, so the hero card visual
reads as part of the same theme family instead of an isolated panel.
* chore(cards): reframe step 2 around the top-up link + drop hero gradient
- Step 2 title shifts from "Connect it" to "Get a top-up link" so the
deliverable (a saveable link) is what users see, matching what
actually happens at that step post-refactor.
- Drop the white→cream gradient overlay from the hero card; the flat
yellow with the existing shadow is the look we want.
* chore: change wave.space kyc level
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
This commit is contained in:
parent
d58b9f3ead
commit
40dcf11a2b
22 changed files with 1070 additions and 53 deletions
|
|
@ -1413,5 +1413,7 @@ func getEventWhitelist() []string {
|
|||
|
||||
// client-side events
|
||||
"payment_failed_details",
|
||||
"debit_card_url_clicked",
|
||||
"debit_card_connect",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
frontend/src/assets/cards/2fiat.png
Normal file
BIN
frontend/src/assets/cards/2fiat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/src/assets/cards/freedomia.png
Normal file
BIN
frontend/src/assets/cards/freedomia.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/src/assets/cards/redotpay.png
Normal file
BIN
frontend/src/assets/cards/redotpay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/src/assets/suggested-apps/bitcoin-card-topup.png
Normal file
BIN
frontend/src/assets/suggested-apps/bitcoin-card-topup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 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",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
|
|||
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
|
||||
import { LoadingButton } from "src/components/ui/custom/loading-button";
|
||||
import { useChannels } from "src/hooks/useChannels";
|
||||
import { request } from "src/utils/request";
|
||||
import { sendEvent } from "src/utils/sendEvent";
|
||||
|
||||
export function PaymentFailedAlert({
|
||||
invoice,
|
||||
|
|
@ -19,25 +19,12 @@ export function PaymentFailedAlert({
|
|||
|
||||
async function sendDetailsToAlby() {
|
||||
setSendingDetailsToAlby(true);
|
||||
try {
|
||||
await request(`/api/event`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
event: "payment_failed_details",
|
||||
properties: {
|
||||
invoice,
|
||||
errorMessage,
|
||||
channels,
|
||||
},
|
||||
}),
|
||||
});
|
||||
toast("Thanks for improving Alby Hub.");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
await sendEvent("payment_failed_details", {
|
||||
invoice,
|
||||
errorMessage,
|
||||
channels,
|
||||
});
|
||||
toast("Thanks for improving Alby Hub.");
|
||||
setSendingDetailsToAlby(false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,36 +24,27 @@ export function AppStoreDetailHeader({
|
|||
<>
|
||||
<AppHeader
|
||||
pageTitle={appStoreApp.title}
|
||||
title={
|
||||
<>
|
||||
<div className="flex flex-row items-center">
|
||||
<img
|
||||
src={appStoreApp.logo}
|
||||
className="w-14 h-14 rounded-lg mr-4"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{appStoreApp.title}
|
||||
{!!connectedApps.length && (
|
||||
<Badge
|
||||
variant="positive"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<CheckCircleIcon className="w-3 h-3" />{" "}
|
||||
{connectedApps.length > 1
|
||||
? `${connectedApps.length} Connections`
|
||||
: "Connected"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-muted-foreground">
|
||||
{appStoreApp.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
icon={
|
||||
<img
|
||||
src={appStoreApp.logo}
|
||||
alt="logo"
|
||||
className="inline rounded-lg w-14 h-14"
|
||||
/>
|
||||
}
|
||||
description=""
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
{appStoreApp.title}
|
||||
{!!connectedApps.length && (
|
||||
<Badge variant="positive" className="flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-3 h-3" />{" "}
|
||||
{connectedApps.length > 1
|
||||
? `${connectedApps.length} Connections`
|
||||
: "Connected"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={appStoreApp.description}
|
||||
contentRight={
|
||||
contentRight !== undefined ? (
|
||||
contentRight
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import albyGo from "src/assets/suggested-apps/alby-go.png";
|
|||
import albySandbox from "src/assets/suggested-apps/alby-sandbox.png";
|
||||
import albyCli from "src/assets/suggested-apps/alby.png";
|
||||
import amethyst from "src/assets/suggested-apps/amethyst.png";
|
||||
import bitcoinCardTopup from "src/assets/suggested-apps/bitcoin-card-topup.png";
|
||||
import bitrefill from "src/assets/suggested-apps/bitrefill.png";
|
||||
import bitrequest from "src/assets/suggested-apps/bitrequest.png";
|
||||
import bringin from "src/assets/suggested-apps/bringin.png";
|
||||
|
|
@ -34,8 +35,8 @@ import payperq from "src/assets/suggested-apps/payperq.png";
|
|||
import primal from "src/assets/suggested-apps/primal.png";
|
||||
import pullthatupjamie from "src/assets/suggested-apps/pullthatupjamie.png";
|
||||
import runstr from "src/assets/suggested-apps/runstr.png";
|
||||
import sats4ai from "src/assets/suggested-apps/sats4ai.png";
|
||||
import satsorter from "src/assets/suggested-apps/sat-sorter.png";
|
||||
import sats4ai from "src/assets/suggested-apps/sats4ai.png";
|
||||
import simpleboost from "src/assets/suggested-apps/simple-boost.png";
|
||||
import snort from "src/assets/suggested-apps/snort.png";
|
||||
import stackernews from "src/assets/suggested-apps/stacker-news.png";
|
||||
|
|
@ -218,6 +219,60 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
categories: ["audio"],
|
||||
addedDate: "2026-03-12",
|
||||
},
|
||||
{
|
||||
id: "bitcoin-card-topup",
|
||||
title: "Bitcoin Card Topup",
|
||||
description: "Top up any crypto debit card instantly with bitcoin",
|
||||
logo: bitcoinCardTopup,
|
||||
categories: ["payment-tools"],
|
||||
extendedDescription:
|
||||
"A generic top-up app that swaps Lightning sats to a stablecoin and sends them to your card's deposit address. Works with RedotPay, Freedomia, Nexo, Bybit, and any other card that accepts on-chain crypto deposits.",
|
||||
webLink: "https://card.albylabs.com",
|
||||
installGuide: (
|
||||
<>
|
||||
<div>
|
||||
<ul className="list-inside list-decimal text-muted-foreground">
|
||||
<li>
|
||||
Open{" "}
|
||||
<ExternalLink
|
||||
to="https://card.albylabs.com"
|
||||
className="underline"
|
||||
>
|
||||
card.albylabs.com
|
||||
</ExternalLink>{" "}
|
||||
on the device you'll top up from.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">
|
||||
Add it to your home screen
|
||||
</span>{" "}
|
||||
(or bookmark it) so you can reopen it later.
|
||||
</li>
|
||||
<li>
|
||||
Enter your card's deposit address, network, and currency to set
|
||||
it up.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
finalizeGuide: (
|
||||
<>
|
||||
<div>
|
||||
<ul className="list-inside list-decimal text-muted-foreground">
|
||||
<li>Copy the connection secret below.</li>
|
||||
<li>
|
||||
In the topup app, tap{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Connect Wallet
|
||||
</span>{" "}
|
||||
and paste the connection secret.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "2fiat",
|
||||
title: "2fiat Top up",
|
||||
|
|
|
|||
28
frontend/src/components/icons/GooglePay.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/icons/MastercardLogo.tsx
Normal file
19
frontend/src/components/icons/MastercardLogo.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function MastercardLogo(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 152 108"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Mastercard"
|
||||
>
|
||||
<circle cx="60" cy="54" r="36" fill="#EB001B" />
|
||||
<circle cx="92" cy="54" r="36" fill="#F79E1B" />
|
||||
<path
|
||||
fill="#FF5F00"
|
||||
d="M 76 21.75 A 36 36 0 0 1 76 86.25 A 36 36 0 0 1 76 21.75 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/icons/VisaLogo.tsx
Normal file
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
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
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
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,12 @@ const routes: RouteObject[] = [
|
|||
element: <AI />,
|
||||
handle: { crumb: () => "AI & Agents" },
|
||||
},
|
||||
{
|
||||
path: "cards",
|
||||
element: <DefaultRedirect />,
|
||||
handle: { crumb: () => "Cards" },
|
||||
children: [{ index: true, element: <Cards /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -662,7 +662,7 @@ const whyLightningItems = [
|
|||
icon: EyeOffIcon,
|
||||
title: "Private by Default",
|
||||
description:
|
||||
"No credit cards or personal info shared with merchants. Your agent pays over Lightning — fast, direct, and private.",
|
||||
"No credit cards or personal info shared with merchants. Your agent pays over lightning — fast, direct, and private.",
|
||||
},
|
||||
{
|
||||
icon: ZapIcon,
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
|
|||
<img
|
||||
src={appStoreApp.logo}
|
||||
alt="logo"
|
||||
className="inline rounded-lg w-12 h-12"
|
||||
className="inline rounded-lg w-14 h-14"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
|
|
|
|||
759
frontend/src/screens/cards/Cards.tsx
Normal file
759
frontend/src/screens/cards/Cards.tsx
Normal file
|
|
@ -0,0 +1,759 @@
|
|||
import {
|
||||
ArrowUpRightIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
FingerprintIcon,
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import twoFiatLogo from "src/assets/cards/2fiat.png";
|
||||
import freedomiaLogo from "src/assets/cards/freedomia.png";
|
||||
import redotpayLogo from "src/assets/cards/redotpay.png";
|
||||
import bringinLogo from "src/assets/suggested-apps/bringin.png";
|
||||
import wavespaceLogo from "src/assets/suggested-apps/wave-space.png";
|
||||
import AppHeader from "src/components/AppHeader";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "src/components/ui/dialog";
|
||||
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 { localStorageKeys } from "src/constants";
|
||||
import { useAlbyMe } from "src/hooks/useAlbyMe";
|
||||
import { useInfo } from "src/hooks/useInfo";
|
||||
import { cn } from "src/lib/utils";
|
||||
import { sendEvent } from "src/utils/sendEvent";
|
||||
|
||||
type Region = "Global" | "US" | "EU" | "UK" | "LATAM" | "Africa" | "Asia";
|
||||
|
||||
type FeatureFilter =
|
||||
| "ApplePay"
|
||||
| "GooglePay"
|
||||
| "Self-custody"
|
||||
| "Lightning-native"
|
||||
| "No KYC";
|
||||
|
||||
type Provider = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
initials: string;
|
||||
network: "Visa" | "Mastercard";
|
||||
cardType: "Physical" | "Virtual" | "Both";
|
||||
regions: Region[];
|
||||
applePay: boolean;
|
||||
googlePay: boolean;
|
||||
selfCustody: boolean;
|
||||
lightningNative: boolean;
|
||||
kyc: "Full" | "Light" | "None";
|
||||
timeToGet: string;
|
||||
cardCost: string;
|
||||
fees: string;
|
||||
/**
|
||||
* If set, the provider connects via its own NWC flow in the in-hub app store
|
||||
* (the row's action links to /appstore/<id>) rather than through the
|
||||
* stablecoin top-up Connect-card dialog.
|
||||
*/
|
||||
appStoreId?: string;
|
||||
};
|
||||
|
||||
const providers: Provider[] = [
|
||||
{
|
||||
id: "redotpay",
|
||||
name: "RedotPay",
|
||||
url: "https://www.redotpay.com",
|
||||
logo: redotpayLogo,
|
||||
initials: "RP",
|
||||
network: "Visa",
|
||||
cardType: "Virtual",
|
||||
regions: ["Global", "US", "UK", "EU", "LATAM", "Asia"],
|
||||
applePay: true,
|
||||
googlePay: true,
|
||||
selfCustody: false,
|
||||
lightningNative: false,
|
||||
kyc: "Light",
|
||||
timeToGet: "<10 minutes",
|
||||
cardCost: "$10",
|
||||
fees: "~2.2% + FX",
|
||||
appStoreId: "bitcoin-card-topup",
|
||||
},
|
||||
{
|
||||
id: "2fiat",
|
||||
name: "2fiat",
|
||||
url: "https://2fiat.com/getalby",
|
||||
logo: twoFiatLogo,
|
||||
initials: "2F",
|
||||
network: "Mastercard",
|
||||
cardType: "Virtual",
|
||||
regions: ["Global"],
|
||||
applePay: true,
|
||||
googlePay: true,
|
||||
selfCustody: false,
|
||||
lightningNative: true,
|
||||
kyc: "None",
|
||||
timeToGet: "Instant",
|
||||
cardCost: "$50",
|
||||
fees: "~6.8% top-up + $0.50",
|
||||
appStoreId: "2fiat",
|
||||
},
|
||||
{
|
||||
id: "freedomia",
|
||||
name: "Freedomia",
|
||||
url: "https://www.freedomia.io/a/getalby",
|
||||
logo: freedomiaLogo,
|
||||
initials: "FR",
|
||||
network: "Visa",
|
||||
cardType: "Virtual",
|
||||
regions: ["Global"],
|
||||
applePay: false,
|
||||
googlePay: true,
|
||||
selfCustody: false,
|
||||
lightningNative: false,
|
||||
kyc: "None",
|
||||
timeToGet: "Instant",
|
||||
cardCost: "$5–30 / mo",
|
||||
fees: "1.3–4.3%",
|
||||
appStoreId: "bitcoin-card-topup",
|
||||
},
|
||||
{
|
||||
id: "bringin",
|
||||
name: "Bringin",
|
||||
url: "https://bringin.app",
|
||||
logo: bringinLogo,
|
||||
initials: "BR",
|
||||
network: "Visa",
|
||||
cardType: "Both",
|
||||
regions: ["EU"],
|
||||
// Direct Apple Pay isn't live — usable today via Curve only, so don't
|
||||
// claim it. Google/Samsung/Fitbit/Garmin Pay are direct.
|
||||
applePay: false,
|
||||
googlePay: true,
|
||||
selfCustody: false,
|
||||
lightningNative: true,
|
||||
kyc: "Full",
|
||||
timeToGet: "Minutes",
|
||||
// Their cards page advertises a €3.49/mo subscription that bundles
|
||||
// both cards. Worth re-confirming during checkout — sources disagree.
|
||||
cardCost: "€3.49 / mo",
|
||||
fees: "1% + 0.5%",
|
||||
appStoreId: "bringin",
|
||||
},
|
||||
{
|
||||
id: "wavespace",
|
||||
name: "wavecard by wave.space",
|
||||
url: "https://app.wave.space/spend/?utm_source=albyhub&affiliate=AlbyHub",
|
||||
logo: wavespaceLogo,
|
||||
initials: "WS",
|
||||
network: "Visa",
|
||||
cardType: "Both",
|
||||
// Card is accepted worldwide, but issuance requires EEA residency.
|
||||
regions: ["EU"],
|
||||
applePay: false,
|
||||
googlePay: true,
|
||||
selfCustody: false,
|
||||
lightningNative: true,
|
||||
kyc: "Light",
|
||||
timeToGet: "Minutes",
|
||||
cardCost: "€2.99 / €29.99",
|
||||
fees: "1% + 0.5%",
|
||||
appStoreId: "wavespace",
|
||||
},
|
||||
];
|
||||
|
||||
const howItWorksSteps = [
|
||||
{
|
||||
icon: CreditCardIcon,
|
||||
title: "Get a card",
|
||||
description:
|
||||
"Pick a debit card that fits your region. Physical or virtual, Apple Pay or Google Pay ready.",
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
title: "Get a top-up link",
|
||||
description:
|
||||
"Connect your card once and save the top-up link to your phone. Open it anytime to fund your card.",
|
||||
},
|
||||
{
|
||||
icon: ZapIcon,
|
||||
title: "Top up in seconds",
|
||||
description:
|
||||
"Pay from your Lightning balance. Your card is funded in seconds, ready to spend.",
|
||||
},
|
||||
];
|
||||
|
||||
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 [connectOpen, setConnectOpen] = React.useState(false);
|
||||
const [heroDismissed, setHeroDismissed] = React.useState(
|
||||
() => localStorage.getItem(localStorageKeys.cardsHeroDismissed) === "true"
|
||||
);
|
||||
|
||||
const dismissHero = React.useCallback(() => {
|
||||
setHeroDismissed(true);
|
||||
localStorage.setItem(localStorageKeys.cardsHeroDismissed, "true");
|
||||
}, []);
|
||||
|
||||
const filtered = providers.filter((p) => {
|
||||
if (
|
||||
region !== "All" &&
|
||||
!p.regions.includes(region) &&
|
||||
!(region !== "Global" && p.regions.includes("Global"))
|
||||
) {
|
||||
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;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader
|
||||
title="Cards"
|
||||
pageTitle="Cards"
|
||||
contentRight={
|
||||
<Button size="sm" onClick={() => setConnectOpen(true)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Connect card
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ConnectCardDialog
|
||||
open={connectOpen}
|
||||
onOpenChange={setConnectOpen}
|
||||
providers={providers}
|
||||
/>
|
||||
|
||||
{/* Hero — AI-style with 3-step strip */}
|
||||
{!heroDismissed && (
|
||||
<div className="overflow-hidden">
|
||||
<div className="bg-card text-card-foreground rounded-xl overflow-hidden relative border border-border">
|
||||
<button
|
||||
onClick={dismissHero}
|
||||
className="absolute top-4 right-4 z-10 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<XIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex flex-col lg:flex-row min-h-[360px]">
|
||||
{/* Left */}
|
||||
<div className="flex-1 p-8 lg:p-12 flex flex-col justify-center">
|
||||
<p className="text-muted-foreground text-sm font-medium tracking-widest uppercase mb-4">
|
||||
Cards + Bitcoin
|
||||
</p>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold tracking-tight leading-[1.1] mb-4">
|
||||
Got your card ready?
|
||||
<br />
|
||||
<span className="text-primary">Connect it.</span>
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-md">
|
||||
Spend Bitcoin anywhere. Top up your debit card from your
|
||||
Lightning balance in under a minute.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right — card visual */}
|
||||
<div className="flex-1 p-6 pt-12 lg:p-8 lg:pt-14 flex items-center justify-center">
|
||||
<div className="relative w-full max-w-sm aspect-[1.6/1]">
|
||||
<div className="absolute inset-0 rounded-2xl bg-primary text-primary-foreground shadow-2xl overflow-hidden">
|
||||
<div className="relative p-6 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>
|
||||
|
||||
{/* Three steps */}
|
||||
<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">
|
||||
{howItWorksSteps.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.title} className="p-6 lg:p-8">
|
||||
<Icon className="w-5 h-5 text-primary mb-2" />
|
||||
<h3 className="font-semibold text-lg mb-1">{item.title}</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
{providers.some((p) => p.applePay) && (
|
||||
<ToggleGroupItem value="ApplePay" aria-label="Apple Pay">
|
||||
<AppleIcon />
|
||||
Apple Pay
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
{providers.some((p) => p.googlePay) && (
|
||||
<ToggleGroupItem value="GooglePay" aria-label="Google Pay">
|
||||
<GooglePayIcon />
|
||||
Google Pay
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
{providers.some((p) => p.selfCustody) && (
|
||||
<ToggleGroupItem value="Self-custody" aria-label="Self-custody">
|
||||
<ShieldCheckIcon />
|
||||
Self-custody
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
{providers.some((p) => p.lightningNative) && (
|
||||
<ToggleGroupItem
|
||||
value="Lightning-native"
|
||||
aria-label="Lightning-native"
|
||||
>
|
||||
<ZapIcon />
|
||||
Lightning
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
{providers.some((p) => p.kyc === "None") && (
|
||||
<ToggleGroupItem value="No KYC" aria-label="No KYC">
|
||||
<FingerprintIcon />
|
||||
No KYC
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(region !== "All" || features.length > 0) && (
|
||||
<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>
|
||||
<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-x-auto">
|
||||
<Table className="min-w-[720px]">
|
||||
<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</TableHead>
|
||||
<TableHead>KYC</TableHead>
|
||||
<TableHead>Time to get</TableHead>
|
||||
<TableHead>Card cost</TableHead>
|
||||
<TableHead>Fees</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={9}
|
||||
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 cursor-pointer hover:bg-accent/30 transition-colors"
|
||||
onClick={() => {
|
||||
sendEvent("debit_card_url_clicked", {
|
||||
name: provider.name,
|
||||
url: provider.url,
|
||||
});
|
||||
window.open(provider.url, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<div 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.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>
|
||||
</div>
|
||||
</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>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<ClockIcon className="size-3" />
|
||||
{provider.timeToGet}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
{provider.cardCost}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
{provider.fees}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectCardDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
providers,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providers: Provider[];
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pick your card provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
We'll take you to the setup guide for the one you choose.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{providers.map((p) => {
|
||||
if (!p.appStoreId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/apps/new?app=${p.appStoreId}`}
|
||||
onClick={() => {
|
||||
sendEvent("debit_card_connect", { name: p.name });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<Avatar className="size-10 rounded-lg shrink-0">
|
||||
<AvatarImage
|
||||
src={p.logo}
|
||||
alt={p.name}
|
||||
className="rounded-lg object-contain bg-secondary p-1"
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg bg-secondary text-secondary-foreground text-xs font-semibold">
|
||||
{p.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.network} · {p.cardType}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowUpRightIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<Link
|
||||
to="/apps/new?app=bitcoin-card-topup"
|
||||
onClick={() => {
|
||||
sendEvent("debit_card_connect", { name: "Other" });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-lg border border-dashed border-border p-3 hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-center size-10 rounded-lg shrink-0 bg-secondary text-secondary-foreground">
|
||||
<CreditCardIcon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">Other card</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Any crypto card not listed
|
||||
</p>
|
||||
</div>
|
||||
<ArrowUpRightIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground hover:text-foreground cursor-help">
|
||||
<InfoIcon className="size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
No-KYC cards typically operate via a single merchant-of-record
|
||||
account. Privacy-friendly, but operationally fragile — the program
|
||||
can be paused or shut down without notice.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (kyc === "Light") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
Light
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground hover:text-foreground cursor-help">
|
||||
<InfoIcon className="size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
ID verification only — no proof of address, employer details, or
|
||||
source-of-funds questions.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{kyc}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
16
frontend/src/utils/sendEvent.ts
Normal file
16
frontend/src/utils/sendEvent.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { request } from "src/utils/request";
|
||||
|
||||
export async function sendEvent(
|
||||
name: string,
|
||||
properties?: Record<string, unknown>
|
||||
) {
|
||||
try {
|
||||
await request(`/api/event`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: name, properties }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
|
|||
fullAccessApiGroup.Use(echojwt.WithConfig(jwtConfig))
|
||||
fullAccessApiGroup.Use(httpSvc.requireFullAccess)
|
||||
|
||||
fullAccessApiGroup.POST("/api/event", httpSvc.eventHandler)
|
||||
fullAccessApiGroup.POST("/event", httpSvc.eventHandler)
|
||||
fullAccessApiGroup.PATCH("/unlock-password", httpSvc.changeUnlockPasswordHandler)
|
||||
fullAccessApiGroup.PATCH("/auto-unlock", httpSvc.autoUnlockHandler)
|
||||
fullAccessApiGroup.PATCH("/settings", httpSvc.updateSettingsHandler)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue