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&currency=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:
René Aaron 2026-05-24 07:22:48 +02:00 committed by GitHub
parent d58b9f3ead
commit 40dcf11a2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1070 additions and 53 deletions

View file

@ -1413,5 +1413,7 @@ func getEventWhitelist() []string {
// client-side events
"payment_failed_details",
"debit_card_url_clicked",
"debit_card_connect",
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View file

@ -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",
},
],

View file

@ -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);
}

View file

@ -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

View file

@ -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",

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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 };

View 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",
},
}
);

View file

@ -5,6 +5,7 @@ export const localStorageKeys = {
authToken: "authToken",
supportAlbySidebarHintHiddenUntil: "supportAlbySidebarHintHiddenUntil",
aiHeroDismissed: "aiHeroDismissed",
cardsHeroDismissed: "cardsHeroDismissed",
};
export const ONCHAIN_DUST_SATS = 1000;

View file

@ -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 /> }],
},
],
},
{

View file

@ -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,

View file

@ -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
}

View 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: "$530 / mo",
fees: "1.34.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>
);
}

View 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);
}
}

View file

@ -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)