Compare commits

..

No commits in common. "feat/chatelet-stay-calendar" and "main" have entirely different histories.

154 changed files with 1857 additions and 8990 deletions

1
.gitignore vendored
View file

@ -29,7 +29,6 @@ dist.tar.gz
# auto-generated build file for PWA # auto-generated build file for PWA
dev-dist/sw.js dev-dist/sw.js
public/icons/
aio-shadcn-vite.code-workspace aio-shadcn-vite.code-workspace
dev-dist dev-dist
.specstory/history .specstory/history

View file

@ -712,63 +712,8 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
# Optional: Disable WebSocket if needed # Optional: Disable WebSocket if needed
VITE_WEBSOCKET_ENABLED=true VITE_WEBSOCKET_ENABLED=true
# Brand kit override (defaults to ./branding/default)
BRAND_DIR=branding/cfaun
# Per-standalone brand override (set by build pipeline, not directly)
BRAND_APP=events
``` ```
## Brand kit (white-label PWA branding)
The webapp ships a brand kit architecture so the hub + every standalone
(events, wallet, chat, market, …) can be rebranded per deployment without
forking the codebase. See `branding/README.md` for the deployer contract.
**Single source of truth:** `branding/<dep>/` holds `logo.{svg,png}` +
`brand.json`. `vite-branding.ts` reads brand.json and exposes a `@brand`
import alias. `pwa-assets.config.ts` + `@vite-pwa/assets-generator` derive
the full PWA icon set from the single logo source.
**brand.json schema:** `{ name, shortName?, themeColor?, backgroundColor? }`
— `name` drives the manifest. `themeColor`/`backgroundColor` are optional
chrome overrides; when unset, each standalone's per-app accent applies.
**In-app logo:** components reference `@brand/logo.png`. Active consumers:
`Login.vue`, `LoginDemo.vue`, `AppSidebar.vue`, `MobileDrawer.vue`. The
Vite alias resolves to the active brand dir at build time.
**Generated icons:** `public/icons/` is gitignored. `brandAssetsPlugin()`
(registered first in every `vite.*.config.ts`'s plugins[]) runs the
generator once per build/dev start via `buildStart`. Outputs match the
existing filename convention (`icon-192.png`, `icon-maskable-512.png`,
…) so HTML `<link>` hrefs and VitePWA `manifest.icons` reference
`/icons/<name>` consistently across all 9 configs.
**Per-standalone override:** `branding/<dep>/icons/<app>/logo.{svg,png}`
is checked before the brand's primary logo. The standalone build sets
`BRAND_APP`; deployers just put files in the right place.
**Switching brands:**
```bash
BRAND_DIR=branding/cfaun pnpm build:events
```
**Adding a new in-app logo consumer:** use `<img src="@brand/logo.png">`
instead of `@/assets/logo.png`. The latter still works for non-brand
assets (`@/assets/bitcoin.svg`, etc.) — it's only the logo that moved.
**NixOS deployment:** `flake.nix` exposes
`lib.mkWebapp { pkgs, brandDir ? ./branding/default, app ? "main" }`.
Server-deploy hosts call it from their `services/webapp.nix`:
`inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "events"; }`.
Builder pins `pkgs.pnpm_10` regardless of consumer's nixpkgs (keeps
pnpmDeps hash stable downstream), uses `autoPatchelfHook` to handle
prebuilt sharp binaries, and sets `CI=true` to bypass pnpm's
interactive modules-purge prompt. Per-host migration tracked in
aiolabs/server-deploy#8.
## Payment Rails Pattern ## Payment Rails Pattern
Shared primitives for modules that mix Lightning + fiat (and, future, Shared primitives for modules that mix Lightning + fiat (and, future,

View file

@ -1,172 +0,0 @@
# Brand kit
This directory holds the **white-label brand kit** that drives the PWA's icons, manifest name/colors, and in-app `<img>` logo across the hub and every standalone (events, wallet, chat, market, …).
The committed `default/` is aiolabs's brand and is what unparameterized builds use. Downstream deployers add a sibling directory (e.g. `branding/cfaun/`) and point `BRAND_DIR` at it to ship a fully rebranded build with no fork required.
## Directory layout
```
branding/
README.md
default/ # aiolabs default, committed
logo.svg # preferred source (sharp at every size)
logo.png # fallback source (≥ 1024×1024 if PNG-only)
brand.json # { name, shortName?, themeColor?, backgroundColor? }
icons/ # optional per-standalone overrides
events/logo.svg
wallet/logo.png
cfaun/ # downstream deployer's brand (gitignored or in deploy repo)
logo.svg
brand.json
```
aiolabs's `default/` currently ships PNG-only (1024×1024). Replace with `logo.svg` when a vector source becomes available — produces sharper icons at every size and unlocks `favicon.svg`.
## Source formats
**SVG strongly preferred:**
- Crisp at every output size (192 / 512 maskable / 180 apple / 48 favicon)
- Enables sharp `favicon.svg` for modern browsers
- The in-app `@brand/logo` reference can be tinted via CSS (`currentColor`, filters)
**PNG accepted with constraints:**
- **≥ 1024×1024** — smaller sources produce blurry icons on high-DPI Android install screens
- **Square aspect ratio** — PWA icon canvas is square
- **Transparent background** — the generator applies maskable/apple background colors itself
- PNG-source deployments lose the `favicon.svg` benefit and the recolorable in-app logo
When both `logo.svg` and `logo.png` are present, SVG wins.
## brand.json schema
```jsonc
{
"name": "AIO", // required — drives PWA manifest name
"shortName": "AIO", // optional — PWA home-screen label; defaults to `name`
"themeColor": "#1f2937", // optional — PWA chrome color override (otherwise each standalone keeps its accent)
"backgroundColor": "#fff", // optional — PWA splash background
"theme": "light", // optional — default in-app mode: light | dark | system
"palette": "darkmatter" // optional — default in-app palette (see PALETTES)
}
```
`themeColor` and `backgroundColor` are *overrides*, not defaults. When unset, each standalone's own accent applies (wallet yellow `#eab308`, chat green `#16a34a`, …) — so the default brand kit preserves the per-app visual identity, and a deployer who wants unified chrome adds the override.
`theme` and `palette` set the **in-app** color scheme — distinct from `themeColor` (which is only the PWA chrome / status-bar color). They define the *initial* default a fresh visitor sees; once a user picks a theme in-app it's stored in `localStorage` and always wins. `palette` must be one of the names in `src/components/theme-provider` (`PALETTES`): `catppuccin` (the built-in default), `countrysidecastle`, `darkmatter`, `emeraldforest`, `lightgreen`, `neobrut`, `starrynight`. Each palette has both a light and a dark variant, so e.g. "darkmatter light" is `{ "theme": "light", "palette": "darkmatter" }`. Unset → the app's built-ins (`dark` + `catppuccin`). Applies app-wide (hub + every standalone).
## Per-standalone overrides
Place a logo at `branding/<dep>/icons/<app>/logo.{svg,png}` to override the brand's primary logo for a single standalone build.
Resolution at build time:
1. `branding/<dep>/icons/<app>/logo.svg`
2. `branding/<dep>/icons/<app>/logo.png`
3. `branding/<dep>/logo.svg`
4. `branding/<dep>/logo.png`
5. Build fails with a clear error pointing here.
`<app>` is set via `BRAND_APP` env var (the standalone build script sets this; deployers don't touch it directly).
## Optional banner (logo + wordmark lockup)
A brand may ship a **banner** — a single wide image combining the logo and the wordmark — that replaces the logo + app-name pair in a standalone's header (currently the events page header). Banners are optional: brands without one keep the default logo + name rendering.
```
branding/<dep>/
banner.svg # preferred — crisp at any size, recolorable
banner.png # fallback (wide, transparent background)
icons/
events/banner.svg # optional per-standalone override
```
Resolution mirrors the logo chain (`resolveAppBanner` in `vite-branding.ts`):
1. `branding/<dep>/icons/<app>/banner.svg`
2. `branding/<dep>/icons/<app>/banner.png`
3. `branding/<dep>/banner.svg`
4. `branding/<dep>/banner.png`
5. No banner → header falls back to logo + name (no error).
SVG is strongly preferred — a banner is wide and rasterizes poorly when scaled. Components reference it via the build-time `@brand-app-banner` alias; whether it renders is driven by the `VITE_APP_BANNER` flag, so the component stays brand-agnostic.
> **⚠️ Outline text to paths in any SVG you ship.** The browser only has
> web-safe fonts — if a banner/logo SVG keeps live `<text>` elements that
> reference a designer font (e.g. a decorative display face), the browser
> substitutes a default font and the glyphs render wrong (we hit this with
> a mangled `!` in the "Oyez!" banner). In Inkscape: **Edit → Select All in
> All Layers (Ctrl+Alt+A)** — plain Select All only covers the current
> layer — then **Path → Object to Path (Shift+Ctrl+C)**, and save. Verify
> with `grep -c '<text' banner.svg` → should be `0`.
## How to use
**Building with the default brand:**
```bash
pnpm build # main shell
pnpm build:events # events standalone
# … one per standalone
```
**Building with a deployer's brand:**
```bash
BRAND_DIR=branding/cfaun pnpm build:events
```
`BRAND_DIR` accepts relative paths (resolved from the webapp repo root) or absolute paths (used by the NixOS builder, which mounts the brand directory into the sandbox at a `/nix/store/...-branding` path).
**Regenerating icons explicitly:**
The Vite plugin auto-runs the generator on every build/dev start. To run it standalone:
```bash
pnpm generate-pwa-assets
```
Outputs land in `public/icons/` (gitignored).
## Build pipeline
1. `BRAND_DIR` is resolved (defaults to `./branding/default`).
2. `vite-branding.ts` reads `brand.json` and exposes `@brand/<file>` alias.
3. `brandAssetsPlugin()` (registered in every `vite.*.config.ts`) runs `scripts/generate-pwa-assets.mjs` once per build via `buildStart`.
4. The script stages the source logo into `public/icons/.brand-source.{svg,png}`, runs `pwa-assets-generator`, then deletes the staged source.
5. Vite copies `public/icons/` into `dist/icons/`. Manifest references `icons/<name>.png`. HTML `<link>` tags reference `/icons/<name>.{ico,png}`.
## Integration with NixOS deployment
`flake.nix` exposes `lib.mkWebapp { pkgs, brandDir ? ./branding/default, app ? "main" }` for downstream consumers. Per-host wiring in `deploy/server-deploy/hosts/<host>/services/webapp.nix` looks like:
```nix
{ inputs, pkgs, ... }:
{
services.webapp.apps = {
main = inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; };
events = inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "events"; };
wallet = inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "wallet"; };
};
}
```
`brandDir` is either a path inside this flake (`./branding/<name>`) or an external path (e.g. `./../branding` from server-deploy). Either way Nix copies it into the build sandbox.
Builder details:
- Uses `pkgs.pnpm_10` regardless of consumer's nixpkgs, so the pnpmDeps hash stays stable across downstream nixpkgs versions.
- `pkgs.autoPatchelfHook` + `stdenv.cc.cc.lib` patch the prebuilt `@img/sharp-libvips-linux-*` binaries.
- `CI=true` bypasses pnpm 10's interactive modules-purge prompt in the sandbox.
The architectural payoff: brand and code become independent axes. Logo changes ship via server-deploy commits + redeploys — no webapp release, no `flake.lock` bump.
For local sanity:
```bash
nix build .#main # hub with aiolabs default brand
nix build .#events # events standalone with aiolabs default
# events with a custom brand (the impure way, ad-hoc):
nix build --impure --expr 'let pkgs = import <nixpkgs> {}; flake = builtins.getFlake (toString ./.); in flake.lib.mkWebapp { inherit pkgs; brandDir = /path/to/brand; app = "events"; }'
```

View file

@ -1,4 +0,0 @@
{
"name": "AIO",
"shortName": "AIO"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

View file

@ -6,8 +6,9 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180"> <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Chat — Encrypted</title> <title>Chat — Encrypted</title>
<meta name="apple-mobile-web-app-title" content="Chat"> <meta name="apple-mobile-web-app-title" content="Chat">
<meta name="description" content="End-to-end encrypted Nostr chat"> <meta name="description" content="End-to-end encrypted Nostr chat">

View file

@ -1,19 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
<title>%VITE_APP_NAME%</title>
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
<meta name="description" content="Book a room">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/chatelet-app/main.ts"></script>
</body>
</html>

View file

@ -1,76 +0,0 @@
# Events — guest checkout, fiat return flow and ticket delivery
Buyers can purchase **paid** tickets without an LNbits account. This note is
the wire contract between the webapp and the `aiolabs/events` extension
(≥ v1.6.1-aio.8) for that flow. Free tickets still require a login
(events#29: no anonymous free issuance).
## Identity on `POST /events/api/v1/tickets/{event_id}`
| Buyer | Body fields | Auth header |
|---|---|---|
| Logged in | `user_id` + `email` (delivery address, prefilled from the account, editable) | `Authorization: Bearer <token>` |
| Guest | `name` + `email` | none (the endpoint is anonymous; the instance `X-API-KEY` is always sent) |
Every purchase also sends `frontend_url` — this app's root
(`window.location.origin + import.meta.env.BASE_URL`, no trailing slash;
`src/modules/events/lib/frontendRoot.ts`). The extension only accepts it when
its origin is on the LNbits `LNBITS_CORS_ALLOWED_ORIGINS` allowlist (or the
LNbits base / custom-frontend origin); otherwise the request fails with
`frontend_url origin is not allowed.` and the dialog shows that message.
`payment_method` is `lightning` or `fiat`. The event's enabled rails come from
the NIP-52 tag `tickets_payment_methods` (`"lightning,fiat"`); when the tag is
absent the legacy rule applies (Lightning always, fiat iff
`tickets_allow_fiat`). See `lib/paymentMethods.ts`. A rail that is off is
rejected by the backend with `Payment method not enabled for this event.`
## Return from the fiat provider
The extension composes both Stripe URLs under `frontend_url`:
| Purpose | URL |
|---|---|
| success | `<root>/events/{event_id}?checkout=success&tickets=<id1,id2>` |
| cancel | `<root>/events/{event_id}?checkout=cancelled` |
Before redirecting (same tab, `window.location.assign`), the purchase dialog
writes `localStorage['events:pending-checkout']` =
`{ eventId, paymentHash, ticketIds, email, ts }` (`lib/pendingCheckout.ts`).
`EventDetailPage` strips the query on arrival, then:
- `success` → opens `CheckoutReturnDialog`, which polls the anonymous
`GET /events/api/v1/tickets/{ticket_id}` for every id every 2 s (60 s budget,
retry button) until all are `paid`, then renders one QR per ticket, the
"emailed to" note (email from the localStorage record) and a link to the
ticket page. Logged-in buyers also get `useOwnedTickets().refresh()`.
- `cancelled` → info toast, record cleared. Nothing was charged; the
extension purges unpaid rows after 24 h.
## Public ticket page
`/events/ticket/:ticketId` (`views/TicketPage.vue`, `requiresAuth: false`) is
where the emailed link lands. It reads the `PublicTicket` (no id / email /
user_id in the payload; the id in the URL is the door credential, exactly as
in the LNbits UI), resolves the event name (Nostr store → scoped relay query
→ public REST, which 410s on sold-out events and is treated as non-fatal) and
renders the QR.
## QR rendering
All `ticket://<id>` QRs (purchase success, return dialog, ticket page, My
Tickets) go through `lib/ticketQr.ts`: error-correction level H with the
brand logo (`@brand/logo.png`) centred at ≤ 20 % width on a white pad, so the
in-app image matches the PNG the extension embeds in the ticket email
(`GET /events/api/v1/qr/{ticket_id}`). Canvas failures fall back to the plain
QR.
## Organizer side
`CreateEventDialog` publishes `extra.payment_methods` (Lightning / Card
checkboxes; at least one) and keeps `allow_fiat` in sync for older readers.
Card is disabled with a tooltip when the organizer's LNbits user has no fiat
provider (`useFiatProviders().hasAnyProvider`).
`resendTicketEmail` returns the structured `TicketResendResult`
(`{ ticket, email: {attempted, sent, error}, nostr: {...} }`).

View file

@ -182,13 +182,6 @@ interface ModulePlugin {
**See:** [[market-module/index|📖 Market Module Documentation]] **See:** [[market-module/index|📖 Market Module Documentation]]
### **Events Module** 🎟️
**Purpose:** Ticketed events (Nostr NIP-52 feed + LNbits events extension)
**Location:** `src/modules/events/`
**Dependencies:** `['base']`
**See:** [[events-module/guest-checkout|📖 Guest checkout, fiat return flow and ticket delivery]]
## Module Development ## Module Development
### **Creating a New Module** ### **Creating a New Module**

View file

@ -7,19 +7,15 @@ in this file follows from that single fact.
## Strictly-monotonic `created_at` per coord ## Strictly-monotonic `created_at` per coord
**Canonical helper:** `src/lib/nostr/timestamp.ts` — **Canonical:** `src/modules/events/composables/useRSVP.ts` —
`monotonicCreatedAt(lastCreatedAt, now?)` returns `max(now, last + 1)`. `lastPublishAt` map + the `Math.max(now, previous + 1)` line.
Use it for **every** replaceable-event publish; track the last
`created_at` per coord (a `Map<coord, number>` when one composable
publishes many coords like `useRSVP.ts`, or a single field when there's
one coord per user like `useBookmarks.ts`' kind-10003 list).
```ts ```ts
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
const lastPublishAt = new Map<string, number>() const lastPublishAt = new Map<string, number>()
const createdAt = monotonicCreatedAt(lastPublishAt.get(coord)) const now = Math.floor(Date.now() / 1000)
const previous = lastPublishAt.get(coord) ?? 0
const createdAt = Math.max(now, previous + 1)
… …
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
``` ```

View file

@ -6,8 +6,9 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180"> <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>%VITE_APP_NAME%</title> <title>%VITE_APP_NAME%</title>
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%"> <meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
<meta name="description" content="Discover events near you"> <meta name="description" content="Discover events near you">

61
flake.lock generated
View file

@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1780749050,
"narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

114
flake.nix
View file

@ -1,114 +0,0 @@
{
description = "AIO webapp — modular Vue 3 + Vite shell with Lightning + Nostr standalones";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
let
apps = [ "main" "events" "wallet" "chat" "market" "forum" "tasks" "restaurant" "libra" ];
# Use this flake's pinned nixpkgs for the build, regardless of which
# nixpkgs the consumer's `pkgs` is from. Without this, the pnpmDeps
# hash drifts as soon as a consumer's nixpkgs has a different
# pnpm_10 minor version (snapshots are byte-for-byte different per
# pnpm version). Only `pkgs`'s system attribute is honored.
flakePkgsFor = pkgs: import nixpkgs {
inherit (pkgs.stdenv.hostPlatform) system;
};
mkWebapp = { pkgs, brandDir ? ./branding/default, app ? "main", extraEnv ? {} }:
let
buildScript = if app == "main" then "build" else "build:${app}";
outDir = if app == "main" then "dist" else "dist-${app}";
flakePkgs = flakePkgsFor pkgs;
in
flakePkgs.stdenv.mkDerivation (finalAttrs: {
pname = "aio-webapp-${app}";
version = "0.0.0";
src = ./.;
# pnpm comes from THIS flake's pinned nixpkgs (via flakePkgs),
# never the consumer's, so the pnpmDeps snapshot is stable.
pnpm = flakePkgs.pnpm_10;
pnpmDeps = flakePkgs.fetchPnpmDeps {
inherit (finalAttrs) pname version src;
inherit (finalAttrs) pnpm;
fetcherVersion = 3;
hash = "sha256-2azTpxT+zZqNYNbwC7mj187Tn68p4T0626NotPDGuSU=";
};
nativeBuildInputs = [
flakePkgs.nodejs
finalAttrs.pnpm
flakePkgs.pnpmConfigHook
flakePkgs.autoPatchelfHook
];
# sharp's prebuilt libvips binaries (under @img/sharp-libvips-*)
# are dynamically linked; autoPatchelfHook needs the runtime libs.
buildInputs = [
flakePkgs.stdenv.cc.cc.lib
];
# Brand kit env knobs read by vite-branding.ts and
# pwa-assets.config.ts. brandDir is either ./branding/default
# (a path inside this flake's source) or an external path that
# nix has copied into the build sandbox.
#
# `extraEnv` flows in VITE_* and any other build-time env vars
# the caller wants to bake into the bundle (e.g. webapp-module
# passes VITE_NOSTR_RELAYS / VITE_LNBITS_BASE_URL / …; the
# server-deploy standalones module passes VITE_BASE_PATH +
# VITE_APP_NAME for per-app path mounts).
env = {
BRAND_DIR = "${brandDir}";
BRAND_APP = if app == "main" then "" else app;
# Avoid pnpm 10's interactive modules-purge prompt in the
# sandbox (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY).
CI = "true";
} // extraEnv;
buildPhase = ''
runHook preBuild
pnpm run ${buildScript}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r ${outDir} $out/
runHook postInstall
'';
meta = with flakePkgs.lib; {
description = "AIO webapp${if app == "main" then "" else " (${app} standalone)"}";
homepage = "https://git.atitlan.io/aiolabs/webapp";
license = licenses.mit;
platforms = platforms.linux;
};
});
in
{
# System-agnostic builder. Downstream NixOS hosts call this from
# their services/webapp.nix with their own brandDir.
lib.mkWebapp = mkWebapp;
}
// flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
# One package per standalone, all using the aiolabs default brand.
# `nix build .#<app>` exercises the builder for sanity / CI.
appPackages = pkgs.lib.genAttrs apps (app: mkWebapp { inherit pkgs app; });
in
{
packages = appPackages // {
default = appPackages.main;
};
});
}

View file

@ -6,8 +6,9 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180"> <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Forum — Discussions</title> <title>Forum — Discussions</title>
<meta name="apple-mobile-web-app-title" content="Forum"> <meta name="apple-mobile-web-app-title" content="Forum">
<meta name="description" content="Decentralized link aggregator and discussion forum on Nostr"> <meta name="description" content="Decentralized link aggregator and discussion forum on Nostr">

View file

@ -7,8 +7,9 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- <meta name="theme-color" content="#ffffff"> --> <!-- <meta name="theme-color" content="#ffffff"> -->
<link rel="icon" href="/icons/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180"> <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>%VITE_APP_NAME% Hub</title> <title>%VITE_APP_NAME% Hub</title>
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%"> <meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
</head> </head>

View file

@ -6,8 +6,9 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180"> <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Libra — Accounting</title> <title>Libra — Accounting</title>
<meta name="apple-mobile-web-app-title" content="Libra"> <meta name="apple-mobile-web-app-title" content="Libra">
<meta name="description" content="Team accounting and expense management"> <meta name="description" content="Team accounting and expense management">

View file

@ -6,8 +6,9 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180"> <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Market — Nostr</title> <title>Market — Nostr</title>
<meta name="apple-mobile-web-app-title" content="Market"> <meta name="apple-mobile-web-app-title" content="Market">
<meta name="description" content="Decentralized marketplace on Nostr with Lightning payments"> <meta name="description" content="Decentralized marketplace on Nostr with Lightning payments">

View file

@ -5,13 +5,10 @@
"type": "module", "type": "module",
"main": "electron/main.cjs", "main": "electron/main.cjs",
"scripts": { "scripts": {
"generate-pwa-assets": "node scripts/generate-pwa-assets.mjs",
"dev": "vite --host", "dev": "vite --host",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview --host", "preview": "vite preview --host",
"analyze": "vite build --mode analyze", "analyze": "vite build --mode analyze",
"test": "vitest run",
"test:watch": "vitest",
"dev:events": "vite --host --config vite.events.config.ts", "dev:events": "vite --host --config vite.events.config.ts",
"build:events": "vue-tsc -b && vite build --config vite.events.config.ts", "build:events": "vue-tsc -b && vite build --config vite.events.config.ts",
"preview:events": "vite preview --host --config vite.events.config.ts", "preview:events": "vite preview --host --config vite.events.config.ts",
@ -36,11 +33,8 @@
"dev:restaurant": "vite --host --config vite.restaurant.config.ts", "dev:restaurant": "vite --host --config vite.restaurant.config.ts",
"build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts", "build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts",
"preview:restaurant": "vite preview --host --config vite.restaurant.config.ts", "preview:restaurant": "vite preview --host --config vite.restaurant.config.ts",
"dev:chatelet": "vite --host --config vite.chatelet.config.ts", "dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"",
"build:chatelet": "vue-tsc -b && vite build --config vite.chatelet.config.ts", "build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant",
"preview:chatelet": "vite preview --host --config vite.chatelet.config.ts",
"dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant,chatelet -c blue,magenta,cyan,yellow,green,blue,red,gray,green,cyan \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\" \"npm:dev:chatelet\"",
"build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant && VITE_BASE_PATH=/chatelet/ npm run build:chatelet",
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder", "electron:build": "vue-tsc -b && vite build && electron-builder",
"electron:package": "electron-builder", "electron:package": "electron-builder",
@ -98,7 +92,6 @@
"@types/node": "^22.18.1", "@types/node": "^22.18.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/rollup-plugin-visualizer": "^4.2.3", "@types/rollup-plugin-visualizer": "^4.2.3",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
@ -112,7 +105,6 @@
"vite-plugin-image-optimizer": "^1.1.7", "vite-plugin-image-optimizer": "^1.1.7",
"vite-plugin-inspect": "^0.8.3", "vite-plugin-inspect": "^0.8.3",
"vite-plugin-pwa": "^0.21.1", "vite-plugin-pwa": "^0.21.1",
"vitest": "^4.1.9",
"vue-tsc": "^2.2.0", "vue-tsc": "^2.2.0",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"

346
pnpm-lock.yaml generated
View file

@ -150,9 +150,6 @@ importers:
'@types/rollup-plugin-visualizer': '@types/rollup-plugin-visualizer':
specifier: ^4.2.3 specifier: ^4.2.3
version: 4.2.4 version: 4.2.4
'@vite-pwa/assets-generator':
specifier: ^1.0.2
version: 1.0.2
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3)) version: 5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))
@ -191,10 +188,7 @@ importers:
version: 0.8.9(rollup@4.60.4)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) version: 0.8.9(rollup@4.60.4)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
vite-plugin-pwa: vite-plugin-pwa:
specifier: ^0.21.1 specifier: ^0.21.1
version: 0.21.2(@vite-pwa/assets-generator@1.0.2)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1) version: 0.21.2(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@22.19.19)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
vue-tsc: vue-tsc:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.12(typescript@5.6.3) version: 2.2.12(typescript@5.6.3)
@ -717,9 +711,6 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@canvas/image-data@1.1.0':
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
'@electron-forge/cli@7.11.2': '@electron-forge/cli@7.11.2':
resolution: {integrity: sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg==} resolution: {integrity: sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg==}
engines: {node: '>= 16.4.0'} engines: {node: '>= 16.4.0'}
@ -1292,9 +1283,6 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@quansync/fs@1.0.0':
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
'@rollup/plugin-babel@6.1.0': '@rollup/plugin-babel@6.1.0':
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@ -1498,9 +1486,6 @@ packages:
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.21': '@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
@ -1641,12 +1626,6 @@ packages:
'@types/cacheable-request@6.0.3': '@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -1718,11 +1697,6 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.24.0 zod: ^3.24.0
'@vite-pwa/assets-generator@1.0.2':
resolution: {integrity: sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==}
engines: {node: '>=16.14.0'}
hasBin: true
'@vitejs/plugin-vue@5.2.4': '@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@ -1730,35 +1704,6 @@ packages:
vite: ^5.0.0 || ^6.0.0 vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25 vue: ^3.2.25
'@vitest/expect@4.1.9':
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
'@vitest/mocker@4.1.9':
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.9':
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
'@vitest/runner@4.1.9':
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
'@vitest/snapshot@4.1.9':
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
'@vitest/spy@4.1.9':
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
'@vitest/utils@4.1.9':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
'@volar/language-core@2.4.15': '@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
@ -2070,10 +2015,6 @@ packages:
asn1.js@5.4.1: asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-function@1.0.0: async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2180,10 +2121,6 @@ packages:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
cacache@16.1.3: cacache@16.1.3:
resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -2215,10 +2152,6 @@ packages:
caniuse-lite@1.0.30001793: caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2334,10 +2267,6 @@ packages:
engines: {node: ^14.13.0 || >=16.0.0} engines: {node: ^14.13.0 || >=16.0.0}
hasBin: true hasBin: true
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -2437,14 +2366,6 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
decode-bmp@0.2.1:
resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==}
engines: {node: '>=8.6.0'}
decode-ico@0.4.1:
resolution: {integrity: sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==}
engines: {node: '>=8.6'}
decompress-response@6.0.0: decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2662,9 +2583,6 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3: esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2688,10 +2606,6 @@ packages:
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
engines: {node: '>=6'} engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
exponential-backoff@3.1.3: exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
@ -2976,9 +2890,6 @@ packages:
humanize-ms@1.2.1: humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
ico-endec@0.1.6:
resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==}
iconv-lite@0.4.24: iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3652,10 +3563,6 @@ packages:
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
obug@2.1.3:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
ohash@2.0.11: ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@ -3786,9 +3693,6 @@ packages:
pathe@1.1.2: pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pe-library@1.0.1: pe-library@1.0.1:
resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==} resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==}
engines: {node: '>=14', npm: '>=7'} engines: {node: '>=14', npm: '>=7'}
@ -3896,9 +3800,6 @@ packages:
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
hasBin: true hasBin: true
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -4119,9 +4020,6 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
sharp-ico@0.1.5:
resolution: {integrity: sha512-a3jODQl82NPp1d5OYb0wY+oFaPk7AvyxipIowCHk7pBsZCWgbe0yAkU2OOXdoH0ENyANhyOQbs9xkAiRHcF02Q==}
sharp@0.33.5: sharp@0.33.5:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@ -4162,9 +4060,6 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7: signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@ -4245,12 +4140,6 @@ packages:
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4418,21 +4307,10 @@ packages:
tiny-each-async@2.0.3: tiny-each-async@2.0.3:
resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==} resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
tinyglobby@0.2.16: tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tmp-promise@3.0.3: tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
@ -4444,9 +4322,6 @@ packages:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
engines: {node: '>=14.14'} engines: {node: '>=14.14'}
to-data-view@1.1.0:
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@ -4522,12 +4397,6 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
unconfig-core@7.5.0:
resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
unconfig@7.5.0:
resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -4667,47 +4536,6 @@ packages:
yaml: yaml:
optional: true optional: true
vitest@4.1.9:
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.9
'@vitest/browser-preview': 4.1.9
'@vitest/browser-webdriverio': 4.1.9
'@vitest/coverage-istanbul': 4.1.9
'@vitest/coverage-v8': 4.1.9
'@vitest/ui': 4.1.9
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-uri@3.1.0: vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@ -4828,11 +4656,6 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -5633,8 +5456,6 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@canvas/image-data@1.1.0': {}
'@electron-forge/cli@7.11.2(encoding@0.1.13)(lightningcss@1.32.0)': '@electron-forge/cli@7.11.2(encoding@0.1.13)(lightningcss@1.32.0)':
dependencies: dependencies:
'@electron-forge/core': 7.11.2(encoding@0.1.13)(lightningcss@1.32.0) '@electron-forge/core': 7.11.2(encoding@0.1.13)(lightningcss@1.32.0)
@ -6413,10 +6234,6 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@quansync/fs@1.0.0':
dependencies:
quansync: 1.0.0
'@rollup/plugin-babel@6.1.0(@babel/core@7.29.0)(rollup@4.60.4)': '@rollup/plugin-babel@6.1.0(@babel/core@7.29.0)(rollup@4.60.4)':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
@ -6552,8 +6369,6 @@ snapshots:
'@sindresorhus/is@4.6.0': {} '@sindresorhus/is@4.6.0': {}
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.21': '@swc/helpers@0.5.21':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -6670,13 +6485,6 @@ snapshots:
'@types/node': 22.19.19 '@types/node': 22.19.19
'@types/responselike': 1.0.3 '@types/responselike': 1.0.3
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
@ -6751,61 +6559,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- vue - vue
'@vite-pwa/assets-generator@1.0.2':
dependencies:
cac: 6.7.14
colorette: 2.0.20
consola: 3.4.2
sharp: 0.33.5
sharp-ico: 0.1.5
unconfig: 7.5.0
'@vitejs/plugin-vue@5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))': '@vitejs/plugin-vue@5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))':
dependencies: dependencies:
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
vue: 3.5.34(typescript@5.6.3) vue: 3.5.34(typescript@5.6.3)
'@vitest/expect@4.1.9':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.9(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))':
dependencies:
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
'@vitest/pretty-format@4.1.9':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.9':
dependencies:
'@vitest/utils': 4.1.9
pathe: 2.0.3
'@vitest/snapshot@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
'@vitest/utils': 4.1.9
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.9': {}
'@vitest/utils@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@volar/language-core@2.4.15': '@volar/language-core@2.4.15':
dependencies: dependencies:
'@volar/source-map': 2.4.15 '@volar/source-map': 2.4.15
@ -7167,8 +6925,6 @@ snapshots:
minimalistic-assert: 1.0.1 minimalistic-assert: 1.0.1
safer-buffer: 2.1.2 safer-buffer: 2.1.2
assertion-error@2.0.1: {}
async-function@1.0.0: {} async-function@1.0.0: {}
async@3.2.6: {} async@3.2.6: {}
@ -7274,8 +7030,6 @@ snapshots:
dependencies: dependencies:
run-applescript: 7.1.0 run-applescript: 7.1.0
cac@6.7.14: {}
cacache@16.1.3: cacache@16.1.3:
dependencies: dependencies:
'@npmcli/fs': 2.1.2 '@npmcli/fs': 2.1.2
@ -7332,8 +7086,6 @@ snapshots:
caniuse-lite@1.0.30001793: {} caniuse-lite@1.0.30001793: {}
chai@6.2.2: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@ -7441,8 +7193,6 @@ snapshots:
tree-kill: 1.2.2 tree-kill: 1.2.2
yargs: 17.7.2 yargs: 17.7.2
consola@3.4.2: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
copy-anything@4.0.5: copy-anything@4.0.5:
@ -7537,17 +7287,6 @@ snapshots:
decamelize@1.2.0: {} decamelize@1.2.0: {}
decode-bmp@0.2.1:
dependencies:
'@canvas/image-data': 1.1.0
to-data-view: 1.1.0
decode-ico@0.4.1:
dependencies:
'@canvas/image-data': 1.1.0
decode-bmp: 0.2.1
to-data-view: 1.1.0
decompress-response@6.0.0: decompress-response@6.0.0:
dependencies: dependencies:
mimic-response: 3.1.0 mimic-response: 3.1.0
@ -7870,10 +7609,6 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
esutils@2.0.3: {} esutils@2.0.3: {}
eta@3.5.0: {} eta@3.5.0: {}
@ -7894,8 +7629,6 @@ snapshots:
signal-exit: 3.0.7 signal-exit: 3.0.7
strip-eof: 1.0.0 strip-eof: 1.0.0
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {} exponential-backoff@3.1.3: {}
external-editor@3.1.0: external-editor@3.1.0:
@ -8240,8 +7973,6 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
ico-endec@0.1.6: {}
iconv-lite@0.4.24: iconv-lite@0.4.24:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@ -8837,8 +8568,6 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
object-keys: 1.1.1 object-keys: 1.1.1
obug@2.1.3: {}
ohash@2.0.11: {} ohash@2.0.11: {}
once@1.4.0: once@1.4.0:
@ -8957,8 +8686,6 @@ snapshots:
pathe@1.1.2: {} pathe@1.1.2: {}
pathe@2.0.3: {}
pe-library@1.0.1: {} pe-library@1.0.1: {}
pend@1.2.0: {} pend@1.2.0: {}
@ -9042,8 +8769,6 @@ snapshots:
pngjs: 5.0.0 pngjs: 5.0.0
yargs: 15.4.1 yargs: 15.4.1
quansync@1.0.0: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
quick-lru@5.1.1: {} quick-lru@5.1.1: {}
@ -9333,12 +9058,6 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
sharp-ico@0.1.5:
dependencies:
decode-ico: 0.4.1
ico-endec: 0.1.6
sharp: 0.33.5
sharp@0.33.5: sharp@0.33.5:
dependencies: dependencies:
color: 4.2.3 color: 4.2.3
@ -9407,8 +9126,6 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {} signal-exit@3.0.7: {}
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
@ -9485,10 +9202,6 @@ snapshots:
dependencies: dependencies:
minipass: 3.3.6 minipass: 3.3.6
stackback@0.0.2: {}
std-env@4.1.0: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@ -9657,17 +9370,11 @@ snapshots:
tiny-each-async@2.0.3: tiny-each-async@2.0.3:
optional: true optional: true
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.16: tinyglobby@0.2.16:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tinyrainbow@3.1.0: {}
tmp-promise@3.0.3: tmp-promise@3.0.3:
dependencies: dependencies:
tmp: 0.2.5 tmp: 0.2.5
@ -9680,8 +9387,6 @@ snapshots:
tmp@0.2.5: tmp@0.2.5:
optional: true optional: true
to-data-view@1.1.0: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
@ -9757,19 +9462,6 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
unconfig-core@7.5.0:
dependencies:
'@quansync/fs': 1.0.0
quansync: 1.0.0
unconfig@7.5.0:
dependencies:
'@quansync/fs': 1.0.0
defu: 6.1.7
jiti: 2.7.0
quansync: 1.0.0
unconfig-core: 7.5.0
undici-types@6.21.0: {} undici-types@6.21.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {}
@ -9851,7 +9543,7 @@ snapshots:
- rollup - rollup
- supports-color - supports-color
vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@1.0.2)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1): vite-plugin-pwa@0.21.2(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
pretty-bytes: 6.1.1 pretty-bytes: 6.1.1
@ -9859,8 +9551,6 @@ snapshots:
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
workbox-build: 7.4.1 workbox-build: 7.4.1
workbox-window: 7.4.1 workbox-window: 7.4.1
optionalDependencies:
'@vite-pwa/assets-generator': 1.0.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -9879,33 +9569,6 @@ snapshots:
lightningcss: 1.32.0 lightningcss: 1.32.0
terser: 5.48.0 terser: 5.48.0
vitest@4.1.9(@types/node@22.19.19)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
'@vitest/pretty-format': 4.1.9
'@vitest/runner': 4.1.9
'@vitest/snapshot': 4.1.9
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.3
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.19
transitivePeerDependencies:
- msw
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.6.3)): vue-demi@0.14.10(vue@3.5.34(typescript@5.6.3)):
@ -10068,11 +9731,6 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: word-wrap@1.2.5:
optional: true optional: true

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

3
public/mask-icon.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 3L4 14h7l-2 7 9-11h-7l2-7z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

View file

@ -1,54 +0,0 @@
import { defineConfig } from '@vite-pwa/assets-generator/config'
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
const BRAND_DIR = process.env.BRAND_DIR ?? './branding/default'
const BRAND_APP = process.env.BRAND_APP ?? ''
const candidates: string[] = []
if (BRAND_APP) {
candidates.push(
join(BRAND_DIR, 'icons', BRAND_APP, 'logo.svg'),
join(BRAND_DIR, 'icons', BRAND_APP, 'logo.png'),
)
}
candidates.push(
join(BRAND_DIR, 'logo.svg'),
join(BRAND_DIR, 'logo.png'),
)
const source = candidates.find((p) => existsSync(p))
if (!source) {
throw new Error(
`No brand logo found. Tried:\n ${candidates.join('\n ')}\n` +
`See branding/README.md for the brand kit contract.`,
)
}
// The CLI emits next to the source. Stage into public/icons/ so generated
// PNGs are served at /icons/<name>.png and a single .gitignore line covers
// the whole tree.
const stagingDir = resolve('public/icons')
mkdirSync(stagingDir, { recursive: true })
const sourceExt = source.toLowerCase().endsWith('.svg') ? '.svg' : '.png'
const stagedSource = join(stagingDir, `.brand-source${sourceExt}`)
copyFileSync(source, stagedSource)
export default defineConfig({
headLinkOptions: { preset: '2023' },
preset: {
transparent: {
sizes: [192, 512],
favicons: [[48, 'favicon.ico']],
},
maskable: { sizes: [192, 512] },
apple: { sizes: [180] },
assetName: (type, size) => {
if (type === 'transparent') return `icon-${size.width}.png`
if (type === 'maskable') return `icon-maskable-${size.width}.png`
if (type === 'apple') return 'apple-touch-icon.png'
throw new Error(`Unknown asset type: ${type}`)
},
},
images: [stagedSource],
})

View file

@ -6,8 +6,9 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180"> <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Restaurant — Order</title> <title>Restaurant — Order</title>
<meta name="apple-mobile-web-app-title" content="Restaurant"> <meta name="apple-mobile-web-app-title" content="Restaurant">
<meta name="description" content="Order from your local Nostr-native restaurant with Lightning payments"> <meta name="description" content="Order from your local Nostr-native restaurant with Lightning payments">

View file

@ -1,19 +0,0 @@
#!/usr/bin/env node
// Wraps pwa-assets-generator and removes the staged brand source after
// generation. pwa-assets.config.ts copies $BRAND_DIR/logo.{svg,png}
// into public/icons/.brand-source.* because the CLI emits next to the
// source. Without this cleanup the full-resolution source ships in
// dist/icons/ and is publicly served.
import { spawnSync } from 'node:child_process'
import { existsSync, rmSync } from 'node:fs'
import { resolve } from 'node:path'
const cli = resolve('node_modules/.bin/pwa-assets-generator')
const { status } = spawnSync(cli, process.argv.slice(2), { stdio: 'inherit' })
if (status !== 0) process.exit(status ?? 1)
const stagingDir = resolve('public/icons')
for (const ext of ['svg', 'png']) {
const staged = resolve(stagingDir, `.brand-source.${ext}`)
if (existsSync(staged)) rmSync(staged)
}

View file

@ -78,12 +78,8 @@
<!-- Step 1: Revenue Account Selection --> <!-- Step 1: Revenue Account Selection -->
<div v-if="currentStep === 1"> <div v-if="currentStep === 1">
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 mb-4 text-sm"> <p class="text-sm text-muted-foreground mb-4">
<span class="text-muted-foreground">{{ t('libra.income.selectAccount') }}</span> {{ t('libra.income.selectAccount') }}
<span class="inline-flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-4 w-4 shrink-0" />
{{ t('libra.income.otherAccountHint') }}
</span>
</p> </p>
<AccountSelector <AccountSelector
v-model="selectedRevenueAccount" v-model="selectedRevenueAccount"
@ -100,58 +96,60 @@
<FormLabel>Description *</FormLabel> <FormLabel>Description *</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="A detailed description of the income source, and what it was for (event/project/etc)..." placeholder="e.g., Workshop fee, Donation, Service revenue"
v-bind="componentField" v-bind="componentField"
rows="3" rows="3"
/> />
</FormControl> </FormControl>
<FormDescription>Describe the source of this income</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<div class="grid grid-cols-2 gap-4"> <FormField v-slot="{ componentField }" name="amount">
<FormField v-slot="{ componentField }" name="amount"> <FormItem>
<FormItem> <FormLabel>Amount *</FormLabel>
<FormLabel>Amount *</FormLabel> <FormControl>
<FormControl> <Input
<Input type="number"
type="number" placeholder="0.00"
placeholder="0.00" v-bind="componentField"
v-bind="componentField" min="0.01"
min="0.01" step="0.01"
step="0.01" />
/> </FormControl>
</FormControl> <FormDescription>Amount in selected currency</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="currency"> <FormField v-slot="{ componentField }" name="currency">
<FormItem> <FormItem>
<FormLabel>Currency *</FormLabel> <FormLabel>Currency *</FormLabel>
<Select v-bind="componentField"> <Select v-bind="componentField">
<FormControl> <FormControl>
<SelectTrigger class="w-full"> <SelectTrigger>
<SelectValue placeholder="Select currency" /> <SelectValue placeholder="Select currency" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency"> <SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
{{ currency }} {{ currency }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormDescription>Currency for this income</FormDescription>
</FormItem> <FormMessage />
</FormField> </FormItem>
</div> </FormField>
<FormField v-slot="{ componentField }" name="reference"> <FormField v-slot="{ componentField }" name="reference">
<FormItem> <FormItem>
<FormLabel>Reference (optional)</FormLabel> <FormLabel>Reference</FormLabel>
<FormControl> <FormControl>
<Input placeholder="e.g., Invoice #123, Receipt #456" v-bind="componentField" /> <Input placeholder="e.g., Invoice #123, Receipt #456" v-bind="componentField" />
</FormControl> </FormControl>
<FormDescription>Optional reference number or note</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@ -203,6 +201,7 @@ import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@ -222,7 +221,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { TrendingUp, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock, AlertTriangle } from 'lucide-vue-next' import { TrendingUp, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'

View file

@ -96,10 +96,6 @@
/* Default palette: Catppuccin (Latte for light, Mocha for dark). /* Default palette: Catppuccin (Latte for light, Mocha for dark).
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */ Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
:root { :root {
/* Rendered height of the fixed BottomNav (h-14 + safe-area + border), kept
current by BottomNav.vue. Pin anything that must sit flush above the nav
with `bottom: var(--bottom-nav-height)`; 0 when no nav is mounted. */
--bottom-nav-height: 0px;
--background: oklch(0.9578 0.0058 264.5321); --background: oklch(0.9578 0.0058 264.5321);
--foreground: oklch(0.4355 0.0430 279.3250); --foreground: oklch(0.4355 0.0430 279.3250);
--card: oklch(1.0000 0 0); --card: oklch(1.0000 0 0);
@ -225,16 +221,3 @@
background: transparent; background: transparent;
} }
} }
/*
* Disable enter/exit animations on reka-ui overlays (dialog, sheet,
* popover, dropdown, tooltip, …) app-wide. They animate via the
* data-state open/closed attribute; zeroing the duration keeps the final
* state but removes the motion (overlays appear/disappear instantly).
* Pulse/spin loaders and CSS transitions (e.g. hovers, the favourite
* heart pop) are unaffected.
*/
[data-state='open'],
[data-state='closed'] {
animation-duration: 0s !important;
}

View file

@ -1,16 +0,0 @@
<script setup lang="ts">
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
// Chatelet's in-page navigation is route-driven (browse → detail); the shell
// only contributes the always-on Profile entry on the bottom row.
const tabs: BottomTab[] = []
function isActive(_path: string): boolean {
return false
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" />
</template>

View file

@ -1,54 +0,0 @@
import type { AppConfig } from '@/core/types'
/**
* Standalone chatelet app configuration.
* Enables base + chatelet modules only.
*/
export const appConfig: AppConfig = {
modules: {
base: {
name: 'base',
enabled: true,
lazy: false,
config: {
nostr: {
relays: JSON.parse(
import.meta.env.VITE_NOSTR_RELAYS ||
'["wss://relay.damus.io", "wss://nos.lol"]',
),
},
auth: {
sessionTimeout: 24 * 60 * 60 * 1000,
},
pwa: {
autoPrompt: true,
},
imageUpload: {
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
maxSizeMB: 10,
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'],
},
},
},
chatelet: {
name: 'chatelet',
enabled: true,
lazy: false,
config: {
apiConfig: {
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
apiKey: import.meta.env.VITE_API_KEY || '',
},
},
},
},
features: {
pwa: true,
pushNotifications: true,
electronApp: false,
developmentMode: import.meta.env.DEV,
},
}
export default appConfig

View file

@ -1,128 +0,0 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'
import { pluginManager } from '@/core/plugin-manager'
import { eventBus } from '@/core/event-bus'
import { container } from '@/core/di-container'
import appConfig from './app.config'
import baseModule from '@/modules/base'
import chateletModule from '@/modules/chatelet'
import App from './App.vue'
import '@/assets/index.css'
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
import { acceptTokenFromUrl } from '@/lib/url-token'
const APP_NAME = (import.meta.env.VITE_APP_NAME as string) || 'Chatelet'
const APP_LABEL =
APP_NAME.toLowerCase() === 'chatelet' ? 'Chatelet' : `Chatelet (${APP_NAME})`
/**
* Initialize the standalone chatelet app (guest room booking).
*/
export async function createAppInstance() {
console.log(`🚀 Starting ${APP_LABEL}...`)
// Accept token from URL before anything else (cross-subdomain auth relay)
acceptTokenFromUrl(APP_NAME)
const app = createApp(App)
const moduleRoutes = [
...(baseModule.routes || []),
...(chateletModule.routes || []),
].filter(Boolean)
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', redirect: '/chatelet' },
{
path: '/login',
name: 'login',
component:
import.meta.env.VITE_DEMO_MODE === 'true'
? () => import('@/pages/LoginDemo.vue')
: () => import('@/pages/Login.vue'),
meta: { requiresAuth: false },
},
...moduleRoutes,
{
path: '/settings',
name: 'settings',
component: () => import('./views/SettingsPage.vue'),
meta: { requiresAuth: false },
},
catchAllRoute,
],
})
installLenientAuthGuard(router)
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.use(i18n)
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
if (defaultLocale && !localStorage.getItem('user-locale')) {
await changeLocale(defaultLocale)
}
pluginManager.init(app, router)
const moduleRegistrations = []
if (appConfig.modules.base.enabled) {
moduleRegistrations.push(
pluginManager.register(baseModule, appConfig.modules.base),
)
}
if (appConfig.modules.chatelet?.enabled) {
moduleRegistrations.push(
pluginManager.register(chateletModule, appConfig.modules.chatelet),
)
}
await Promise.all(moduleRegistrations)
await pluginManager.installAll()
const { auth } = await import('@/composables/useAuthService')
await auth.initialize()
markAuthReady(auth)
app.config.errorHandler = (err, _vm, info) => {
console.error('Global error:', err, info)
eventBus.emit('app:error', { error: err, info }, 'app')
}
if (appConfig.features.developmentMode) {
;(window as any).__pluginManager = pluginManager
;(window as any).__eventBus = eventBus
;(window as any).__container = container
}
console.log(`✅ ${APP_LABEL} initialized`)
return { app, router }
}
export async function startApp() {
try {
const { app } = await createAppInstance()
app.mount('#app')
console.log(`🎉 ${APP_LABEL} started!`)
eventBus.emit('app:started', {}, 'app')
} catch (error) {
console.error(`💥 Failed to start ${APP_LABEL}:`, error)
document.getElementById('app')!.innerHTML = `
<div style="padding: 20px; text-align: center; color: red;">
<h1>Failed to Start</h1>
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
<p>Please refresh the page.</p>
</div>
`
}
}

View file

@ -1,24 +0,0 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
cleanupStaleDevServiceWorkers()
// PWA service worker with periodic updates
const intervalMS = 60 * 60 * 1000 // 1 hour
registerSW({
onRegistered(r) {
r &&
setInterval(() => {
r.update()
}, intervalMS)
},
onOfflineReady() {
console.log(
`${(import.meta.env.VITE_APP_NAME as string) || 'Chatelet'} ready to work offline`,
)
},
})
startApp()

View file

@ -1,55 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Sun, Moon, LogIn, LogOut } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { useTheme } from '@/components/theme-provider'
import { auth } from '@/composables/useAuthService'
const { theme, setTheme } = useTheme()
const router = useRouter()
const isAuthenticated = computed(() => auth.isAuthenticated.value)
const userPubkey = computed(() => auth.currentUser.value?.pubkey)
function toggleTheme() {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
async function handleLogout() {
await auth.logout()
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<h1 class="text-2xl font-bold text-foreground mb-6">Settings</h1>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-foreground">Theme</span>
<Button variant="outline" size="sm" @click="toggleTheme">
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
<span class="ml-2">{{ theme === 'dark' ? 'Light' : 'Dark' }}</span>
</Button>
</div>
<Separator />
<div v-if="isAuthenticated" class="space-y-2">
<p class="text-sm text-muted-foreground">Signed in as</p>
<p class="text-sm text-foreground break-all">{{ userPubkey }}</p>
<Button variant="outline" size="sm" @click="handleLogout">
<LogOut class="h-4 w-4 mr-2" /> Log out
</Button>
</div>
<div v-else>
<Button variant="outline" size="sm" @click="router.push('/login')">
<LogIn class="h-4 w-4 mr-2" /> Log in
</Button>
</div>
</div>
</div>
</template>

View file

@ -4,23 +4,24 @@ import { useRoute } from 'vue-router'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { useTheme } from '@/components/theme-provider' import { useTheme } from '@/components/theme-provider'
import BottomNav, { type BottomTab } from './BottomNav.vue' import BottomNav, { type BottomTab } from './BottomNav.vue'
import StandaloneMenu, { type SidebarNavItem } from './StandaloneMenu.vue' import HubPill from './HubPill.vue'
interface Props { interface Props {
/** App-specific tabs displayed before the constant Profile entry. */ /** App-specific tabs displayed before the constant Profile entry. */
tabs: BottomTab[] tabs: BottomTab[]
/** Active-tab matcher. Forwarded to BottomNav. */ /** Active-tab matcher. Forwarded to BottomNav. */
isActive: (path: string) => boolean isActive: (path: string) => boolean
/** Hide the top-right standalone menu — only true when this shell is /** Hide the top-right HubPill — only true when this shell is rendering
* rendering the hub itself. Standalones leave this false (default). */ * the hub itself. Standalones leave this false (default). */
hideHub?: boolean hideHub?: boolean
/** App-specific nav items rendered at the top of the standalone menu. */ /** Forwarded to BottomNav. Hub passes true so logged-out users can still
sidebarNav?: SidebarNavItem[] * reach prefs from the sheet. Standalones leave it false. */
loggedOutOpensSheet?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
hideHub: false, hideHub: false,
sidebarNav: () => [], loggedOutOpensSheet: false,
}) })
const route = useRoute() const route = useRoute()
@ -44,13 +45,11 @@ const isLoginPage = computed(() => route.path === '/login')
v-if="!isLoginPage" v-if="!isLoginPage"
:tabs="props.tabs" :tabs="props.tabs"
:is-active="props.isActive" :is-active="props.isActive"
:logged-out-opens-sheet="props.loggedOutOpensSheet"
/> />
</div> </div>
<StandaloneMenu <HubPill v-if="!props.hideHub && !isLoginPage" />
v-if="!props.hideHub && !isLoginPage"
:items="props.sidebarNav"
/>
<Toaster /> <Toaster />
<!-- Default slot for shell-level overlays (dialogs, sheets, etc.) that <!-- Default slot for shell-level overlays (dialogs, sheets, etc.) that

View file

@ -48,7 +48,7 @@ const isActive = (href: string) => {
<div class="flex h-16 shrink-0 items-center"> <div class="flex h-16 shrink-0 items-center">
<router-link to="/" class="flex items-center gap-2"> <router-link to="/" class="flex items-center gap-2">
<img <img
src="@brand-app-logo" src="@/assets/logo.png"
alt="Logo" alt="Logo"
class="h-8 w-8" class="h-8 w-8"
/> />

View file

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { Component } from 'vue' import type { Component } from 'vue'
import { useResizeObserver } from '@vueuse/core' import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
export interface BottomTab { export interface BottomTab {
/** Translated label shown under the icon. */ /** Translated label shown under the icon. */
@ -19,11 +18,6 @@ export interface BottomTab {
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and /** Render the entry ghosted (opacity-reduced). Used for coming-soon and
* for auth-required tabs when the user is logged out. */ * for auth-required tabs when the user is logged out. */
disabled?: boolean disabled?: boolean
/** Per-tab active-state override for entries whose active condition
* doesn't reduce to "current route starts with this.path" — e.g. a
* "Hosting" tab that is active when a feed-filter ref is on. When
* set it wins over the App-level `isActive(path)` matcher. */
isActive?: () => boolean
} }
interface Props { interface Props {
@ -31,25 +25,13 @@ interface Props {
/** Active-tab matcher. Each app has its own nesting rules so we don't try /** Active-tab matcher. Each app has its own nesting rules so we don't try
* to derive a one-size-fits-all default — consumer supplies the function. */ * to derive a one-size-fits-all default — consumer supplies the function. */
isActive: (path: string) => boolean isActive: (path: string) => boolean
/** When true (Hub), the unauthenticated profile button still opens the
* sheet so logged-out users can change theme/lang. When false (standalones),
* unauth profile button routes straight to /login. */
loggedOutOpensSheet?: boolean
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: false })
// Publish the nav's rendered height (h-14 + safe-area padding + border,
// whatever the device makes of it) as `--bottom-nav-height` on <html>, so
// anything that needs to sit flush above the nav — sticky action bars,
// toasts — can use `bottom: var(--bottom-nav-height)` instead of
// re-deriving the CSS and drifting when insets or font scaling differ.
const navRef = ref<HTMLElement | null>(null)
function publishHeight() {
const h = navRef.value?.getBoundingClientRect().height ?? 0
document.documentElement.style.setProperty('--bottom-nav-height', `${Math.round(h)}px`)
}
useResizeObserver(navRef, publishHeight)
onMounted(publishHeight)
onBeforeUnmount(() => {
document.documentElement.style.setProperty('--bottom-nav-height', '0px')
})
const router = useRouter() const router = useRouter()
@ -60,16 +42,10 @@ function onTabClick(tab: BottomTab) {
} }
if (tab.path) router.push(tab.path) if (tab.path) router.push(tab.path)
} }
function isTabActive(tab: BottomTab): boolean {
if (tab.isActive) return tab.isActive()
return !!tab.path && props.isActive(tab.path)
}
</script> </script>
<template> <template>
<nav <nav
ref="navRef"
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60" class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
style="padding-bottom: env(safe-area-inset-bottom)" style="padding-bottom: env(safe-area-inset-bottom)"
> >
@ -80,12 +56,12 @@ function isTabActive(tab: BottomTab): boolean {
:key="tab.name" :key="tab.name"
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors" class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="[ :class="[
isTabActive(tab) tab.path && props.isActive(tab.path)
? 'text-primary' ? 'text-primary'
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
tab.disabled ? 'opacity-50' : '', tab.disabled ? 'opacity-50' : '',
]" ]"
:aria-current="isTabActive(tab) ? 'page' : undefined" :aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
@click="onTabClick(tab)" @click="onTabClick(tab)"
> >
<component :is="tab.icon" class="w-5 h-5" /> <component :is="tab.icon" class="w-5 h-5" />
@ -97,6 +73,10 @@ function isTabActive(tab: BottomTab): boolean {
{{ tab.badge > 99 ? '99+' : tab.badge }} {{ tab.badge > 99 ? '99+' : tab.badge }}
</span> </span>
</button> </button>
<!-- Always-on Profile entry, appended on the right. Consumers don't
pass it; the shell owns it so it's identical across every app. -->
<ProfileSheetTrigger :logged-out-opens-sheet="props.loggedOutOpensSheet" />
</div> </div>
</nav> </nav>
</template> </template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Home } from 'lucide-vue-next'
const { t } = useI18n()
/** Falls back to '/' for path-mount deployments where the hub root is the
* same origin. Set VITE_HUB_ROOT_URL to a full URL for subdomain
* deployments where the hub lives on a sibling origin. */
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
</script>
<template>
<a
:href="hubRootUrl"
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center gap-1.5 rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
:aria-label="t('common.nav.backToHub')"
>
<Home class="w-3.5 h-3.5" />
<span class="hidden sm:inline">{{ t('common.nav.hub') }}</span>
</a>
</template>

View file

@ -77,7 +77,7 @@ const navigateTo = (href: string) => {
<SheetHeader class="px-6 py-4 border-b border-border"> <SheetHeader class="px-6 py-4 border-b border-border">
<SheetTitle class="flex items-center gap-2"> <SheetTitle class="flex items-center gap-2">
<img <img
src="@brand-app-logo" src="@/assets/logo.png"
alt="Logo" alt="Logo"
class="h-8 w-8" class="h-8 w-8"
/> />

View file

@ -1,277 +1,86 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Check, Copy, LogIn, LogOut, Pencil, Zap } from 'lucide-vue-next' import { Home, LogIn } from 'lucide-vue-next'
import { nip19 } from 'nostr-tools'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet' import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar' import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
import { toastService } from '@/core/services/ToastService'
import PreferencesRow from './PreferencesRow.vue' import PreferencesRow from './PreferencesRow.vue'
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue' import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const { isAuthenticated, user, logout } = useAuth() const { isAuthenticated, user } = useAuth()
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar() const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
const lightningDomain = computed( const npubPreview = computed(() => {
() => import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname,
)
// Lightning Address and NIP-05 share the same identifier in this app —
// both are `username@domain`. The `@username` row above the identity
// card already signals NIP-05, so this row is labeled just "Lightning".
const lightningAddress = computed(() => {
const username = user.value?.username
if (!username) return ''
return `${username}@${lightningDomain.value}`
})
const npub = computed(() => {
const pubkey = user.value?.pubkey const pubkey = user.value?.pubkey
if (!pubkey) return '' if (!pubkey) return ''
try { return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`
return nip19.npubEncode(pubkey)
} catch {
return pubkey
}
})
const npubPreview = computed(() => {
const value = npub.value
if (!value) return ''
return value.length > 24 ? `${value.slice(0, 12)}…${value.slice(-8)}` : value
}) })
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/') const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
const copiedField = ref<string | null>(null)
async function copyToClipboard(text: string, field: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
copiedField.value = field
toastService.success(t('common.nav.copied', 'Copied to clipboard'))
setTimeout(() => {
if (copiedField.value === field) copiedField.value = null
}, 1500)
} catch (err) {
console.error('Copy failed:', err)
toastService.error(t('common.nav.copyFailed', 'Failed to copy'))
}
}
const editProfileOpen = ref(false)
function goLogin() { function goLogin() {
router.push('/login') router.push('/login')
} }
async function onLogout() {
try {
await logout()
toastService.success(t('common.nav.loggedOut', 'Logged out'))
router.push('/login')
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to log out'
toastService.error(`${t('common.nav.logoutFailed', 'Logout failed')}: ${msg}`)
}
}
</script> </script>
<template> <template>
<!-- Fill the sheet exactly (h-full) so the footer stays stuck to the <SheetHeader>
bottom while only the region above it scrolls. The sheet host already <SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
has overflow-y-auto, but with an exact-fit child it never triggers — <SheetDescription v-if="isAuthenticated">
the inner flex-1 region owns the scroll instead. --> {{ t('common.nav.profileDescription') }}
<div class="flex h-full flex-col"> </SheetDescription>
<!-- Scrollable region: everything above the pinned footer. min-h-0 lets <SheetDescription v-else>
this flex child shrink below its content height so it can scroll. --> {{ t('common.nav.profileLoggedOutDescription') }}
<div class="flex-1 min-h-0 overflow-y-auto"> </SheetDescription>
<SheetHeader> </SheetHeader>
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
<SheetDescription v-if="isAuthenticated">
{{ t('common.nav.profileDescription') }}
</SheetDescription>
<SheetDescription v-else>
{{ t('common.nav.profileLoggedOutDescription') }}
</SheetDescription>
</SheetHeader>
<!-- Identity card (logged in) — summary with an inline edit (pencil) <!-- Identity card (logged in) -->
button that opens the profile form. --> <div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4"> <Avatar class="h-12 w-12">
<div class="flex items-center gap-3 min-w-0"> <AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
<Avatar class="h-12 w-12 shrink-0"> <AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" /> </Avatar>
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback> <div class="min-w-0 flex-1">
</Avatar> <p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
<div class="min-w-0 flex-1"> <p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p>
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
<p v-if="displayName && user?.username" class="text-xs text-muted-foreground truncate">
@{{ user.username }}
</p>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 self-start text-muted-foreground"
:aria-label="t('common.nav.editProfile', 'Edit profile')"
@click="editProfileOpen = true"
>
<Pencil class="h-4 w-4" />
</Button>
</div>
<!-- Identifier rows: full-width value with a corner-offset "legend"
badge straddling the top border (fieldset-legend pattern). The
value gets the entire row so long bech32 / username@domain
strings have room to render. -->
<div class="space-y-3 pt-1">
<!-- Lightning Address — this is also the NIP-05 in this stack,
but the @username above already signals the NIP-05. -->
<button
v-if="lightningAddress"
type="button"
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
:aria-label="t('common.nav.copyLightning', 'Copy Lightning address')"
@click="copyToClipboard(lightningAddress, 'lightning')"
>
<span class="absolute -top-2 left-2 inline-flex items-center gap-1 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
<Zap class="w-3 h-3 text-yellow-500 fill-yellow-500" />
{{ t('common.nav.lightning', 'Lightning') }}
</span>
<span class="block truncate pr-6 text-xs font-mono">{{ lightningAddress }}</span>
<component
:is="copiedField === 'lightning' ? Check : Copy"
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
/>
</button>
<!-- npub — copy the full bech32 even though we display a preview. -->
<button
v-if="npub"
type="button"
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
:aria-label="t('common.nav.copyNpub', 'Copy npub')"
@click="copyToClipboard(npub, 'npub')"
>
<span class="absolute -top-2 left-2 inline-flex items-center rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
{{ t('common.nav.npub', 'npub') }}
</span>
<span class="block truncate pr-6 text-xs font-mono">{{ npubPreview }}</span>
<component
:is="copiedField === 'npub' ? Check : Copy"
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
/>
</button>
</div>
</div>
<!-- App-specific nav items (rendered by callers like StandaloneMenu) -->
<slot name="app-nav" />
<!-- Cross-app links + global preferences (always visible, auth or not) -->
<div class="mt-4">
<PreferencesRow layout="list" />
</div>
</div>
<!-- Pinned footer: stays stuck to the bottom of the sheet (shrink-0);
"Back to hub" sits directly above the log-in/out bar. -->
<div class="shrink-0 pt-1">
<!-- "Back to hub" shows the HUB's brand-kit logo (the brand's
primary/global logo via @brand-hub-logo) — NOT the per-standalone
@brand-app-logo, which resolves to this standalone's own logo. -->
<a
:href="hubRootUrl"
class="flex items-center justify-center gap-3 px-3 py-1.5 hover:bg-accent rounded-md transition-colors"
:aria-label="t('common.nav.backToHub')"
>
<div class="flex items-center gap-3">
<img src="@brand-hub-logo" :alt="t('common.nav.backToHub')" class="w-8 h-8 shrink-0" />
<span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
</div>
</a>
<!-- Logged-out: prominent log-in CTA -->
<div v-if="!isAuthenticated">
<Separator class="mb-2" />
<Button class="w-full" @click="goLogin">
<LogIn class="mr-2 h-4 w-4" />
{{ t('common.nav.login') }}
</Button>
</div>
<!-- Logged-in: log-out button stays visible without opening the edit popup. -->
<div v-else>
<Separator class="mb-2" />
<AlertDialog>
<AlertDialogTrigger as-child>
<Button variant="destructive" class="w-full">
<LogOut class="mr-2 h-4 w-4" />
{{ t('common.nav.logOut', 'Log out') }}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Log out of {{ user?.username || 'your account' }}?
</AlertDialogTitle>
<AlertDialogDescription>
{{ t('common.nav.logOutConfirmDescription', "You'll need to sign in again to access your wallet, post in the forum, place orders, or use any feature that needs your account.") }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('common.nav.cancel', 'Cancel') }}</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@click="onLogout"
>
{{ t('common.nav.logOut', 'Log out') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div> </div>
</div> </div>
<!-- Edit-profile popup (pencil button in the identity card) — the full <!-- Cross-app links + global preferences (always visible, auth or not) -->
form lives here so the sheet stays scannable. Outside the flex root: <div class="mt-4">
its content portals to <body>, so it's not part of the sheet flow. --> <a
<Dialog v-model:open="editProfileOpen"> :href="hubRootUrl"
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden"> class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors"
<DialogHeader> :aria-label="t('common.nav.backToHub')"
<DialogTitle>{{ t('common.nav.editProfile', 'Edit profile') }}</DialogTitle> >
<DialogDescription> <div class="flex items-center gap-3">
{{ t('common.nav.editProfileDescription', 'Update your display name and profile picture.') }} <Home class="w-5 h-5 text-muted-foreground" />
</DialogDescription> <span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
</DialogHeader> </div>
<ProfileSettings /> </a>
</DialogContent>
</Dialog> <PreferencesRow layout="list" />
</div>
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings -->
<div v-if="!isAuthenticated" class="mt-6">
<Separator class="mb-4" />
<Button class="w-full" @click="goLogin">
<LogIn class="mr-2 h-4 w-4" />
{{ t('common.nav.login') }}
</Button>
</div>
<!-- Logged-in: full profile management form -->
<div v-else class="mt-6">
<Separator class="mb-4" />
<ProfileSettings />
</div>
</template> </template>

View file

@ -50,7 +50,7 @@ const open = ref(false)
</template> </template>
</button> </button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto overflow-x-hidden"> <SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
<ProfileSheetContent /> <ProfileSheetContent />
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View file

@ -1,93 +0,0 @@
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { User } from 'lucide-vue-next'
import {
Sheet,
SheetContent,
SheetTrigger,
} from '@/components/ui/sheet'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Separator } from '@/components/ui/separator'
import { useAuth } from '@/composables/useAuthService'
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
import ProfileSheetContent from './ProfileSheetContent.vue'
export interface SidebarNavItem {
/** Display label. */
name: string
/** Lucide (or any) component to render as the leading icon. */
icon: Component
/** Optional route to navigate to on click. */
path?: string
/** Optional click handler. Runs after navigation if both are set. */
onClick?: () => void
/** Visual-only "active" predicate for highlight state. */
isActive?: () => boolean
}
interface Props {
/** App-specific nav items rendered at the top of the sheet. */
items?: SidebarNavItem[]
}
const props = withDefaults(defineProps<Props>(), { items: () => [] })
const { t } = useI18n()
const router = useRouter()
const open = ref(false)
const { isAuthenticated } = useAuth()
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
function handleClick(item: SidebarNavItem) {
if (item.path) router.push(item.path)
item.onClick?.()
open.value = false
}
</script>
<template>
<Sheet v-model:open="open">
<SheetTrigger as-child>
<button
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center overflow-hidden rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
:aria-label="isAuthenticated ? t('common.nav.profile') : t('common.nav.login')"
>
<!-- Logged in: avatar (image, or first initial). Logged out: a
login icon. Opens the same profile/menu sheet either way. -->
<Avatar v-if="isAuthenticated" class="h-full w-full">
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
</Avatar>
<User v-else class="w-5 h-5" />
</button>
</SheetTrigger>
<SheetContent side="right" class="w-80 sm:w-96 max-w-full overflow-y-auto overflow-x-hidden">
<ProfileSheetContent>
<template v-if="props.items.length" #app-nav>
<nav class="mt-4 space-y-1">
<button
v-for="item in props.items"
:key="item.name"
type="button"
:class="[
item.isActive?.()
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'group flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
]"
@click="handleClick(item)"
>
<component :is="item.icon" class="h-5 w-5 shrink-0" />
{{ item.name }}
</button>
</nav>
<Separator class="mt-4" />
</template>
</ProfileSheetContent>
</SheetContent>
</Sheet>
</template>

View file

@ -21,28 +21,10 @@ export const PALETTES: Palette[] = [
'starrynight', 'starrynight',
] ]
// The palette styled by the bare `:root` in index.css (no `data-theme` const DEFAULT_PALETTE: Palette = 'catppuccin'
// attribute). This is a fixed invariant of the CSS, NOT the configurable
// default — applyPalette() removes the attribute only for this one.
const BASE_PALETTE: Palette = 'catppuccin'
// Brand-configurable initial defaults, surfaced from brand.json via
// vite-branding.ts (VITE_BRAND_THEME / VITE_BRAND_PALETTE). These set the
// first-load value when the user has no saved preference; a stored choice
// always overrides (see onMounted). Invalid/unset values fall back to the
// app's built-ins ('dark' + catppuccin).
const BRAND_THEME = (import.meta.env.VITE_BRAND_THEME as string) || ''
const BRAND_PALETTE = (import.meta.env.VITE_BRAND_PALETTE as string) || ''
const DEFAULT_THEME: Theme =
BRAND_THEME === 'dark' || BRAND_THEME === 'light' || BRAND_THEME === 'system'
? BRAND_THEME
: 'dark'
const DEFAULT_PALETTE: Palette = (PALETTES as string[]).includes(BRAND_PALETTE)
? (BRAND_PALETTE as Palette)
: BASE_PALETTE
const useTheme = () => { const useTheme = () => {
const theme = ref<Theme>(DEFAULT_THEME) const theme = ref<Theme>('dark')
const systemTheme = ref<'dark' | 'light'>('light') const systemTheme = ref<'dark' | 'light'>('light')
const palette = ref<Palette>(DEFAULT_PALETTE) const palette = ref<Palette>(DEFAULT_PALETTE)
@ -63,7 +45,7 @@ const useTheme = () => {
} }
const applyPalette = () => { const applyPalette = () => {
if (palette.value === BASE_PALETTE) { if (palette.value === DEFAULT_PALETTE) {
document.documentElement.removeAttribute('data-theme') document.documentElement.removeAttribute('data-theme')
} else { } else {
document.documentElement.setAttribute('data-theme', palette.value) document.documentElement.setAttribute('data-theme', palette.value)

View file

@ -5,7 +5,7 @@ export { default as AvatarFallback } from './AvatarFallback.vue'
export { default as AvatarImage } from './AvatarImage.vue' export { default as AvatarImage } from './AvatarImage.vue'
export const avatarVariant = cva( export const avatarVariant = cva(
'inline-flex items-center justify-center font-normal text-secondary-foreground select-none shrink-0 bg-secondary overflow-hidden', 'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
{ {
variants: { variants: {
size: { size: {

View file

@ -1,58 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarRootEmits, RangeCalendarRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { RangeCalendarCell, RangeCalendarCellTrigger, RangeCalendarGrid, RangeCalendarGridBody, RangeCalendarGridHead, RangeCalendarGridRow, RangeCalendarHeadCell, RangeCalendarHeader, RangeCalendarHeading, RangeCalendarNextButton, RangeCalendarPrevButton } from "."
const props = defineProps<RangeCalendarRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<RangeCalendarRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<RangeCalendarRoot
v-slot="{ grid, weekDays }"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<RangeCalendarHeader>
<RangeCalendarPrevButton />
<RangeCalendarHeading />
<RangeCalendarNextButton />
</RangeCalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<RangeCalendarGrid v-for="month in grid" :key="month.value.toString()">
<RangeCalendarGridHead>
<RangeCalendarGridRow>
<RangeCalendarHeadCell
v-for="day in weekDays" :key="day"
>
{{ day }}
</RangeCalendarHeadCell>
</RangeCalendarGridRow>
</RangeCalendarGridHead>
<RangeCalendarGridBody>
<RangeCalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<RangeCalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<RangeCalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</RangeCalendarCell>
</RangeCalendarGridRow>
</RangeCalendarGridBody>
</RangeCalendarGrid>
</div>
</RangeCalendarRoot>
</template>

View file

@ -1,22 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarCell
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20', props.class)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarCell>
</template>

View file

@ -1,40 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarCellTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarCellTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<RangeCalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarCellTrigger
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Range interior: hover preview (highlighted) and committed selection
// share the accent tint; the two ends get the primary pill.
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[highlighted]:rounded-none',
'data-[selected]:bg-accent data-[selected]:text-accent-foreground data-[selected]:rounded-none data-[selected]:opacity-100',
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:rounded-md data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground',
'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:rounded-md data-[selection-end]:hover:bg-primary data-[selection-end]:hover:text-primary-foreground',
// Disabled (before today, past the next occupied night, under min stay)
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-40 data-[disabled]:pointer-events-none',
// Unavailable: an occupied night — struck through, muted, not a target
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through data-[unavailable]:opacity-50 data-[unavailable]:pointer-events-none',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarCellTrigger>
</template>

View file

@ -1,22 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarGridProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarGrid, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarGridProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarGrid>
</template>

View file

@ -1,12 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarGridBodyProps } from "reka-ui"
import { RangeCalendarGridBody } from "reka-ui"
const props = defineProps<RangeCalendarGridBodyProps>()
</script>
<template>
<RangeCalendarGridBody v-bind="props">
<slot />
</RangeCalendarGridBody>
</template>

View file

@ -1,13 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarGridHeadProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { RangeCalendarGridHead } from "reka-ui"
const props = defineProps<RangeCalendarGridHeadProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<RangeCalendarGridHead v-bind="props">
<slot />
</RangeCalendarGridHead>
</template>

View file

@ -1,19 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarGridRowProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarGridRow, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</RangeCalendarGridRow>
</template>

View file

@ -1,19 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarHeadCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarHeadCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarHeadCell :class="cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
<slot />
</RangeCalendarHeadCell>
</template>

View file

@ -1,19 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarHeaderProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarHeader, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
<slot />
</RangeCalendarHeader>
</template>

View file

@ -1,29 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarHeadingProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RangeCalendarHeading, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RangeCalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</RangeCalendarHeading>
</template>

View file

@ -1,30 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import { RangeCalendarNext, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<RangeCalendarNextProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarNext
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</RangeCalendarNext>
</template>

View file

@ -1,30 +0,0 @@
<script lang="ts" setup>
import type { RangeCalendarPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronLeft } from "lucide-vue-next"
import { RangeCalendarPrev, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<RangeCalendarPrevProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarPrev
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</RangeCalendarPrev>
</template>

View file

@ -1,12 +0,0 @@
export { default as RangeCalendar } from "./RangeCalendar.vue"
export { default as RangeCalendarCell } from "./RangeCalendarCell.vue"
export { default as RangeCalendarCellTrigger } from "./RangeCalendarCellTrigger.vue"
export { default as RangeCalendarGrid } from "./RangeCalendarGrid.vue"
export { default as RangeCalendarGridBody } from "./RangeCalendarGridBody.vue"
export { default as RangeCalendarGridHead } from "./RangeCalendarGridHead.vue"
export { default as RangeCalendarGridRow } from "./RangeCalendarGridRow.vue"
export { default as RangeCalendarHeadCell } from "./RangeCalendarHeadCell.vue"
export { default as RangeCalendarHeader } from "./RangeCalendarHeader.vue"
export { default as RangeCalendarHeading } from "./RangeCalendarHeading.vue"
export { default as RangeCalendarNextButton } from "./RangeCalendarNextButton.vue"
export { default as RangeCalendarPrevButton } from "./RangeCalendarPrevButton.vue"

View file

@ -175,9 +175,6 @@ export const SERVICE_TOKENS = {
// Restaurant services // Restaurant services
RESTAURANT_API: Symbol('restaurantAPI'), RESTAURANT_API: Symbol('restaurantAPI'),
RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'), RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'),
// Chatelet (room rentals) services
CHATELET_API: Symbol('chateletAPI'),
} as const } as const
// Type-safe injection helpers // Type-safe injection helpers

View file

@ -3,7 +3,7 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { Home, Map, Heart, Ticket, Megaphone } from 'lucide-vue-next' import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue' import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue' import type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
@ -23,72 +23,37 @@ const eventsStore = useEventsStore()
const { isAdmin, autoApprove } = useApprovalState() const { isAdmin, autoApprove } = useApprovalState()
// Used to merge own LNbits drafts into the events feed right after // Used to merge own LNbits drafts into the events feed right after
// the user creates or edits an event — otherwise the new draft only // the user creates or edits an event — otherwise the new draft only
// surfaces on the next EventsPage subscribe cycle. `onlyHosting` // surfaces on the next EventsPage subscribe cycle.
// is the feed filter that backs the Hosting bottom-nav tab — tapping const { loadOwnEvents } = useEvents()
// it toggles the filter on; Home tab toggles it off.
const { loadOwnEvents, onlyHosting, toggleHosting } = useEvents()
// True for /events and its sub-routes (incl. detail pages) but
// not for the routes owned by other tabs (map/favorites). Used by
// both Home and Hosting active-state predicates so the highlight
// only shifts based on the onlyHosting flag while you're in the feed.
function inFeedRoute(): boolean {
if (route.path.startsWith('/events/map')) return false
if (route.path.startsWith('/events/favorites')) return false
return route.path === '/events' || route.path.startsWith('/events/')
}
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
// Create lives in the bottom nav: when logged out, tapping it shows an
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
// opening the dialog. Per-app placement deliberation tracked at #53.
const tabs = computed<BottomTab[]>(() => [ const tabs = computed<BottomTab[]>(() => [
{ name: t('events.nav.feed'), icon: Search, path: '/events' },
{ name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' },
{ {
name: t('events.nav.feed'), name: t('events.createNew'),
icon: Home, icon: Plus,
onClick: () => { onClick: () => {
// Tapping Home clears the hosting filter so the feed always if (!isAuthenticated.value) {
// returns to the unfiltered view, regardless of where the toast.info('Log in to create an event', {
// user just came from. action: {
if (onlyHosting.value) toggleHosting() label: 'Log in',
if (route.path !== '/events') router.push('/events') onClick: () => router.push('/login'),
},
})
return
}
// Defensively clear any lingering edit selection so the Create
// tap always opens in Create mode regardless of a prior Edit.
eventsStore.editingEvent = null
eventsStore.showCreateDialog = true
}, },
isActive: () => inFeedRoute() && !onlyHosting.value, disabled: !isAuthenticated.value,
}, },
{ name: t('events.nav.map'), icon: Map, path: '/events/map' }, { name: t('events.nav.map'), icon: Map, path: '/events/map' },
{
name: t('events.filters.myTickets'),
icon: Ticket,
path: '/my-tickets',
onClick: () => {
if (!isAuthenticated.value) {
toast.info(t('events.detail.loginToBuyTickets'), {
action: {
label: t('events.detail.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
router.push('/my-tickets')
},
disabled: !isAuthenticated.value,
},
{
name: t('events.filters.hosting'),
icon: Megaphone,
onClick: () => {
if (!isAuthenticated.value) {
toast.info(t('events.hosting.loginPrompt', 'Log in to manage your hosted events'), {
action: {
label: t('events.favorites.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
if (!onlyHosting.value) toggleHosting()
if (route.path !== '/events') router.push('/events')
},
isActive: () => inFeedRoute() && onlyHosting.value,
disabled: !isAuthenticated.value,
},
{ {
name: t('events.nav.favorites'), name: t('events.nav.favorites'),
icon: Heart, icon: Heart,
@ -112,8 +77,18 @@ const tabs = computed<BottomTab[]>(() => [
}, },
]) ])
// Path-based fallback for tabs that don't carry their own `isActive`. // Feed tab is active for the bare /events route AND all sub-paths that
// aren't owned by another tab (e.g. /events/<id> detail pages).
function isActive(path: string): boolean { function isActive(path: string): boolean {
if (path === '/events') {
return (
route.path === '/events' ||
(route.path.startsWith('/events/') &&
!route.path.startsWith('/events/calendar') &&
!route.path.startsWith('/events/map') &&
!route.path.startsWith('/events/favorites'))
)
}
return route.path.startsWith(path) return route.path.startsWith(path)
} }

View file

@ -22,7 +22,6 @@ const messages: LocaleMessages = {
profileDescription: 'Your Nostr identity and display name.', profileDescription: 'Your Nostr identity and display name.',
profileLoggedOutDescription: 'Sign in or change your preferences.', profileLoggedOutDescription: 'Sign in or change your preferences.',
login: 'Log in', login: 'Log in',
menu: 'Menu',
backToHub: 'Back to hub', backToHub: 'Back to hub',
hub: 'Hub', hub: 'Hub',
theme: 'Theme', theme: 'Theme',
@ -58,27 +57,6 @@ const messages: LocaleMessages = {
events: { events: {
title: 'Events', title: 'Events',
createNew: 'Create Event', createNew: 'Create Event',
create: {
promo: {
title: 'Promo codes',
hint: 'Percentage discounts buyers can enter at checkout. Codes are never shown publicly.',
empty: 'No promo codes yet.',
code: 'Code',
codePlaceholder: 'EARLYBIRD',
discount: 'Discount %',
maxUses: 'Max uses',
active: 'Active',
used: 'Used {count}',
add: 'Add code',
removeLabel: 'Remove code {code}',
errors: {
required: 'Enter a code',
duplicate: 'Duplicate code',
percentRange: 'Between 1 and 100',
maxUsesMin: 'Must be 1 or more',
},
},
},
noEvents: 'No events found', noEvents: 'No events found',
filters: { filters: {
all: 'All', all: 'All',
@ -90,10 +68,6 @@ const messages: LocaleMessages = {
hosting: 'Hosting', hosting: 'Hosting',
pastEvents: 'Past events', pastEvents: 'Past events',
past: 'Past', past: 'Past',
filters: 'Filters',
clearAll: 'Clear all',
filteringBy: 'Filtering by:',
removeCategory: 'Remove {category} filter',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -124,13 +98,15 @@ const messages: LocaleMessages = {
}, },
detail: { detail: {
getTicket: 'Get Ticket', getTicket: 'Get Ticket',
going: 'Going',
maybe: 'Maybe',
notGoing: 'Not Going',
contactOrganizer: 'Contact Organizer', contactOrganizer: 'Contact Organizer',
organizer: 'Organizer', organizer: 'Organizer',
location: 'Location', location: 'Location',
when: 'When', when: 'When',
tickets: 'Tickets', tickets: 'Tickets',
ticketsAvailable: '{count} tickets available', ticketsAvailable: '{count} tickets available',
ticketsRemainingOfTotal: '{count} of {total} tickets left',
ticketsOwned: 'You have {count} ticket | You have {count} tickets', ticketsOwned: 'You have {count} ticket | You have {count} tickets',
unlimitedTickets: 'Unlimited tickets', unlimitedTickets: 'Unlimited tickets',
buyTicket: 'Buy ticket', buyTicket: 'Buy ticket',
@ -150,67 +126,8 @@ const messages: LocaleMessages = {
pending: 'Pending', pending: 'Pending',
registered: 'Registered', registered: 'Registered',
}, },
purchase: {
card: 'Card',
continueTo: 'Continue to {provider} checkout',
fiatRedirectHint: "You'll be taken to the provider's secure checkout and brought back here once paid.",
noCheckoutUrl: 'Fiat provider did not return a checkout URL.',
fiatFailed: 'Card checkout failed',
loginRequiredTitle: 'Login required',
loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.',
promo: {
label: 'Promo code',
placeholder: 'Enter code',
apply: 'Apply',
remove: 'Remove',
checking: 'Checking…',
applied: '{code} −{percent}%',
discountLine: 'Discount ({code})',
invalid: 'This code is not valid for this purchase.',
unverified: 'Could not verify the code — it will be applied at checkout if valid.',
loginRequiredDiscount: 'This code makes your ticket free. Free tickets need an account — log in to claim it.',
},
contact: {
title: 'Your details',
name: 'Name',
namePlaceholder: 'Name on the ticket',
nameRequired: 'Name is required',
email: 'Email',
emailHint: 'Your ticket will be sent to this address.',
spamHint: 'If it has not arrived a few minutes after paying, check your spam folder.',
emailInvalid: 'Enter a valid email address',
confirmEmail: 'Confirm email',
emailMismatch: 'Email addresses do not match',
},
},
checkout: {
title: 'Confirming your payment…',
paidTitle: 'Payment received',
processing: 'Waiting for the payment to settle. This usually takes a few seconds.',
stillProcessing: 'Still processing. Your ticket will be emailed to {email} as soon as the payment settles.',
stillProcessingNoEmail: 'Still processing. Reload this page in a moment to check again.',
emailedTo: 'We emailed your ticket to {email}.',
cancelled: 'Payment cancelled — no seats were reserved.',
viewTicket: 'Open ticket page',
viewMyTickets: 'View My Tickets',
retry: 'Check again',
ticketN: 'Ticket {n} of {total}',
close: 'Close',
},
ticket: {
title: 'Your ticket',
loading: 'Loading ticket…',
notFound: 'Ticket not found',
event: 'Event',
holder: 'Name',
paid: 'Paid',
pending: 'Payment pending',
registered: 'Checked in',
showAtDoor: 'Show this QR at the door.',
backToEvent: 'Back to event',
},
nav: { nav: {
feed: 'Home', feed: 'Feed',
calendar: 'Calendar', calendar: 'Calendar',
map: 'Map', map: 'Map',
favorites: 'Favorites', favorites: 'Favorites',
@ -282,8 +199,7 @@ const messages: LocaleMessages = {
income: { income: {
title: 'Add Income', title: 'Add Income',
description: 'Submit income for the organization', description: 'Submit income for the organization',
selectAccount: 'Select the revenue account.', selectAccount: 'Select the revenue account',
otherAccountHint: 'Use the "Other" account if you\'re not sure.',
submitIncome: 'Submit Income', submitIncome: 'Submit Income',
notAvailable: 'Income submission is not yet available. This feature is coming soon.', notAvailable: 'Income submission is not yet available. This feature is coming soon.',
}, },

View file

@ -22,7 +22,6 @@ const messages: LocaleMessages = {
profileDescription: 'Tu identidad Nostr y nombre de visualización.', profileDescription: 'Tu identidad Nostr y nombre de visualización.',
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.', profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
login: 'Iniciar sesión', login: 'Iniciar sesión',
menu: 'Menú',
backToHub: 'Volver al hub', backToHub: 'Volver al hub',
hub: 'Hub', hub: 'Hub',
theme: 'Tema', theme: 'Tema',
@ -58,27 +57,6 @@ const messages: LocaleMessages = {
events: { events: {
title: 'Eventos', title: 'Eventos',
createNew: 'Crear evento', createNew: 'Crear evento',
create: {
promo: {
title: 'Códigos promocionales',
hint: 'Descuentos porcentuales que los compradores pueden introducir al pagar. Los códigos nunca se muestran públicamente.',
empty: 'Aún no hay códigos promocionales.',
code: 'Código',
codePlaceholder: 'EARLYBIRD',
discount: 'Descuento %',
maxUses: 'Usos máx.',
active: 'Activo',
used: 'Usado {count}',
add: 'Añadir código',
removeLabel: 'Eliminar el código {code}',
errors: {
required: 'Introduce un código',
duplicate: 'Código duplicado',
percentRange: 'Entre 1 y 100',
maxUsesMin: 'Debe ser 1 o más',
},
},
},
noEvents: 'No se encontraron eventos', noEvents: 'No se encontraron eventos',
filters: { filters: {
all: 'Todas', all: 'Todas',
@ -90,10 +68,6 @@ const messages: LocaleMessages = {
hosting: 'Organizo', hosting: 'Organizo',
pastEvents: 'Eventos pasados', pastEvents: 'Eventos pasados',
past: 'Pasado', past: 'Pasado',
filters: 'Filtros',
clearAll: 'Limpiar todo',
filteringBy: 'Filtrando por:',
removeCategory: 'Quitar el filtro {category}',
}, },
categories: { categories: {
concert: 'Concierto', concert: 'Concierto',
@ -124,13 +98,15 @@ const messages: LocaleMessages = {
}, },
detail: { detail: {
getTicket: 'Obtener boleto', getTicket: 'Obtener boleto',
going: 'Voy',
maybe: 'Tal vez',
notGoing: 'No voy',
contactOrganizer: 'Contactar organizador', contactOrganizer: 'Contactar organizador',
organizer: 'Organizador', organizer: 'Organizador',
location: 'Ubicación', location: 'Ubicación',
when: 'Cuándo', when: 'Cuándo',
tickets: 'Boletos', tickets: 'Boletos',
ticketsAvailable: '{count} boletos disponibles', ticketsAvailable: '{count} boletos disponibles',
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos', ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
unlimitedTickets: 'Boletos ilimitados', unlimitedTickets: 'Boletos ilimitados',
buyTicket: 'Comprar boleto', buyTicket: 'Comprar boleto',
@ -150,65 +126,6 @@ const messages: LocaleMessages = {
pending: 'Pendiente', pending: 'Pendiente',
registered: 'Registrado', registered: 'Registrado',
}, },
purchase: {
card: 'Tarjeta',
continueTo: 'Continuar al pago con {provider}',
fiatRedirectHint: 'Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.',
noCheckoutUrl: 'El proveedor no devolvió una URL de pago.',
fiatFailed: 'El pago con tarjeta falló',
loginRequiredTitle: 'Inicio de sesión requerido',
loginRequiredFree: 'Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.',
promo: {
label: 'Código promocional',
placeholder: 'Introduce el código',
apply: 'Aplicar',
remove: 'Quitar',
checking: 'Comprobando…',
applied: '{code} −{percent}%',
discountLine: 'Descuento ({code})',
invalid: 'Este código no es válido para esta compra.',
unverified: 'No se pudo verificar el código; se aplicará al pagar si es válido.',
loginRequiredDiscount: 'Este código hace que tu boleto sea gratis. Los boletos gratuitos requieren una cuenta: inicia sesión para reclamarlo.',
},
contact: {
title: 'Tus datos',
name: 'Nombre',
namePlaceholder: 'Nombre en el boleto',
nameRequired: 'El nombre es obligatorio',
email: 'Correo electrónico',
emailHint: 'Tu boleto se enviará a esta dirección.',
spamHint: 'Si no llega unos minutos después de pagar, revisa tu carpeta de spam.',
emailInvalid: 'Introduce un correo electrónico válido',
confirmEmail: 'Confirmar correo',
emailMismatch: 'Los correos no coinciden',
},
},
checkout: {
title: 'Confirmando tu pago…',
paidTitle: 'Pago recibido',
processing: 'Esperando a que se liquide el pago. Suele tardar unos segundos.',
stillProcessing: 'Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.',
stillProcessingNoEmail: 'Aún en proceso. Recarga esta página en un momento.',
emailedTo: 'Enviamos tu boleto a {email}.',
cancelled: 'Pago cancelado — no se reservó ningún asiento.',
viewTicket: 'Abrir página del boleto',
viewMyTickets: 'Ver mis boletos',
retry: 'Comprobar de nuevo',
ticketN: 'Boleto {n} de {total}',
close: 'Cerrar',
},
ticket: {
title: 'Tu boleto',
loading: 'Cargando boleto…',
notFound: 'Boleto no encontrado',
event: 'Evento',
holder: 'Nombre',
paid: 'Pagado',
pending: 'Pago pendiente',
registered: 'Registrado',
showAtDoor: 'Muestra este QR en la entrada.',
backToEvent: 'Volver al evento',
},
nav: { nav: {
feed: 'Inicio', feed: 'Inicio',
calendar: 'Calendario', calendar: 'Calendario',
@ -282,8 +199,7 @@ const messages: LocaleMessages = {
income: { income: {
title: 'A\u00f1adir ingreso', title: 'A\u00f1adir ingreso',
description: 'Enviar un ingreso para la organizaci\u00f3n', description: 'Enviar un ingreso para la organizaci\u00f3n',
selectAccount: 'Seleccionar la cuenta de ingresos.', selectAccount: 'Seleccionar la cuenta de ingresos',
otherAccountHint: 'Use la cuenta "Other" si no está seguro.',
submitIncome: 'Enviar ingreso', submitIncome: 'Enviar ingreso',
notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.', notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.',
}, },

View file

@ -22,7 +22,6 @@ const messages: LocaleMessages = {
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.', profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.', profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
login: 'Se connecter', login: 'Se connecter',
menu: 'Menu',
backToHub: 'Retour au hub', backToHub: 'Retour au hub',
hub: 'Hub', hub: 'Hub',
theme: 'Thème', theme: 'Thème',
@ -58,27 +57,6 @@ const messages: LocaleMessages = {
events: { events: {
title: 'Événements', title: 'Événements',
createNew: 'Créer un événement', createNew: 'Créer un événement',
create: {
promo: {
title: 'Codes promo',
hint: 'Remises en pourcentage que les acheteurs peuvent saisir au paiement. Les codes ne sont jamais affichés publiquement.',
empty: 'Aucun code promo pour le moment.',
code: 'Code',
codePlaceholder: 'EARLYBIRD',
discount: 'Remise %',
maxUses: 'Utilisations max.',
active: 'Actif',
used: 'Utilisé {count}',
add: 'Ajouter un code',
removeLabel: 'Supprimer le code {code}',
errors: {
required: 'Saisissez un code',
duplicate: 'Code en double',
percentRange: 'Entre 1 et 100',
maxUsesMin: 'Doit être 1 ou plus',
},
},
},
noEvents: 'Aucun événement trouvé', noEvents: 'Aucun événement trouvé',
filters: { filters: {
all: 'Tout', all: 'Tout',
@ -90,10 +68,6 @@ const messages: LocaleMessages = {
hosting: 'J\'organise', hosting: 'J\'organise',
pastEvents: 'Événements passés', pastEvents: 'Événements passés',
past: 'Passé', past: 'Passé',
filters: 'Filtres',
clearAll: 'Tout effacer',
filteringBy: 'Filtré par :',
removeCategory: 'Retirer le filtre {category}',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -124,13 +98,15 @@ const messages: LocaleMessages = {
}, },
detail: { detail: {
getTicket: 'Obtenir un billet', getTicket: 'Obtenir un billet',
going: 'Présent',
maybe: 'Peut-être',
notGoing: 'Absent',
contactOrganizer: "Contacter l'organisateur", contactOrganizer: "Contacter l'organisateur",
organizer: 'Organisateur', organizer: 'Organisateur',
location: 'Lieu', location: 'Lieu',
when: 'Quand', when: 'Quand',
tickets: 'Billets', tickets: 'Billets',
ticketsAvailable: '{count} billets disponibles', ticketsAvailable: '{count} billets disponibles',
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets', ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
unlimitedTickets: 'Billets illimités', unlimitedTickets: 'Billets illimités',
buyTicket: 'Acheter un billet', buyTicket: 'Acheter un billet',
@ -150,67 +126,8 @@ const messages: LocaleMessages = {
pending: 'En attente', pending: 'En attente',
registered: 'Enregistré', registered: 'Enregistré',
}, },
purchase: {
card: 'Carte',
continueTo: 'Continuer vers le paiement {provider}',
fiatRedirectHint: 'Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.',
noCheckoutUrl: "Le prestataire n'a pas renvoyé d'URL de paiement.",
fiatFailed: 'Le paiement par carte a échoué',
loginRequiredTitle: 'Connexion requise',
loginRequiredFree: 'Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.',
promo: {
label: 'Code promo',
placeholder: 'Saisir le code',
apply: 'Appliquer',
remove: 'Retirer',
checking: 'Vérification…',
applied: '{code} −{percent}%',
discountLine: 'Remise ({code})',
invalid: "Ce code n'est pas valable pour cet achat.",
unverified: "Impossible de vérifier le code — il sera appliqué au paiement s'il est valable.",
loginRequiredDiscount: 'Ce code rend votre billet gratuit. Les billets gratuits nécessitent un compte : connectez-vous pour le réclamer.',
},
contact: {
title: 'Vos coordonnées',
name: 'Nom',
namePlaceholder: 'Nom sur le billet',
nameRequired: 'Le nom est obligatoire',
email: 'E-mail',
emailHint: 'Votre billet sera envoyé à cette adresse.',
spamHint: 'S’il n’est pas arrivé quelques minutes après le paiement, vérifiez votre dossier spam.',
emailInvalid: 'Saisissez une adresse e-mail valide',
confirmEmail: "Confirmer l'e-mail",
emailMismatch: 'Les adresses e-mail ne correspondent pas',
},
},
checkout: {
title: 'Confirmation de votre paiement…',
paidTitle: 'Paiement reçu',
processing: 'En attente du règlement du paiement. Cela prend généralement quelques secondes.',
stillProcessing: 'Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.',
stillProcessingNoEmail: 'Toujours en cours. Rechargez cette page dans un instant.',
emailedTo: 'Nous avons envoyé votre billet à {email}.',
cancelled: 'Paiement annulé — aucune place réservée.',
viewTicket: 'Ouvrir la page du billet',
viewMyTickets: 'Voir mes billets',
retry: 'Vérifier à nouveau',
ticketN: 'Billet {n} sur {total}',
close: 'Fermer',
},
ticket: {
title: 'Votre billet',
loading: 'Chargement du billet…',
notFound: 'Billet introuvable',
event: 'Événement',
holder: 'Nom',
paid: 'Payé',
pending: 'Paiement en attente',
registered: 'Enregistré',
showAtDoor: "Présentez ce QR à l'entrée.",
backToEvent: "Retour à l'événement",
},
nav: { nav: {
feed: 'Accueil', feed: 'Fil',
calendar: 'Calendrier', calendar: 'Calendrier',
map: 'Carte', map: 'Carte',
favorites: 'Favoris', favorites: 'Favoris',
@ -282,8 +199,7 @@ const messages: LocaleMessages = {
income: { income: {
title: 'Ajouter un revenu', title: 'Ajouter un revenu',
description: 'Soumettre un revenu pour l\u2019organisation', description: 'Soumettre un revenu pour l\u2019organisation',
selectAccount: 'S\u00e9lectionner le compte de revenus.', selectAccount: 'S\u00e9lectionner le compte de revenus',
otherAccountHint: 'Utilisez le compte "Other" si vous n\'\u00eates pas s\u00fbr.',
submitIncome: 'Soumettre le revenu', submitIncome: 'Soumettre le revenu',
notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.', notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.',
}, },

View file

@ -21,7 +21,6 @@ export interface LocaleMessages {
profileDescription: string profileDescription: string
profileLoggedOutDescription: string profileLoggedOutDescription: string
login: string login: string
menu: string
backToHub: string backToHub: string
hub: string hub: string
theme: string theme: string
@ -59,27 +58,6 @@ export interface LocaleMessages {
events?: { events?: {
title: string title: string
createNew: string createNew: string
create: {
promo: {
title: string
hint: string
empty: string
code: string
codePlaceholder: string
discount: string
maxUses: string
active: string
used: string
add: string
removeLabel: string
errors: {
required: string
duplicate: string
percentRange: string
maxUsesMin: string
}
}
}
noEvents: string noEvents: string
filters: { filters: {
all: string all: string
@ -91,21 +69,19 @@ export interface LocaleMessages {
hosting: string hosting: string
pastEvents: string pastEvents: string
past: string past: string
filters: string
clearAll: string
filteringBy: string
removeCategory: string
} }
categories: Record<string, string> categories: Record<string, string>
detail: { detail: {
getTicket: string getTicket: string
going: string
maybe: string
notGoing: string
contactOrganizer: string contactOrganizer: string
organizer: string organizer: string
location: string location: string
when: string when: string
tickets: string tickets: string
ticketsAvailable: string ticketsAvailable: string
ticketsRemainingOfTotal: string
ticketsOwned: string ticketsOwned: string
unlimitedTickets: string unlimitedTickets: string
buyTicket: string buyTicket: string
@ -125,65 +101,6 @@ export interface LocaleMessages {
pending: string pending: string
registered: string registered: string
} }
purchase: {
card: string
continueTo: string
fiatRedirectHint: string
noCheckoutUrl: string
fiatFailed: string
loginRequiredTitle: string
loginRequiredFree: string
promo: {
label: string
placeholder: string
apply: string
remove: string
checking: string
applied: string
discountLine: string
invalid: string
unverified: string
loginRequiredDiscount: string
}
contact: {
title: string
name: string
namePlaceholder: string
nameRequired: string
email: string
emailHint: string
spamHint: string
emailInvalid: string
confirmEmail: string
emailMismatch: string
}
}
checkout: {
title: string
paidTitle: string
processing: string
stillProcessing: string
stillProcessingNoEmail: string
emailedTo: string
cancelled: string
viewTicket: string
viewMyTickets: string
retry: string
ticketN: string
close: string
}
ticket: {
title: string
loading: string
notFound: string
event: string
holder: string
paid: string
pending: string
registered: string
showAtDoor: string
backToEvent: string
}
nav: { nav: {
feed: string feed: string
calendar: string calendar: string
@ -259,7 +176,6 @@ export interface LocaleMessages {
title: string title: string
description: string description: string
selectAccount: string selectAccount: string
otherAccountHint: string
submitIncome: string submitIncome: string
notAvailable: string notAvailable: string
} }

View file

@ -1,36 +0,0 @@
import { describe, it, expect } from 'vitest'
import { monotonicCreatedAt } from './timestamp'
describe('monotonicCreatedAt', () => {
it('uses now when there is no prior version', () => {
expect(monotonicCreatedAt(null, 1000)).toBe(1000)
expect(monotonicCreatedAt(undefined, 1000)).toBe(1000)
})
it('bumps to prior+1 when republished in the same second', () => {
// now == last: a naive floor(Date.now()/1000) would tie and the relay
// would drop the update; we must produce a strictly newer stamp.
expect(monotonicCreatedAt(1000, 1000)).toBe(1001)
})
it('tracks wall-clock once enough real seconds have elapsed', () => {
expect(monotonicCreatedAt(1000, 1005)).toBe(1005)
})
it('steps past a future-dated prior (clock skew / rapid bursts)', () => {
expect(monotonicCreatedAt(2000, 1000)).toBe(2001)
})
it('is strictly increasing across a same-second burst', () => {
let last: number | null = null
const stamps: number[] = []
for (let i = 0; i < 5; i++) {
last = monotonicCreatedAt(last, 1000) // clock frozen at 1000
stamps.push(last)
}
expect(stamps).toEqual([1000, 1001, 1002, 1003, 1004])
for (let i = 1; i < stamps.length; i++) {
expect(stamps[i]).toBeGreaterThan(stamps[i - 1])
}
})
})

View file

@ -1,30 +0,0 @@
/**
* Monotonic `created_at` for replaceable / addressable Nostr events.
*
* Relays only push a replaceable update to OPEN subscriptions when its
* `created_at` is **strictly newer** than the version they already hold
* (verified against our relay). `created_at` is second-resolution, so a
* publisher that stamps `Math.floor(Date.now() / 1000)` can emit two
* versions within the same wall-clock second — the relay treats the
* second as not-newer and never propagates it to live subscribers (it
* only surfaces on a reload / fresh REQ). This is exactly the failure
* seen with rapid bookmark toggles.
*
* Returning `max(now, lastCreatedAt + 1)` guarantees a strictly
* increasing timestamp across successive publishes of the same
* replaceable event, so each version reaches open subscriptions. When
* enough real seconds have elapsed it tracks wall-clock; only same-second
* (or clock-skewed) republishes get nudged forward.
*
* @param lastCreatedAt `created_at` of the previously published version
* (seconds), or null/undefined if none has been published yet.
* @param now Current time in **seconds** — injectable for tests; defaults
* to `Math.floor(Date.now() / 1000)`.
*/
export function monotonicCreatedAt(
lastCreatedAt?: number | null,
now: number = Math.floor(Date.now() / 1000),
): number {
if (lastCreatedAt == null) return now
return Math.max(now, lastCreatedAt + 1)
}

View file

@ -77,16 +77,3 @@ export const catchAllRoute: RouteRecordRaw = {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
redirect: '/', redirect: '/',
} }
/**
* Where to send the user after a successful login. Honours a `?redirect=`
* query param so a page can bounce an anonymous user through /login and
* back (e.g. chatelet's Book button). Only same-origin absolute paths are
* accepted — anything else (external URLs, protocol-relative `//host`)
* falls back to home.
*/
export function postLoginTarget(redirect: unknown): string {
if (typeof redirect !== 'string') return '/'
if (!redirect.startsWith('/') || redirect.startsWith('//')) return '/'
return redirect
}

View file

@ -1,5 +1,14 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<div>
<h3 class="text-lg font-medium">Profile Settings</h3>
<p class="text-sm text-muted-foreground">
Manage your profile information and Nostr identity
</p>
</div>
<Separator />
<form @submit="onSubmit" class="space-y-6"> <form @submit="onSubmit" class="space-y-6">
<!-- Profile Picture --> <!-- Profile Picture -->
<FormField name="picture"> <FormField name="picture">
@ -8,38 +17,33 @@
<FormDescription> <FormDescription>
Upload a profile picture. This will be published to your Nostr profile. Upload a profile picture. This will be published to your Nostr profile.
</FormDescription> </FormDescription>
<!-- Stack preview + upload on narrow viewports so the upload <div class="flex items-center gap-4">
component's Gallery/Camera buttons don't push out of the
sheet/dialog. -->
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-4">
<!-- Current picture preview --> <!-- Current picture preview -->
<div v-if="currentPictureUrl" class="relative shrink-0"> <div v-if="currentPictureUrl" class="relative">
<img <img
:src="currentPictureUrl" :src="currentPictureUrl"
alt="Profile picture" alt="Profile picture"
class="h-20 w-20 rounded-full object-cover border-2 border-border" class="h-20 w-20 rounded-full object-cover border-2 border-border"
/> />
</div> </div>
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border shrink-0"> <div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border">
<User class="h-10 w-10 text-muted-foreground" /> <User class="h-10 w-10 text-muted-foreground" />
</div> </div>
<!-- Upload component. Avatars are small; tighten the <!-- Upload component. Avatars are small; tighten the
default compress knobs so a 4K phone photo lands as default compress knobs so a 4K phone photo lands as
a ~200 KB 512px WebP. --> a ~200 KB 512px WebP. -->
<div class="flex-1 min-w-0 w-full"> <ImageUpload
<ImageUpload v-model="uploadedPicture"
v-model="uploadedPicture" :multiple="false"
:multiple="false" :max-files="1"
:max-files="1" :max-size-mb="5"
:max-size-mb="5" :disabled="isUpdating"
:disabled="isUpdating" :allow-camera="true"
:allow-camera="true" :compress="{ maxWidthOrHeight: 512, maxSizeMB: 0.2 }"
:compress="{ maxWidthOrHeight: 512, maxSizeMB: 0.2 }" placeholder="Upload picture"
placeholder="Upload picture" accept="image/*"
accept="image/*" />
/>
</div>
</div> </div>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -87,6 +91,26 @@
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Lightning Address / NIP-05 (read-only info) -->
<div class="rounded-lg border p-4 space-y-2 bg-muted/50">
<h4 class="text-sm font-medium">Nostr Identity</h4>
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<Zap class="h-4 w-4 text-yellow-500" />
<span class="text-muted-foreground">Lightning Address:</span>
<code class="text-xs bg-background px-2 py-1 rounded">{{ lightningAddress }}</code>
</div>
<div class="flex items-center gap-2">
<Hash class="h-4 w-4 text-purple-500" />
<span class="text-muted-foreground">NIP-05:</span>
<code class="text-xs bg-background px-2 py-1 rounded">{{ nip05Preview }}</code>
</div>
</div>
<p class="text-xs text-muted-foreground mt-2">
These identifiers are automatically derived from your username
</p>
</div>
<!-- Error Display --> <!-- Error Display -->
<div v-if="updateError" class="text-sm text-destructive"> <div v-if="updateError" class="text-sm text-destructive">
{{ updateError }} {{ updateError }}
@ -111,6 +135,34 @@
Your profile is broadcast to Nostr automatically when you save changes. Your profile is broadcast to Nostr automatically when you save changes.
</p> </p>
</form> </form>
<Separator />
<div class="flex flex-col gap-2">
<AlertDialog>
<AlertDialogTrigger as-child>
<Button variant="destructive" class="w-full">
<LogOut class="mr-2 h-4 w-4" />
Log out
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Log out of {{ user?.username || 'your account' }}?</AlertDialogTitle>
<AlertDialogDescription>
You'll need to sign in again to access your wallet, post in the
forum, place orders, or use any feature that needs your account.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="onLogout" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Log out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div> </div>
</template> </template>
@ -121,7 +173,8 @@ import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod' import * as z from 'zod'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { User } from 'lucide-vue-next' import { Separator } from '@/components/ui/separator'
import { User, Zap, Hash } from 'lucide-vue-next'
import { import {
FormControl, FormControl,
FormDescription, FormDescription,
@ -131,13 +184,27 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import ImageUpload from './ImageUpload.vue' import ImageUpload from './ImageUpload.vue'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { LogOut } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useRouter } from 'vue-router'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '../services/ImageUploadService' import type { ImageUploadService } from '../services/ImageUploadService'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
// Services // Services
const { user, updateProfile } = useAuth() const { user, updateProfile, logout } = useAuth()
const router = useRouter()
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const toast = useToast() const toast = useToast()
@ -157,14 +224,14 @@ const lightningDomain = computed(() =>
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
) )
// Live preview of the user's NIP-05 / Lightning address — shown in the // Computed previews
// username field's helper text so the consequence of a future rename is
// visible inline.
const nip05Preview = computed(() => { const nip05Preview = computed(() => {
const username = form.values.username || currentUsername.value || 'username' const username = form.values.username || currentUsername.value || 'username'
return `${username}@${lightningDomain.value}` return `${username}@${lightningDomain.value}`
}) })
const lightningAddress = computed(() => nip05Preview.value)
// Form schema // Form schema
const profileFormSchema = toTypedSchema(z.object({ const profileFormSchema = toTypedSchema(z.object({
username: z.string() username: z.string()
@ -260,4 +327,17 @@ const updateUserProfile = async (formData: any) => {
isUpdating.value = false isUpdating.value = false
} }
} }
// Log out + redirect to /login on this app's origin.
const onLogout = async () => {
try {
await logout()
toast.success('Logged out')
router.push('/login')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to log out'
console.error('Error logging out:', error)
toast.error(`Logout failed: ${errorMessage}`)
}
}
</script> </script>

View file

@ -1,353 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { toast } from 'vue-sonner'
import { format } from 'date-fns'
import { Check, CheckCircle2, Copy, Loader2, TimerOff, Wallet, Zap } from 'lucide-vue-next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { useAuth } from '@/composables/useAuthService'
import { formatSats } from '@/lib/utils/formatting'
import { useBookingFlow } from '../composables/useBookingFlow'
import type { AvailabilityResult, Room } from '../types/room'
interface Props {
room: Room
/** The availability quote the guest just saw — dates + nights + price. */
availability: AvailabilityResult
isOpen: boolean
}
interface Emits {
(e: 'update:isOpen', value: boolean): void
/** Fired once when the invoice settles, before the dialog closes. */
(e: 'booked', bookingId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { currentUser } = useAuth()
const flow = useBookingFlow()
// --- Guest details form --------------------------------------------------
const formSchema = computed(() =>
toTypedSchema(
z.object({
num_guests: z.coerce
.number()
.int()
.min(1, 'At least one guest')
.max(props.room.max_guests, `This room sleeps up to ${props.room.max_guests}`),
guest_contact: z.string().max(200, 'Too long').optional(),
message: z.string().max(1000, 'Too long').optional(),
}),
),
)
const form = useForm({
validationSchema: formSchema,
initialValues: { num_guests: 1, guest_contact: '', message: '' },
})
const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => {
const pubkey = currentUser.value?.pubkey
if (!pubkey) {
toast.error('Sign in to book a room')
return
}
try {
await flow.request({
room_id: props.room.id,
guest_pubkey: pubkey,
check_in: props.availability.check_in,
check_out: props.availability.check_out,
num_guests: values.num_guests,
guest_contact: values.guest_contact?.trim() || undefined,
message: values.message?.trim() || undefined,
})
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Could not request the booking')
}
})
// --- Display helpers -----------------------------------------------------
function longDate(ymd: string): string {
return format(new Date(ymd + 'T00:00:00'), 'EEE, MMM d')
}
const stayLabel = computed(
() => `${longDate(props.availability.check_in)} → ${longDate(props.availability.check_out)}`,
)
const nightsLabel = computed(
() => `${props.availability.nights} night${props.availability.nights === 1 ? '' : 's'}`,
)
/** Hold expiry as a wall-clock time, so the guest knows how long the invoice lives. */
const holdExpiry = computed(() => {
const iso = flow.booking.value?.expires_at
if (!iso) return ''
const d = new Date(iso)
return isNaN(d.getTime()) ? '' : format(d, 'p')
})
const step = computed<'form' | 'invoice' | 'confirmed' | 'lost'>(() => {
if (flow.isPaid.value) return 'confirmed'
if (flow.lostStatus.value) return 'lost'
if (flow.quote.value) return 'invoice'
return 'form'
})
const lostLabel = computed(() =>
flow.lostStatus.value === 'expired'
? 'The hold on your dates expired before the invoice was paid.'
: 'The host released these dates before the invoice was paid.',
)
watch(
() => flow.isPaid.value,
(paid) => {
if (paid && flow.booking.value) emit('booked', flow.booking.value.id)
},
)
// --- Open / close --------------------------------------------------------
const confirmClose = ref(false)
function handleClose(open: boolean) {
if (open) return
// Closing mid-invoice abandons the hold; it expires on its own, but
// make the guest confirm so a stray tap doesn't lose the QR.
if (step.value === 'invoice' && !confirmClose.value) {
confirmClose.value = true
return
}
confirmClose.value = false
emit('update:isOpen', false)
}
watch(
() => props.isOpen,
(open) => {
if (open) {
flow.reset()
confirmClose.value = false
form.resetForm({ values: { num_guests: 1, guest_contact: '', message: '' } })
} else {
flow.reset()
}
},
)
</script>
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{{ room.title }}</DialogTitle>
<DialogDescription>
{{ stayLabel }} · {{ nightsLabel }}
</DialogDescription>
</DialogHeader>
<!-- Step 1: who's coming -->
<form v-if="step === 'form'" class="space-y-4" @submit="onSubmit">
<div class="rounded-lg bg-muted/50 p-3 text-sm flex items-baseline justify-between">
<span class="text-muted-foreground">Total</span>
<span class="font-semibold text-foreground">
<template v-if="availability.quote_sat != null">
{{ formatSats(availability.quote_sat) }}
</template>
<span
v-if="availability.quote_fiat != null"
class="text-muted-foreground font-normal"
>
(≈ {{ availability.quote_fiat }} {{ availability.currency }})
</span>
</span>
</div>
<FormField v-slot="{ componentField }" name="num_guests">
<FormItem>
<FormLabel>Guests</FormLabel>
<FormControl>
<Input type="number" min="1" :max="room.max_guests" v-bind="componentField" />
</FormControl>
<FormDescription>Sleeps up to {{ room.max_guests }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="guest_contact">
<FormItem>
<FormLabel>
Contact
<span class="text-muted-foreground font-normal">(optional)</span>
</FormLabel>
<FormControl>
<Input placeholder="Email or phone, in case the host needs to reach you" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="message">
<FormItem>
<FormLabel>
Note to the host
<span class="text-muted-foreground font-normal">(optional)</span>
</FormLabel>
<FormControl>
<Textarea rows="3" placeholder="Arrival time, questions…" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" class="w-full" :disabled="!isFormValid || flow.isRequesting.value">
<Loader2 v-if="flow.isRequesting.value" class="mr-2 h-4 w-4 animate-spin" />
{{ flow.isRequesting.value ? 'Holding your dates…' : 'Continue to payment' }}
</Button>
</form>
<!-- Step 2: pay the invoice (events / restaurant invoice-card pattern) -->
<div v-else-if="step === 'invoice'" class="py-2 space-y-4">
<div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay to confirm</h3>
<p class="text-sm text-muted-foreground">
<template v-if="flow.hasWalletWithBalance.value">
Scan with any Lightning wallet, or pay from your LNbits wallet below.
</template>
<template v-else>
Scan with any Lightning wallet, or open it in your wallet app.
</template>
</p>
</div>
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<!-- White background regardless of theme so phone cameras parse it. -->
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
<img
v-if="flow.qrCode.value"
:src="flow.qrCode.value"
alt="Lightning payment QR code"
class="block h-56 w-56 sm:h-64 sm:w-64"
/>
</div>
<div class="flex items-baseline justify-between">
<span class="text-xs text-muted-foreground">Amount</span>
<span class="font-mono text-sm font-semibold text-primary">
{{ formatSats(flow.booking.value?.deposit_sat ?? 0) }}
</span>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" class="flex-1 font-mono text-xs" @click="flow.copyInvoice">
<Check v-if="flow.copiedInvoice.value" class="mr-2 h-3.5 w-3.5" />
<Copy v-else class="mr-2 h-3.5 w-3.5" />
{{ flow.copiedInvoice.value ? 'Copied' : 'Copy' }}
</Button>
<Button variant="default" size="sm" class="flex-1 text-xs" @click="flow.openExternalWallet">
<Zap class="mr-2 h-3.5 w-3.5" />
Open in wallet
</Button>
</div>
</div>
<Button
v-if="flow.hasWalletWithBalance.value"
size="lg"
class="w-full"
:disabled="flow.isPayingWithWallet.value"
@click="flow.payWithWallet"
>
<Loader2 v-if="flow.isPayingWithWallet.value" class="mr-2 h-4 w-4 animate-spin" />
<Wallet v-else class="mr-2 h-4 w-4" />
{{ flow.isPayingWithWallet.value ? 'Paying…' : 'Pay from my LNbits wallet' }}
</Button>
<p
v-else-if="flow.userWallets.value.length > 0"
class="text-center text-xs text-muted-foreground"
>
Your LNbits wallet is empty — pay with an external wallet using the QR
or "Open in wallet" above.
</p>
<div v-if="flow.isPaymentPending.value" class="text-center space-y-1">
<div class="flex items-center justify-center gap-2">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
<span class="text-sm text-muted-foreground">Waiting for payment…</span>
</div>
<p class="text-xs text-muted-foreground">
Your dates are held<template v-if="holdExpiry"> until {{ holdExpiry }}</template>.
Confirmation lands automatically — no need to refresh.
</p>
</div>
<p v-if="confirmClose" class="text-center text-xs text-destructive">
Close without paying? The hold expires on its own. Tap outside again to close.
</p>
</div>
<!-- Hold lost before payment: nothing to pay any more -->
<div v-else-if="step === 'lost'" class="py-4 space-y-4 text-center">
<TimerOff class="mx-auto h-12 w-12 text-muted-foreground" />
<div class="space-y-1">
<h3 class="text-lg font-semibold">Dates released</h3>
<p class="text-sm text-muted-foreground">{{ lostLabel }}</p>
</div>
<p class="text-xs text-muted-foreground">
If you already paid, the host will see it — otherwise check the dates
again and start over.
</p>
<Button class="w-full" @click="emit('update:isOpen', false)">Close</Button>
</div>
<!-- Step 3: paid -->
<div v-else class="py-4 space-y-4 text-center">
<CheckCircle2 class="mx-auto h-12 w-12 text-primary" />
<div class="space-y-1">
<h3 class="text-lg font-semibold">You're booked</h3>
<p class="text-sm text-muted-foreground">
{{ stayLabel }} · {{ nightsLabel }}
</p>
</div>
<div class="rounded-lg bg-muted/50 p-3 text-sm space-y-1 text-left">
<div class="flex justify-between">
<span class="text-muted-foreground">Booking</span>
<span class="font-mono">{{ flow.booking.value?.id }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Paid</span>
<span class="font-semibold">{{ formatSats(flow.booking.value?.deposit_sat ?? 0) }}</span>
</div>
</div>
<p class="text-xs text-muted-foreground">
Check-in details arrive as a private Nostr message from the host.
</p>
<Button class="w-full" @click="emit('update:isOpen', false)">Done</Button>
</div>
</DialogContent>
</Dialog>
</template>

View file

@ -1,260 +0,0 @@
<script setup lang="ts">
import { computed, ref, shallowRef, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import {
CalendarDate,
getLocalTimeZone,
parseDate,
today,
type DateValue,
} from '@internationalized/date'
import type { DateRange as RekaDateRange } from 'reka-ui'
import { format } from 'date-fns'
import { X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { RangeCalendar } from '@/components/ui/range-calendar'
import { cn } from '@/lib/utils'
import { addDays, nextUnavailableOnOrAfter, nightsBetween, stayIsFree } from '../lib/unavailable'
/**
* Airbnb-style stay picker: one range calendar where occupied nights are
* struck through, check-out may land on the morning another guest arrives,
* and a stay can never straddle an occupied night.
*
* reka-ui only guards clicks with `isDateDisabled` and, with contiguous
* ranges, hides the selected styling on `isDateUnavailable` days — so the
* matchers below are selection-aware: once a check-in is chosen, the first
* occupied night after it stops being "unavailable" (it is a legal
* check-out) and every later day is disabled. Both matchers close over refs,
* because reka reads them once at setup.
*/
const props = defineProps<{
/** YYYY-MM-DD or '' */
checkIn: string
checkOut: string
/** Sorted occupied nights (YYYY-MM-DD) and the same as a Set. */
nights: string[]
nightSet: Set<string>
minNights: number
/** Last selectable day, YYYY-MM-DD (the loaded window end). */
maxDate?: string
}>()
const emit = defineEmits<{
'update:checkIn': [value: string]
'update:checkOut': [value: string]
}>()
const isWide = useMediaQuery('(min-width: 640px)')
const todayYmd = today(getLocalTimeZone()).toString()
function toYmd(d: DateValue | undefined): string {
return d ? new CalendarDate(d.year, d.month, d.day).toString() : ''
}
function fromYmd(ymd: string): CalendarDate | undefined {
if (!ymd) return undefined
try {
return parseDate(ymd)
} catch {
return undefined
}
}
// --- selection state -------------------------------------------------------
/** The in-progress check-in while the guest is still picking a check-out. */
const pendingStart = ref<string>('')
const bothSet = computed(() => Boolean(props.checkIn && props.checkOut))
const activeStart = computed(() => (bothSet.value ? '' : pendingStart.value || props.checkIn))
/** First occupied night after the chosen check-in: the last legal check-out. */
const boundary = computed(() =>
activeStart.value
? nextUnavailableOnOrAfter(props.nights, addDays(activeStart.value, 1))
: undefined,
)
// shallowRef: DateValue instances must not be made deeply reactive (their
// private fields break reka's typings and identity checks).
const model = shallowRef<RekaDateRange>({ start: fromYmd(props.checkIn), end: fromYmd(props.checkOut) })
watch(
() => [props.checkIn, props.checkOut] as const,
([ci, co]) => {
model.value = { start: fromYmd(ci), end: fromYmd(co) }
if (ci && co) pendingStart.value = ''
},
)
const placeholder = shallowRef<DateValue>(fromYmd(props.checkIn) ?? today(getLocalTimeZone()))
const minValue = today(getLocalTimeZone())
const maxValue = computed(() => (props.maxDate ? fromYmd(props.maxDate) : undefined))
// --- matchers (stable functions over reactive state) ------------------------
function isDateUnavailable(d: DateValue): boolean {
const y = toYmd(d)
if (!props.nightSet.has(y)) return false
// The boundary night is a legal check-out, and a committed check-out that
// sits on an occupied night must keep rendering as selected.
if (y === boundary.value || (bothSet.value && y === props.checkOut)) return false
return true
}
function isDateDisabled(d: DateValue): boolean {
const y = toYmd(d)
if (y < todayYmd) return true
const start = activeStart.value
if (!start) {
// Picking a check-in: occupied nights are out; everything else is fair.
return props.nightSet.has(y) && !(bothSet.value && y === props.checkOut)
}
if (y === start) return false
if (y > start) {
if (y < addDays(start, props.minNights)) return true
if (boundary.value && y > boundary.value) return true
return false
}
// Clicking before the chosen check-in restarts the range there.
return props.nightSet.has(y)
}
// --- events -----------------------------------------------------------------
function onStartValue(v: DateValue | undefined) {
pendingStart.value = toYmd(v)
}
function onModel(v: RekaDateRange | null) {
const ci = toYmd(v?.start)
const co = toYmd(v?.end)
if (ci && co) {
const ok =
stayIsFree(props.nightSet, ci, co) && nightsBetween(ci, co) >= props.minNights
if (!ok) {
// Defensive: reka let a bad range through — keep the check-in, redo the end.
pendingStart.value = ci
emit('update:checkIn', ci)
emit('update:checkOut', '')
return
}
pendingStart.value = ''
emit('update:checkIn', ci)
emit('update:checkOut', co)
return
}
if (ci && !co) {
pendingStart.value = ci
emit('update:checkIn', ci)
emit('update:checkOut', '')
return
}
pendingStart.value = ''
emit('update:checkIn', '')
emit('update:checkOut', '')
}
function clear() {
onModel(null)
}
function clearCheckOut() {
if (!props.checkIn) return
onModel({ start: fromYmd(props.checkIn), end: undefined })
}
// --- display ----------------------------------------------------------------
function pretty(ymd: string): string {
return ymd ? format(new Date(ymd + 'T00:00:00'), 'MMM d, yyyy') : ''
}
const nights = computed(() => (bothSet.value ? nightsBetween(props.checkIn, props.checkOut) : 0))
const title = computed(() => {
if (bothSet.value) return `${nights.value} night${nights.value === 1 ? '' : 's'}`
if (props.checkIn) return 'Select check-out date'
return 'Select check-in date'
})
const subtitle = computed(() => {
if (bothSet.value) return `${pretty(props.checkIn)} – ${pretty(props.checkOut)}`
if (props.checkIn) return `Minimum stay ${props.minNights} night${props.minNights === 1 ? '' : 's'}`
return 'Add your travel dates for exact pricing'
})
</script>
<template>
<div class="space-y-4">
<!-- Summary header + the two labelled date boxes -->
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-lg font-semibold text-foreground">{{ title }}</p>
<p class="text-sm text-muted-foreground">{{ subtitle }}</p>
</div>
<div class="grid grid-cols-2 rounded-lg border border-input text-left">
<div
:class="cn('px-3 py-2', !checkIn && 'ring-2 ring-ring rounded-l-lg')"
>
<p class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Check-in</p>
<div class="flex items-center justify-between gap-1">
<span :class="cn('text-sm', checkIn ? 'text-foreground' : 'text-muted-foreground')">
{{ checkIn ? pretty(checkIn) : 'Add date' }}
</span>
<button
v-if="checkIn"
type="button"
class="rounded-sm opacity-60 hover:opacity-100"
aria-label="Clear check-in"
@click="clear"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
</div>
<div
:class="cn('border-l border-input px-3 py-2', checkIn && !checkOut && 'ring-2 ring-ring rounded-r-lg')"
>
<p class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Check-out</p>
<div class="flex items-center justify-between gap-1">
<span :class="cn('text-sm', checkOut ? 'text-foreground' : 'text-muted-foreground')">
{{ checkOut ? pretty(checkOut) : 'Add date' }}
</span>
<button
v-if="checkOut"
type="button"
class="rounded-sm opacity-60 hover:opacity-100"
aria-label="Clear check-out"
@click="clearCheckOut"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
<RangeCalendar
:model-value="model"
v-model:placeholder="placeholder"
:number-of-months="isWide ? 2 : 1"
paged-navigation
:week-starts-on="1"
:min-value="minValue"
:max-value="maxValue"
:is-date-unavailable="isDateUnavailable"
:is-date-disabled="isDateDisabled"
class="mx-auto w-fit p-0"
@update:model-value="onModel"
@update:start-value="onStartValue"
/>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1.5">
<span class="inline-block h-3 w-3 rounded-sm bg-muted line-through opacity-70"></span>
Unavailable
</span>
<Button variant="ghost" size="sm" :disabled="!checkIn" @click="clear">Clear dates</Button>
</div>
</div>
</template>

View file

@ -1,150 +0,0 @@
import { computed, onUnmounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService'
import type { ChateletApiService } from '../services/ChateletApiService'
import { LOST_BOOKING_STATUSES, type BookingQuote, type BookingRequest, type BookingStatus } from '../types/booking'
const POLL_MS = 2000
/**
* Drives one booking from request → invoice → paid. Modeled on events'
* useTicketPurchase: the backend holds the dates and returns a bolt11;
* this composable renders it (QR / copy / open-in-wallet / pay from the
* LNbits wallet) and polls the booking until the extension's paid-invoice
* listener flips it to `confirmed` — or the hold lapses (`expired`).
*/
export function useBookingFlow() {
// DI, never a direct import (workspace rule).
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
const paymentService = tryInjectService<PaymentService>(SERVICE_TOKENS.PAYMENT_SERVICE)
const quote = ref<BookingQuote | null>(null)
const qrCode = ref<string | null>(null)
const isRequesting = ref(false)
const isPaymentPending = ref(false)
const isPaid = ref(false)
/** Set when the hold is gone (expired / declined / cancelled) before payment. */
const lostStatus = ref<BookingStatus | null>(null)
const copiedInvoice = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
const paymentRequest = computed(() => quote.value?.payment_request ?? '')
const booking = computed(() => quote.value?.booking ?? null)
const userWallets = computed(() => paymentService?.userWallets ?? [])
const hasWalletWithBalance = computed(() => paymentService?.hasWalletWithBalance ?? false)
const isPayingWithWallet = computed(() => paymentService?.isProcessingPayment.value ?? false)
/** Hold the dates and get the invoice; starts the settlement poll. */
async function request(data: BookingRequest): Promise<BookingQuote | null> {
if (!api) return null
isRequesting.value = true
try {
const q = await api.requestBooking(data)
quote.value = q
try {
qrCode.value = paymentService ? await paymentService.generateQRCode(q.payment_request) : null
} catch (err) {
console.error('Error generating QR code:', err)
}
startPolling(q.booking.id)
return q
} finally {
isRequesting.value = false
}
}
function startPolling(bookingId: string) {
stopPolling()
isPaymentPending.value = true
const check = async () => {
try {
const b = await api?.getPublicBooking(bookingId)
if (!b) return
if (quote.value) quote.value = { ...quote.value, booking: { ...quote.value.booking, ...b } }
if (b.status === 'confirmed' || b.status === 'checked_in' || b.status === 'completed') {
isPaid.value = true
stopPolling()
} else if (LOST_BOOKING_STATUSES.has(b.status)) {
lostStatus.value = b.status
stopPolling()
}
} catch (err) {
// Transient network errors just mean "try again next tick".
console.error('Error checking booking status:', err)
}
}
void check()
pollTimer = setInterval(check, POLL_MS)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
isPaymentPending.value = false
}
async function copyInvoice() {
if (!paymentRequest.value) return
try {
await navigator.clipboard.writeText(paymentRequest.value)
copiedInvoice.value = true
setTimeout(() => (copiedInvoice.value = false), 1500)
} catch {
// Insecure context / old browser — "Open in wallet" still works.
}
}
function openExternalWallet() {
if (paymentRequest.value) paymentService?.openExternalWallet(paymentRequest.value)
}
/** Pay from the logged-in user's LNbits wallet; the poll picks up settlement. */
async function payWithWallet() {
if (!paymentService || !paymentRequest.value) return
try {
await paymentService.payWithWallet(paymentRequest.value, booking.value?.deposit_sat, {
showToast: false,
})
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Wallet payment failed')
}
}
function reset() {
stopPolling()
quote.value = null
qrCode.value = null
isPaid.value = false
lostStatus.value = null
copiedInvoice.value = false
}
onUnmounted(stopPolling)
return {
quote,
booking,
paymentRequest,
qrCode,
isRequesting,
isPaymentPending,
isPaid,
lostStatus,
copiedInvoice,
userWallets,
hasWalletWithBalance,
isPayingWithWallet,
request,
copyInvoice,
openExternalWallet,
payWithWallet,
reset,
}
}

View file

@ -1,38 +0,0 @@
import { storeToRefs } from 'pinia'
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
import type { ChateletApiService } from '../services/ChateletApiService'
import type { AvailabilityResult, Room } from '../types/room'
import { useChateletStore } from '../stores/chatelet'
export function useChatelet() {
const store = useChateletStore()
const { rooms, isLoading } = storeToRefs(store)
// DI, never a direct import (workspace rule).
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
async function loadRooms(): Promise<void> {
if (!api) return
isLoading.value = true
try {
store.rooms = await api.getPublicRooms()
} finally {
isLoading.value = false
}
}
async function getRoom(id: string): Promise<Room | null> {
if (!api) return null
return api.getPublicRoom(id)
}
async function checkAvailability(
roomId: string,
checkIn: string,
checkOut: string,
): Promise<AvailabilityResult | null> {
if (!api) return null
return api.checkAvailability(roomId, checkIn, checkOut)
}
return { rooms, isLoading, loadRooms, getRoom, checkAvailability }
}

View file

@ -1,48 +0,0 @@
import { computed, ref, watch, type Ref } from 'vue'
import { getLocalTimeZone, today } from '@internationalized/date'
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
import type { ChateletApiService } from '../services/ChateletApiService'
import { addDays, expandRanges } from '../lib/unavailable'
const WINDOW_DAYS = 365
/**
* The occupied nights of one room for the calendar. One fetch of a 12-month
* window per room — a few spans, cheap — and the calendar's `maxValue` is
* pinned to the window end, so no refetch on month navigation. `refresh()`
* after a booking lands, or when a quote comes back "not available" for a
* stay the local set thought free (someone else's hold appeared).
*/
export function useRoomUnavailability(roomId: Ref<string | undefined>) {
// DI, never a direct import (workspace rule).
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
const nights = ref<string[]>([])
const windowStart = ref(today(getLocalTimeZone()).toString())
const windowEnd = ref(addDays(windowStart.value, WINDOW_DAYS))
const isLoading = ref(false)
const error = ref<string | null>(null)
const nightSet = computed(() => new Set(nights.value))
async function refresh(): Promise<void> {
if (!api || !roomId.value) return
isLoading.value = true
error.value = null
try {
const start = today(getLocalTimeZone()).toString()
const res = await api.getUnavailableRanges(roomId.value, start, addDays(start, WINDOW_DAYS))
windowStart.value = res.start
windowEnd.value = res.end
nights.value = expandRanges(res.ranges)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Could not load availability'
} finally {
isLoading.value = false
}
}
watch(roomId, () => void refresh(), { immediate: true })
return { nights, nightSet, windowStart, windowEnd, isLoading, error, refresh }
}

View file

@ -1,55 +0,0 @@
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
import { SERVICE_TOKENS } from '@/core/di-container'
import { ChateletApiService, type ChateletApiConfig } from './services/ChateletApiService'
export interface ChateletModuleConfig {
apiConfig: ChateletApiConfig
}
/**
* Chatelet module — guest booking UI for the LNbits `chatelet` room-rentals
* extension: discovery, availability and Lightning-paid booking over HTTP.
*/
export const chateletModule = createModulePlugin({
name: 'chatelet',
version: '1.0.0',
dependencies: ['base'],
routes: [
{
path: '/chatelet',
name: 'chatelet',
component: () => import('./views/ChateletPage.vue'),
meta: {
title: (import.meta.env.VITE_APP_NAME as string) || 'Chatelet',
requiresAuth: false,
},
},
{
path: '/chatelet/:id',
name: 'chatelet-detail',
component: () => import('./views/ChateletDetailPage.vue'),
meta: { title: 'Room', requiresAuth: false },
},
],
onInstall: async (_app, options) => {
const config = options?.config as ChateletModuleConfig | undefined
if (!config) {
throw new Error('Chatelet module requires configuration')
}
const { container } = await import('@/core/di-container')
const api = new ChateletApiService(config.apiConfig)
container.provide(SERVICE_TOKENS.CHATELET_API, api)
},
onUninstall: async () => {
const { container } = await import('@/core/di-container')
container.remove(SERVICE_TOKENS.CHATELET_API)
},
})
export default chateletModule
export type { Room, AvailabilityResult } from './types/room'
export type { Booking, BookingQuote, BookingRequest, BookingStatus } from './types/booking'

View file

@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
addDays,
expandRanges,
nextUnavailableOnOrAfter,
nightsBetween,
stayIsFree,
} from './unavailable'
describe('unavailable-night helpers', () => {
it('addDays crosses month and year boundaries', () => {
expect(addDays('2026-10-31', 1)).toBe('2026-11-01')
expect(addDays('2026-12-31', 1)).toBe('2027-01-01')
expect(addDays('2026-03-01', -1)).toBe('2026-02-28')
})
it('nightsBetween is check_out-exclusive', () => {
expect(nightsBetween('2026-10-05', '2026-10-07')).toBe(2)
expect(nightsBetween('2026-10-05', '2026-10-05')).toBe(0)
})
it('expandRanges yields sorted, de-duplicated occupied nights (end exclusive)', () => {
const nights = expandRanges([
{ start: '2026-10-10', end: '2026-10-12' },
{ start: '2026-10-05', end: '2026-10-07' },
{ start: '2026-10-11', end: '2026-10-13' },
])
expect(nights).toEqual(['2026-10-05', '2026-10-06', '2026-10-10', '2026-10-11', '2026-10-12'])
})
it('nextUnavailableOnOrAfter finds the boundary night', () => {
const nights = ['2026-10-05', '2026-10-06', '2026-10-10']
expect(nextUnavailableOnOrAfter(nights, '2026-10-01')).toBe('2026-10-05')
expect(nextUnavailableOnOrAfter(nights, '2026-10-05')).toBe('2026-10-05')
expect(nextUnavailableOnOrAfter(nights, '2026-10-07')).toBe('2026-10-10')
expect(nextUnavailableOnOrAfter(nights, '2026-10-11')).toBeUndefined()
expect(nextUnavailableOnOrAfter([], '2026-10-11')).toBeUndefined()
})
it('stayIsFree honours half-open semantics', () => {
const set = new Set(['2026-10-05', '2026-10-06'])
// checking out the morning the other guest checks in is fine
expect(stayIsFree(set, '2026-10-03', '2026-10-05')).toBe(true)
// checking in the morning they leave is fine too
expect(stayIsFree(set, '2026-10-07', '2026-10-09')).toBe(true)
// spanning an occupied night is not
expect(stayIsFree(set, '2026-10-04', '2026-10-06')).toBe(false)
expect(stayIsFree(set, '2026-10-06', '2026-10-06')).toBe(false)
})
})

View file

@ -1,49 +0,0 @@
import type { DateRange } from '../types/room'
/** YYYY-MM-DD → YYYY-MM-DD, `days` later. Date-only math, no timezone. */
export function addDays(ymd: string, days: number): string {
const d = new Date(ymd + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
/** Nights between two YYYY-MM-DD dates (check_out exclusive). */
export function nightsBetween(checkIn: string, checkOut: string): number {
const a = Date.UTC(+checkIn.slice(0, 4), +checkIn.slice(5, 7) - 1, +checkIn.slice(8, 10))
const b = Date.UTC(+checkOut.slice(0, 4), +checkOut.slice(5, 7) - 1, +checkOut.slice(8, 10))
return Math.round((b - a) / 86_400_000)
}
/**
* Expand half-open [start, end) spans into the sorted, de-duplicated list of
* occupied nights. A year of a busy room is a few hundred strings — cheap to
* hold and to binary-search.
*/
export function expandRanges(ranges: DateRange[]): string[] {
const out = new Set<string>()
for (const r of ranges) {
for (let d = r.start; d < r.end; d = addDays(d, 1)) out.add(d)
}
return [...out].sort()
}
/** First occupied night ≥ `ymd`, or undefined if none in the loaded window. */
export function nextUnavailableOnOrAfter(nights: string[], ymd: string): string | undefined {
let lo = 0
let hi = nights.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (nights[mid] < ymd) lo = mid + 1
else hi = mid
}
return nights[lo]
}
/** True when every night of [checkIn, checkOut) is free. */
export function stayIsFree(nightSet: Set<string>, checkIn: string, checkOut: string): boolean {
if (!(checkIn < checkOut)) return false
for (let d = checkIn; d < checkOut; d = addDays(d, 1)) {
if (nightSet.has(d)) return false
}
return true
}

View file

@ -1,112 +0,0 @@
import type { AvailabilityResult, Room, UnavailableRanges } from '../types/room'
import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking'
export interface ChateletApiConfig {
baseUrl: string
apiKey: string
}
/**
* HTTP client for the LNbits `chatelet` extension's guest surface.
*
* Plain config-wrapper (same pattern as events' TicketApiService) — not a
* BaseService. Guest browsing, availability, the booking request and the
* booking read-back are all keyless public endpoints.
*
* Transport note: this is HTTP-for-now. The extension also exposes a
* kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but
* the webapp's RPC client is disabled post-lnbits#9 (see aiolabs/lnbits#64).
* When that's revived, only this class swaps HTTP→RPC — no view changes.
*/
export class ChateletApiService {
constructor(private config: ChateletApiConfig) {}
/** Active rooms for guest browsing (operator-private fields stripped server-side). */
async getPublicRooms(): Promise<Room[]> {
return this.request('/chatelet/api/v1/public/rooms', { method: 'GET' })
}
async getPublicRoom(id: string): Promise<Room> {
return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' })
}
/**
* Nights a guest cannot book over a window (default today → +365 d): live
* holds, confirmed stays and manual blocks merged into anonymous spans.
* Keyless, like the room read it hangs off.
*/
async getUnavailableRanges(
roomId: string,
start?: string,
end?: string,
): Promise<UnavailableRanges> {
const q = new URLSearchParams()
if (start) q.set('start', start)
if (end) q.set('end', end)
const qs = q.toString()
return this.request(
`/chatelet/api/v1/public/rooms/${roomId}/unavailable${qs ? `?${qs}` : ''}`,
{ method: 'GET' },
)
}
async checkAvailability(
roomId: string,
checkIn: string,
checkOut: string,
): Promise<AvailabilityResult> {
return this.request('/chatelet/api/v1/availability', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ room_id: roomId, check_in: checkIn, check_out: checkOut }),
})
}
/**
* Hold the dates and get the invoice that confirms them. The backend
* quotes the amount (canonical `booking.deposit_sat`) — the guest never
* recomputes what they owe. 502 = invoice creation failed server-side.
*/
async requestBooking(data: BookingRequest): Promise<BookingQuote> {
return this.request('/chatelet/api/v1/bookings', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data),
})
}
/**
* Keyless read-back of the guest's own booking — what the invoice screen
* polls. `confirmed` means the extension's paid-invoice listener has
* run; `expired` / `declined` mean the hold is gone.
*/
async getPublicBooking(bookingId: string): Promise<PublicBooking> {
return this.request(`/chatelet/api/v1/public/bookings/${bookingId}`, { method: 'GET' })
}
private async request(path: string, init: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = {
accept: 'application/json',
// Guest endpoints are unauthenticated; only send a key if one is set.
...(this.config.apiKey ? { 'X-API-KEY': this.config.apiKey } : {}),
...((init.headers as Record<string, string>) ?? {}),
}
const response = await fetch(`${this.config.baseUrl}${path}`, { ...init, headers })
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: `Request failed: ${path}` }))
const message =
typeof error.detail === 'string'
? error.detail
: Array.isArray(error.detail)
? (error.detail[0]?.msg ?? 'Request failed')
: 'Request failed'
throw new Error(message)
}
return response.json()
}
}

View file

@ -1,9 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Room } from '../types/room'
export const useChateletStore = defineStore('chatelet', () => {
const rooms = ref<Room[]>([])
const isLoading = ref(false)
return { rooms, isLoading }
})

View file

@ -1,78 +0,0 @@
/**
* Booking wire types — mirror `models.py` in the LNbits `chatelet`
* extension (BookingRequestData / Booking / BookingQuote). Field names are
* kept as the backend spells them so the JSON maps 1:1.
*/
export type BookingStatus =
| 'held'
| 'awaiting_payment'
| 'confirmed'
| 'declined'
| 'cancelled'
| 'expired'
| (string & {})
/** What a guest sends to `POST /chatelet/api/v1/bookings`. */
export interface BookingRequest {
room_id: string
/** Hex pubkey of the guest — the identity the check-in DM goes to. */
guest_pubkey: string
/** YYYY-MM-DD, inclusive. */
check_in: string
/** YYYY-MM-DD, exclusive. */
check_out: string
num_guests: number
guest_contact?: string
message?: string
}
export interface Booking {
id: string
room_id: string
guest_pubkey: string
guest_contact: string | null
check_in: string
check_out: string
nights: number
num_guests: number
/** Snapshot of the room's price currency. */
currency: string
/** nights × room price, display only. */
price_fiat: number
/** Canonical total due, in sats. */
amount_sat: number
/** Portion invoiced now (== amount_sat at 100 % prepay). */
deposit_sat: number
status: BookingStatus
payment_hash: string | null
/** Hold expiry while held / awaiting_payment; null once confirmed. */
expires_at: string | null
created_at: string
updated_at: string
}
/** Response to a booking request: the held booking + the bolt11 that confirms it. */
export interface BookingQuote {
booking: Booking
payment_request: string
payment_hash: string
}
/**
* Keyless read-back of the guest's own booking
* (`GET /chatelet/api/v1/public/bookings/{id}`, chatelet ≥ v0.4.0): the
* Booking minus the guest's pubkey/contact and the Lightning/Nostr plumbing.
* The booking id from the quote is the capability.
*/
export type PublicBooking = Omit<
Booking,
'guest_pubkey' | 'guest_contact' | 'payment_hash'
>
/** Statuses that mean the hold is gone and the invoice will never confirm. */
export const LOST_BOOKING_STATUSES: ReadonlySet<string> = new Set([
'expired',
'declined',
'cancelled',
])

View file

@ -1,47 +0,0 @@
// Public shapes returned by the chatelet extension's guest endpoints.
// Note: operator-private fields (wallet, checkin_instructions) are stripped
// server-side by public_room_dict and never reach the client.
export interface Room {
id: string
title: string
description: string
price_amount: number
price_currency: string
price_frequency: string
max_guests: number
min_nights: number
amenities: string[]
location: string
geohash: string
images: string[]
status: string
listing_event_id: string | null
created_at: string
updated_at: string
}
export interface AvailabilityResult {
room_id: string
check_in: string
check_out: string
available: boolean
nights: number
quote_sat: number | null
quote_fiat: number | null
currency: string | null
}
/** Half-open [start, end) span of occupied nights; `end` is the morning the room frees up. */
export interface DateRange {
start: string
end: string
}
/** `GET /chatelet/api/v1/public/rooms/{id}/unavailable` — the guest calendar feed. */
export interface UnavailableRanges {
room_id: string
start: string
end: string
ranges: DateRange[]
}

View file

@ -1,213 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { watchDebounced } from '@vueuse/core'
import { MapPin, Users } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatSats } from '@/lib/utils/formatting'
import { useAuth } from '@/composables/useAuthService'
import { useChatelet } from '../composables/useChatelet'
import { useRoomUnavailability } from '../composables/useRoomUnavailability'
import { stayIsFree } from '../lib/unavailable'
import BookRoomDialog from '../components/BookRoomDialog.vue'
import StayDatePicker from '../components/StayDatePicker.vue'
import type { AvailabilityResult, Room } from '../types/room'
const route = useRoute()
const router = useRouter()
const { isAuthenticated } = useAuth()
const { getRoom, checkAvailability } = useChatelet()
const room = ref<Room | null>(null)
const loading = ref(true)
const checkIn = ref('')
const checkOut = ref('')
const checking = ref(false)
const result = ref<AvailabilityResult | null>(null)
const showBooking = ref(false)
/** Quote snapshot the dialog books against — survives the page's own
* `result` being cleared once the dates are taken. */
const bookingQuote = ref<AvailabilityResult | null>(null)
// Occupied nights for the calendar; refreshed after a booking and when a
// quote contradicts the local set (someone else's hold appeared).
const roomIdRef = computed(() => route.params.id as string | undefined)
const unavailability = useRoomUnavailability(roomIdRef)
// Any date change invalidates the last quote; a complete stay re-quotes
// itself (the availability call is the price preview, so no button).
watch([checkIn, checkOut], () => {
result.value = null
})
watchDebounced(
[checkIn, checkOut],
([ci, co]) => {
if (ci && co && room.value) void onCheck()
},
{ debounce: 150 },
)
const YMD = /^\d{4}-\d{2}-\d{2}$/
onMounted(async () => {
try {
room.value = await getRoom(route.params.id as string)
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Could not load room')
} finally {
loading.value = false
}
// Dates survive a trip through /login (see onBook) as query params.
const qi = route.query.check_in
const qo = route.query.check_out
if (typeof qi === 'string' && YMD.test(qi) && typeof qo === 'string' && YMD.test(qo)) {
checkIn.value = qi
checkOut.value = qo
}
})
async function onCheck() {
if (!room.value || !checkIn.value || !checkOut.value) return
checking.value = true
result.value = null
try {
const ci = checkIn.value
const co = checkOut.value
const r = await checkAvailability(room.value.id, ci, co)
// Only the quote for the dates still selected counts (debounce race).
if (ci !== checkIn.value || co !== checkOut.value) return
result.value = r
if (r && !r.available && stayIsFree(unavailability.nightSet.value, ci, co)) {
// The calendar thought these nights were free — it is stale.
void unavailability.refresh()
}
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Availability check failed')
} finally {
checking.value = false
}
}
/**
* Booking needs a Nostr identity (the extension DMs check-in details to
* the guest pubkey), so anonymous guests bounce through /login and come
* back here with their dates intact. Guest checkout is a later slice.
*/
function onBook() {
if (!result.value?.available) return
if (!isAuthenticated.value) {
const back = router.resolve({
path: route.path,
query: { check_in: checkIn.value, check_out: checkOut.value },
}).fullPath
router.push({ path: '/login', query: { redirect: back } })
return
}
bookingQuote.value = result.value
showBooking.value = true
}
/** The dates are taken now — drop the stale quote and grey them out. */
function onBooked() {
result.value = null
checkIn.value = ''
checkOut.value = ''
void unavailability.refresh()
}
watch(showBooking, (open) => {
if (!open) bookingQuote.value = null
})
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-2xl">
<div v-if="loading" class="text-muted-foreground py-12">Loading…</div>
<div v-else-if="!room" class="text-muted-foreground py-12">Room not found.</div>
<div v-else class="space-y-4">
<img
v-if="room.images?.length"
:src="room.images[0]"
:alt="room.title"
class="w-full h-56 object-cover rounded-lg"
/>
<div>
<h1 class="text-2xl font-bold text-foreground">{{ room.title }}</h1>
<p
v-if="room.location"
class="text-muted-foreground flex items-center gap-1 mt-1"
>
<MapPin class="h-4 w-4" /> {{ room.location }}
</p>
<p class="text-muted-foreground flex items-center gap-1">
<Users class="h-4 w-4" /> up to {{ room.max_guests }} guests · min
{{ room.min_nights }} night(s)
</p>
<p class="text-lg font-semibold text-foreground mt-2">
{{ room.price_amount }} {{ room.price_currency }} / {{ room.price_frequency }}
</p>
</div>
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1">
<Badge v-for="a in room.amenities" :key="a" variant="outline">{{ a }}</Badge>
</div>
<p v-if="room.description" class="text-foreground whitespace-pre-line">
{{ room.description }}
</p>
<Card>
<CardContent class="p-4 space-y-4">
<StayDatePicker
v-model:check-in="checkIn"
v-model:check-out="checkOut"
:nights="unavailability.nights.value"
:night-set="unavailability.nightSet.value"
:min-nights="room.min_nights"
:max-date="unavailability.windowEnd.value"
/>
<p v-if="unavailability.error.value" class="text-xs text-muted-foreground">
Could not load the availability calendar — you can still pick dates and check them.
</p>
<div v-if="checking" class="flex items-center gap-2 text-sm text-muted-foreground">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
Checking availability…
</div>
<div v-if="result && !checking" class="text-sm">
<template v-if="result.available">
<div class="rounded-lg bg-muted/50 p-3 space-y-1">
<div class="flex justify-between text-muted-foreground">
<span>{{ room.price_amount }} {{ room.price_currency }} × {{ result.nights }} night{{ result.nights === 1 ? '' : 's' }}</span>
<span v-if="result.quote_fiat != null">{{ result.quote_fiat }} {{ result.currency }}</span>
</div>
<div class="flex justify-between font-semibold text-foreground">
<span>Total</span>
<span v-if="result.quote_sat != null">{{ formatSats(result.quote_sat) }}</span>
</div>
</div>
<Button class="w-full mt-3" @click="onBook">
{{ isAuthenticated ? 'Book' : 'Sign in to book' }}
</Button>
</template>
<p v-else class="text-destructive">Not available for those dates.</p>
</div>
</CardContent>
</Card>
</div>
<BookRoomDialog
v-if="room && bookingQuote"
:room="room"
:availability="bookingQuote"
v-model:is-open="showBooking"
@booked="onBooked"
/>
</div>
</template>

View file

@ -1,80 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { MapPin, Users } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useChatelet } from '../composables/useChatelet'
import type { Room } from '../types/room'
const router = useRouter()
const { rooms, isLoading, loadRooms } = useChatelet()
onMounted(loadRooms)
function open(id: string) {
router.push(`/chatelet/${id}`)
}
function priceLabel(r: Room): string {
return `${r.price_amount} ${r.price_currency} / ${r.price_frequency}`
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-4xl">
<h1 class="text-2xl font-bold text-foreground mb-6">Rooms</h1>
<div v-if="isLoading" class="grid gap-4 sm:grid-cols-2">
<Skeleton v-for="n in 4" :key="n" class="h-64 w-full rounded-lg" />
</div>
<div v-else-if="rooms.length === 0" class="text-muted-foreground text-center py-16">
No rooms available right now.
</div>
<div v-else class="grid gap-4 sm:grid-cols-2">
<Card
v-for="room in rooms"
:key="room.id"
class="cursor-pointer overflow-hidden hover:border-ring transition-colors"
@click="open(room.id)"
>
<img
v-if="room.images?.length"
:src="room.images[0]"
:alt="room.title"
class="h-40 w-full object-cover"
loading="lazy"
/>
<div
v-else
class="h-40 w-full bg-muted flex items-center justify-center text-muted-foreground text-sm"
>
No image
</div>
<CardContent class="p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="font-semibold text-foreground">{{ room.title }}</h3>
<Badge variant="secondary" class="shrink-0">{{ priceLabel(room) }}</Badge>
</div>
<p
v-if="room.location"
class="text-sm text-muted-foreground mt-1 flex items-center gap-1"
>
<MapPin class="h-3.5 w-3.5" /> {{ room.location }}
</p>
<p class="text-sm text-muted-foreground mt-1 flex items-center gap-1">
<Users class="h-3.5 w-3.5" /> up to {{ room.max_guests }} guests
</p>
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1 mt-2">
<Badge v-for="a in room.amenities.slice(0, 4)" :key="a" variant="outline">
{{ a }}
</Badge>
</div>
</CardContent>
</Card>
</div>
</div>
</template>

View file

@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Heart } from 'lucide-vue-next' import { Heart } from 'lucide-vue-next'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useBookmarks } from '../composables/useBookmarks' import { useBookmarks } from '../composables/useBookmarks'
import { useEventLikes } from '../composables/useEventLikes'
import { NIP52_KINDS } from '../types/nip52' import { NIP52_KINDS } from '../types/nip52'
const props = defineProps<{ const props = defineProps<{
@ -16,56 +15,13 @@ const props = defineProps<{
}>() }>()
const router = useRouter() const router = useRouter()
const { isAuthenticated, user } = useAuth() const { isAuthenticated } = useAuth()
const { isBookmarked, toggleBookmark } = useBookmarks() const { isBookmarked, toggleBookmark } = useBookmarks()
const { track, likeCount, setSelf } = useEventLikes()
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT) const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const coord = computed(() => `${eventKind.value}:${props.pubkey}:${props.dTag}`)
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag)) const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
// Live count of how many people have favorited (liked) this event. function handleToggle() {
const count = computed(() => likeCount(coord.value))
// Register this event so its like count is fetched + kept live.
// `ready` gates the live-increment pop so the historical backlog that
// streams in right after mount doesn't make every heart pop on load.
const ready = ref(false)
onMounted(() => {
track(coord.value)
setTimeout(() => (ready.value = true), 1500)
})
// Keep the current user's own contribution in sync with the optimistic
// heart state — instant like/un-like for self, and rollback-safe.
watch(
bookmarked,
(now) => {
const pk = user.value?.pubkey
if (pk) setSelf(coord.value, pk, now)
},
{ immediate: true },
)
// Brief scale "pop" for tactile feedback.
const popping = ref(false)
function pop() {
popping.value = true
setTimeout(() => (popping.value = false), 220)
}
// Pop on the user's own favorite (optimistic, fires immediately on tap).
watch(bookmarked, (now, was) => {
if (now && !was) pop()
})
// Pop when the live count ticks up from someone else liking it too —
// only once past the initial historical-load settle window.
watch(count, (now, was) => {
if (ready.value && now > was) pop()
})
async function handleToggle() {
if (!isAuthenticated.value) { if (!isAuthenticated.value) {
toast.info('Log in to save favorites', { toast.info('Log in to save favorites', {
action: { action: {
@ -75,26 +31,18 @@ async function handleToggle() {
}) })
return return
} }
const ok = await toggleBookmark(eventKind.value, props.pubkey, props.dTag) toggleBookmark(eventKind.value, props.pubkey, props.dTag)
if (!ok) {
toast.error("Couldn't save favorite — please try again")
}
} }
</script> </script>
<template> <template>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
class="h-8 gap-1 px-2" class="h-8 w-8"
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'" :class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
:aria-label="bookmarked ? 'Remove favorite' : 'Add favorite'"
@click.stop="handleToggle" @click.stop="handleToggle"
> >
<Heart <Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
class="w-4 h-4 transition-transform duration-200 ease-out"
:class="[{ 'fill-current': bookmarked }, popping ? 'scale-125' : 'scale-100']"
/>
<span v-if="count > 0" class="text-xs font-medium tabular-nums">{{ count }}</span>
</Button> </Button>
</template> </template>

View file

@ -1,182 +0,0 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Ticket, Loader2, Clock } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { renderTicketQr } from '../lib/ticketQr'
import { clearPendingCheckout } from '../lib/pendingCheckout'
/**
* Shown when the buyer lands back on the event page from the fiat
* provider with `?checkout=success&tickets=…`. Polls the anonymous
* ticket endpoint until every row is paid (Stripe waits for our
* webhook before redirecting, so the first poll usually already
* says paid), then renders one scannable QR per ticket.
*/
const props = defineProps<{
isOpen: boolean
eventId: string
ticketIds: string[]
email?: string
}>()
const emit = defineEmits<{ (e: 'update:isOpen', value: boolean): void }>()
const { t } = useI18n()
const { isAuthenticated } = useAuth()
const { refresh: refreshOwnedTickets } = useOwnedTickets()
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const POLL_MS = 2000
const TIMEOUT_MS = 60_000
type Phase = 'checking' | 'paid' | 'timeout'
const phase = ref<Phase>('checking')
const qrCodes = ref<Record<string, string>>({})
let timer: number | null = null
let startedAt = 0
const paidCount = ref(0)
async function checkOnce(): Promise<boolean> {
const results = await Promise.all(
props.ticketIds.map((id) => ticketApi.getTicket(id).then((t) => t.paid).catch(() => false)),
)
paidCount.value = results.filter(Boolean).length
return results.length > 0 && results.every(Boolean)
}
async function onPaid() {
phase.value = 'paid'
stop()
clearPendingCheckout()
if (isAuthenticated.value) void refreshOwnedTickets()
const map: Record<string, string> = {}
for (const id of props.ticketIds) {
try {
map[id] = await renderTicketQr(id, { width: 200 })
} catch {
// leave the slot empty; the ticket page link still works
}
}
qrCodes.value = map
}
async function tick() {
if (phase.value !== 'checking') return
try {
if (await checkOnce()) {
await onPaid()
return
}
} catch {
// transient network error — keep polling until the timeout
}
if (Date.now() - startedAt > TIMEOUT_MS) {
phase.value = 'timeout'
stop()
}
}
function start() {
stop()
phase.value = 'checking'
qrCodes.value = {}
startedAt = Date.now()
void tick()
timer = window.setInterval(() => void tick(), POLL_MS)
}
function stop() {
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
watch(
() => [props.isOpen, props.ticketIds.join(',')] as const,
([open]) => {
if (open && props.ticketIds.length > 0) start()
else stop()
},
{ immediate: true },
)
onUnmounted(stop)
function close() {
stop()
emit('update:isOpen', false)
}
const title = computed(() =>
phase.value === 'paid' ? t('events.checkout.paidTitle') : t('events.checkout.title'),
)
</script>
<template>
<Dialog :open="isOpen" @update:open="close">
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Ticket class="w-5 h-5" />
{{ title }}
</DialogTitle>
<DialogDescription v-if="phase === 'paid' && email">
{{ t('events.checkout.emailedTo', { email }) }}
</DialogDescription>
</DialogHeader>
<div v-if="phase === 'checking'" class="py-8 flex flex-col items-center gap-3 text-center">
<Loader2 class="w-8 h-8 animate-spin text-primary" />
<p class="text-sm text-muted-foreground">{{ t('events.checkout.processing') }}</p>
<p v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
{{ paidCount }} / {{ ticketIds.length }}
</p>
</div>
<div v-else-if="phase === 'timeout'" class="py-6 flex flex-col items-center gap-3 text-center">
<Clock class="w-10 h-10 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
{{ email
? t('events.checkout.stillProcessing', { email })
: t('events.checkout.stillProcessingNoEmail') }}
</p>
<Button variant="outline" @click="start">{{ t('events.checkout.retry') }}</Button>
</div>
<div v-else class="py-4 space-y-3">
<div
v-for="(id, idx) in ticketIds"
:key="id"
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
>
<span v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
{{ t('events.checkout.ticketN', { n: idx + 1, total: ticketIds.length }) }}
</span>
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
<img v-if="qrCodes[id]" :src="qrCodes[id]" :alt="`Ticket ${id}`" class="block h-48 w-48" />
<div v-else class="h-48 w-48 flex items-center justify-center">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
{{ t('events.checkout.viewTicket') }}
</RouterLink>
</div>
<p class="text-xs text-muted-foreground text-center">{{ t('events.ticket.showAtDoor') }}</p>
<div class="space-y-2 pt-1">
<Button v-if="isAuthenticated" class="w-full" @click="() => { close(); $router.push('/my-tickets') }">
{{ t('events.checkout.viewMyTickets') }}
</Button>
<Button variant="outline" class="w-full" @click="close">{{ t('events.checkout.close') }}</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View file

@ -26,7 +26,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Bell, ChevronDown, Tag } from 'lucide-vue-next' import { Bell, ChevronDown } from 'lucide-vue-next'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { import {
Select, Select,
@ -41,28 +41,12 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue' import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue' import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.vue' import TimePicker from '@/modules/base/components/TimePicker.vue'
import { Checkbox } from '@/components/ui/checkbox' import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { CreditCard } from 'lucide-vue-next'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket' import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
import { ALL_CATEGORIES } from '../types/category' import { ALL_CATEGORIES } from '../types/category'
import PromoCodesEditor from './PromoCodesEditor.vue'
import {
fromWirePromoCodes,
hasPromoErrors,
toWirePromoCodes,
validatePromoRows,
type PromoCodeRow,
} from '../lib/promoCodes'
interface Props { interface Props {
open: boolean open: boolean
@ -140,11 +124,7 @@ const formSchema = toTypedSchema(
event_end_time: z.string().optional().default(''), event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''), location: z.string().max(500).optional().default(''),
currency: z.string().default("sat"), currency: z.string().default("sat"),
// Rails buyers may use. `allow_fiat` on the wire is derived from allow_fiat: z.boolean().default(false),
// this list (fiat ∈ list) so older extensions / clients keep working.
payment_methods: z
.array(z.enum(['lightning', 'fiat']))
.min(1, 'Enable at least one payment method'),
fiat_currency: z.string().default("USD"), fiat_currency: z.string().default("USD"),
amount_tickets: z.number().min(0).max(100000).default(0), amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0), price_per_ticket: z.number().min(0).default(0),
@ -172,7 +152,7 @@ const formSchema = toTypedSchema(
// mirror themselves into fiat_currency automatically. The events // mirror themselves into fiat_currency automatically. The events
// extension uses 'sat' and 'sats' interchangeably — accept both. // extension uses 'sat' and 'sats' interchangeably — accept both.
const isSat = v.currency === 'sat' || v.currency === 'sats' const isSat = v.currency === 'sat' || v.currency === 'sats'
if (v.payment_methods.includes('fiat') && isSat && !v.fiat_currency) { if (v.allow_fiat && isSat && !v.fiat_currency) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['fiat_currency'], path: ['fiat_currency'],
@ -193,7 +173,7 @@ const form = useForm({
event_end_time: '', event_end_time: '',
location: '', location: '',
currency: 'sat', currency: 'sat',
payment_methods: ['lightning'], allow_fiat: false,
fiat_currency: 'USD', fiat_currency: 'USD',
amount_tickets: 0, amount_tickets: 0,
price_per_ticket: 0, price_per_ticket: 0,
@ -204,42 +184,6 @@ const form = useForm({
} }
}) })
/** Rails an existing event has on; legacy events (no list) derive
* from allow_fiat. */
function paymentMethodsOf(event: TicketedEvent): Array<'lightning' | 'fiat'> {
const explicit = (event.extra?.payment_methods ?? []).filter(
(m): m is 'lightning' | 'fiat' => m === 'lightning' || m === 'fiat',
)
if (explicit.length > 0) return explicit
return event.allow_fiat ? ['lightning', 'fiat'] : ['lightning']
}
const { hasAnyProvider, refresh: refreshFiatProviders } = useFiatProviders()
// Refresh once so the Card checkbox reflects providers the organizer may
// have just configured in LNbits.
refreshFiatProviders()
const PAYMENT_METHOD_OPTIONS = [
{ id: 'lightning' as const, label: 'Lightning', icon: Zap, hint: 'Pay with any Lightning wallet' },
{ id: 'fiat' as const, label: 'Card', icon: CreditCard, hint: 'Card or bank through your configured fiat provider' },
]
function isSatDenomination(d: string | undefined): boolean {
return d === 'sat' || d === 'sats'
}
// When the price is denominated in a fiat currency, the rail currency
// MUST match it — silently mirror so the backend payload stays consistent.
watch(
() => form.values.currency,
(d) => {
if (d && !isSatDenomination(d) && form.values.fiat_currency !== d) {
form.setFieldValue('fiat_currency', d)
}
},
{ immediate: true },
)
interface BannerImage extends UploadedImage { interface BannerImage extends UploadedImage {
isPrimary: boolean isPrimary: boolean
} }
@ -260,7 +204,6 @@ function splitDateTime(value: string | null | undefined): { date: string; time:
// edit-mode population with start-date side effects mid-setValues. // edit-mode population with start-date side effects mid-setValues.
const isPopulating = ref(false) const isPopulating = ref(false)
const notificationsOpen = ref(false) const notificationsOpen = ref(false)
const promoOpen = ref(false)
// Auto-mirror end date to start: when the user picks a start date, // Auto-mirror end date to start: when the user picks a start date,
// surface that same date in the end-date picker so a one-day event // surface that same date in the end-date picker so a one-day event
@ -286,10 +229,6 @@ const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLO
const availableCurrencies = ref<string[]>(['sat']) const availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false) const loadingCurrencies = ref(false)
const selectedCategories = ref<string[]>([]) const selectedCategories = ref<string[]>([])
// Promo codes live outside vee-validate like categories: a plain list
// with its own pure validator (`validatePromoRows`), gating submit below.
const promoRows = ref<PromoCodeRow[]>([])
const promoErrors = computed(() => validatePromoRows(promoRows.value))
async function populateFromEvent(event: TicketedEvent) { async function populateFromEvent(event: TicketedEvent) {
isPopulating.value = true isPopulating.value = true
@ -304,7 +243,7 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time, event_end_time: end.time,
location: event.location ?? '', location: event.location ?? '',
currency: event.currency ?? 'sat', currency: event.currency ?? 'sat',
payment_methods: paymentMethodsOf(event), allow_fiat: event.allow_fiat ?? false,
fiat_currency: event.fiat_currency ?? 'USD', fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 0, amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0, price_per_ticket: event.price_per_ticket ?? 0,
@ -314,10 +253,6 @@ async function populateFromEvent(event: TicketedEvent) {
notification_body: event.extra?.notification_body ?? '', notification_body: event.extra?.notification_body ?? '',
}) })
selectedCategories.value = [...(event.categories ?? [])] selectedCategories.value = [...(event.categories ?? [])]
// Codes come only on the organizer-authenticated record (public
// responses omit them) — useMyEvents / EventDetailPage hand us that one.
promoRows.value = fromWirePromoCodes(event.extra?.promo_codes)
promoOpen.value = promoRows.value.length > 0
if (event.banner) { if (event.banner) {
// Re-render the stored banner via its pict-rs file ID. delete_token // Re-render the stored banner via its pict-rs file ID. delete_token
// is intentionally empty: we don't own the original upload's token // is intentionally empty: we don't own the original upload's token
@ -356,13 +291,11 @@ watch(() => props.open, async (isOpen) => {
} }
} else { } else {
selectedCategories.value = [] selectedCategories.value = []
promoRows.value = []
promoOpen.value = false
} }
}) })
const { resetForm, meta } = form const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value)) const isFormValid = computed(() => meta.value.valid)
const isLoading = ref(false) const isLoading = ref(false)
const today = computed(() => format(new Date(), 'yyyy-MM-dd')) const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
@ -421,31 +354,27 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null eventData.banner = null
} }
if (formValues.currency) eventData.currency = formValues.currency if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat always sends (derived from the rail list) so a // allow_fiat always sends so a true→false flip propagates on edit;
// true→false flip propagates on edit; fiat_currency only sends when // fiat_currency only sends when fiat is on (no point persisting a
// fiat is on (no point persisting a rail-currency the backend won't use). // rail-currency the backend won't use).
const acceptsFiat = formValues.payment_methods.includes('fiat') eventData.allow_fiat = formValues.allow_fiat
eventData.allow_fiat = acceptsFiat if (formValues.allow_fiat && formValues.fiat_currency) {
if (acceptsFiat && formValues.fiat_currency) {
eventData.fiat_currency = formValues.fiat_currency eventData.fiat_currency = formValues.fiat_currency
} }
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
// Notification config + promo codes go inside the `extra` envelope. // Notification config goes inside the `extra` envelope. On edit
// On edit overlay onto the existing event.extra so fields only the // overlay onto the existing event.extra so unrelated fields the
// LNbits admin UI sets (conditional, min_tickets) survive the // LNbits admin UI sets (promo_codes, conditional, min_tickets)
// round-trip. promo_codes is always sent explicitly: what the // survive the round-trip.
// organizer sees in the editor is the truth, an empty list clears.
eventData.extra = { eventData.extra = {
...(props.event?.extra ?? {}), ...(props.event?.extra ?? {}),
payment_methods: formValues.payment_methods,
email_notifications: formValues.email_notifications, email_notifications: formValues.email_notifications,
nostr_notifications: formValues.nostr_notifications, nostr_notifications: formValues.nostr_notifications,
notification_subject: formValues.notification_subject, notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body, notification_body: formValues.notification_body,
promo_codes: toWirePromoCodes(promoRows.value),
} }
if (isEditMode.value) { if (isEditMode.value) {
@ -476,7 +405,6 @@ const onSubmit = form.handleSubmit(async (formValues) => {
resetForm() resetForm()
selectedCategories.value = [] selectedCategories.value = []
promoRows.value = []
bannerImages.value = [] bannerImages.value = []
emit('update:open', false) emit('update:open', false)
} catch (error) { } catch (error) {
@ -496,7 +424,6 @@ const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) { if (!open && !isLoading.value) {
resetForm() resetForm()
selectedCategories.value = [] selectedCategories.value = []
promoRows.value = []
bannerImages.value = [] bannerImages.value = []
} }
emit('update:open', open) emit('update:open', open)
@ -505,7 +432,7 @@ const handleOpenChange = (open: boolean) => {
<template> <template>
<Dialog :open="open" @update:open="handleOpenChange"> <Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="max-w-lg max-h-[90vh] p-0 overflow-x-hidden"> <DialogContent class="max-w-lg max-h-[90vh] p-0">
<DialogHeader class="px-6 pt-6 pb-2"> <DialogHeader class="px-6 pt-6 pb-2">
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<Calendar class="w-5 h-5" /> <Calendar class="w-5 h-5" />
@ -684,13 +611,9 @@ const handleOpenChange = (open: boolean) => {
fiat amounts convert at checkout using current rates. fiat amounts convert at checkout using current rates.
</p> </p>
</div> </div>
<!-- Pricing grid: 2 cols on narrow phones (Tickets/Price share <div class="grid grid-cols-3 gap-3">
a row, Currency takes its own), 3 cols once we have the
breathing room. Plain `grid-cols-3` was overflowing the
dialog on 360px-class viewports. -->
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets"> <FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem class="min-w-0"> <FormItem>
<FormLabel>Tickets</FormLabel> <FormLabel>Tickets</FormLabel>
<FormControl> <FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" /> <Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
@ -701,7 +624,7 @@ const handleOpenChange = (open: boolean) => {
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket"> <FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem class="min-w-0"> <FormItem>
<FormLabel>Price</FormLabel> <FormLabel>Price</FormLabel>
<FormControl> <FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" /> <Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
@ -712,11 +635,11 @@ const handleOpenChange = (open: boolean) => {
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="currency"> <FormField v-slot="{ componentField }" name="currency">
<FormItem class="col-span-2 sm:col-span-1 min-w-0"> <FormItem>
<FormLabel>Price currency</FormLabel> <FormLabel>Price currency</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField"> <Select v-bind="componentField">
<SelectTrigger class="w-full"> <SelectTrigger>
<SelectValue placeholder="sat" /> <SelectValue placeholder="sat" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -733,87 +656,26 @@ const handleOpenChange = (open: boolean) => {
</div> </div>
<!-- ── Payment methods ──────────────────────────────────── --> <!-- ── Payment methods ──────────────────────────────────── -->
<FormField name="payment_methods"> <div class="space-y-3">
<FormItem class="space-y-3"> <div>
<div> <p class="text-sm font-medium">Payment methods</p>
<FormLabel class="text-sm font-medium">Payment methods</FormLabel> <p class="text-xs text-muted-foreground">
<FormDescription class="text-xs"> Lightning is always available. Enable fiat to also accept
Pick the rails buyers can pay with. Turn Lightning off for a card and bank payments through your configured provider.
card-only sale. </p>
</FormDescription> </div>
</div> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<div class="space-y-2"> <Zap class="w-4 h-4" />
<FormField <span>Lightning — always on</span>
v-for="opt in PAYMENT_METHOD_OPTIONS" </div>
:key="opt.id" <FiatToggleField
v-slot="{ value, handleChange }" allow-fiat-field="allow_fiat"
type="checkbox" fiat-currency-field="fiat_currency"
:value="opt.id" :denomination="form.values.currency ?? 'sat'"
:unchecked-value="false" :available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
name="payment_methods" :disabled="isLoading"
> />
<FormItem class="flex flex-row items-center gap-3 space-y-0 rounded-md border p-3"> </div>
<FormControl>
<TooltipProvider v-if="opt.id === 'fiat' && !hasAnyProvider" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Checkbox :model-value="false" disabled />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-xs">
Your LNbits user has no fiat provider configured. Open
LNbits → Account → Fiat providers and add Stripe, PayPal,
or Square to enable card payments.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
v-else
:model-value="Array.isArray(value) && value.includes(opt.id)"
:disabled="isLoading"
@update:model-value="handleChange"
/>
</FormControl>
<div class="flex items-center gap-2 min-w-0">
<component :is="opt.icon" class="w-4 h-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<FormLabel class="font-normal">{{ opt.label }}</FormLabel>
<p class="text-xs text-muted-foreground">{{ opt.hint }}</p>
</div>
</div>
</FormItem>
</FormField>
</div>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="fiat_currency">
<FormItem
v-show="(form.values.payment_methods ?? []).includes('fiat') && isSatDenomination(form.values.currency)"
>
<FormLabel>Fiat currency</FormLabel>
<FormControl>
<Select v-bind="componentField" :disabled="isLoading">
<SelectTrigger>
<SelectValue placeholder="USD" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="c in availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
:key="c"
:value="c"
>
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">Currency card buyers are charged in.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Ticket buyer notifications (collapsible). The backend <!-- Ticket buyer notifications (collapsible). The backend
sends email + NIP-04 Nostr DM confirmations on sends email + NIP-04 Nostr DM confirmations on
@ -877,47 +739,12 @@ const handleOpenChange = (open: boolean) => {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<!-- Promo codes (collapsible). Percent discounts with an <!-- Actions -->
optional redemption cap; the backend hides the list from <div class="flex justify-end gap-3 pt-2">
public responses and enforces active/max_uses at purchase. --> <Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
<Collapsible v-model:open="promoOpen">
<CollapsibleTrigger as-child>
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
<span class="flex items-center gap-1.5">
<Tag class="w-4 h-4" />
{{ t('events.create.promo.title') }}
<Badge v-if="promoRows.length > 0" variant="secondary" class="text-[10px] px-1.5">
{{ promoRows.length }}
</Badge>
</span>
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': promoOpen }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="pt-2">
<PromoCodesEditor v-model="promoRows" :disabled="isLoading" />
</CollapsibleContent>
</Collapsible>
<!-- Actions. Stack full-width on narrow viewports so a wide
localized "Submit Event" label can't push the Cancel
button out of the dialog. Submit reads top-down on
mobile (flex-col-reverse) to keep the primary action
under the thumb. -->
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
class="w-full sm:w-auto"
@click="handleOpenChange(false)"
:disabled="isLoading"
>
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" :disabled="isLoading || !isFormValid">
type="submit"
class="w-full sm:w-auto"
:disabled="isLoading || !isFormValid"
>
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{ {{
isLoading isLoading

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { addDays, format, isSameDay, startOfWeek } from 'date-fns'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useDateLocale } from '../composables/useDateLocale'
const props = defineProps<{
/** Currently selected date (if any) */
selectedDate?: Date
}>()
const emit = defineEmits<{
select: [date: Date]
}>()
const { dateLocale } = useDateLocale()
/** Start of the visible week */
const weekStart = ref(startOfWeek(new Date(), { weekStartsOn: 1 }))
const days = computed(() => {
return Array.from({ length: 7 }, (_, i) => addDays(weekStart.value, i))
})
const isToday = (date: Date) => isSameDay(date, new Date())
const isSelected = (date: Date) => props.selectedDate ? isSameDay(date, props.selectedDate) : false
function prevWeek() {
weekStart.value = addDays(weekStart.value, -7)
}
function nextWeek() {
weekStart.value = addDays(weekStart.value, 7)
}
</script>
<template>
<div class="flex items-center gap-2">
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="prevWeek">
<ChevronLeft class="h-4 w-4" />
</Button>
<div class="grid grid-cols-7 flex-1 gap-0.5">
<button
v-for="day in days"
:key="day.toISOString()"
class="flex flex-col items-center py-1.5 rounded-lg transition-colors"
:class="{
'bg-primary text-primary-foreground': isSelected(day),
'bg-muted/50': isToday(day) && !isSelected(day),
'hover:bg-muted': !isSelected(day),
}"
@click="emit('select', day)"
>
<span class="text-[10px] font-medium uppercase leading-none"
:class="isSelected(day) ? 'text-primary-foreground/70' : 'text-muted-foreground'"
>
{{ format(day, 'EEEEE', { locale: dateLocale }) }}
</span>
<span class="text-sm font-semibold leading-tight mt-0.5">
{{ format(day, 'd') }}
</span>
</button>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="nextWeek">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>

View file

@ -1,118 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
DialogRoot,
DialogPortal,
DialogOverlay,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
} from 'reka-ui'
import { X } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import EventCalendarView from './EventCalendarView.vue'
import type { Event } from '../types/event'
import type { EventCategory } from '../types/category'
// A date-picker popup: the month grid (with per-day event dots) in a
// dialog. Picking a day emits selectDate and closes. Reused by the feed
// (filter to a day) and My Tickets (visualise the user's event dates).
//
// Built on the reka-ui dialog primitives (rather than the shared
// DialogContent) so it can use a light, blurred overlay instead of the
// usual opaque dark dim — the feed stays visible, softly blurred, behind
// the frosted-glass panel.
const props = withDefaults(
defineProps<{
open: boolean
events: Event[]
title: string
description: string
// Active category filter mirrored from the feed. Rendered as
// deselectable chips so the user can see — and loosen — what's
// narrowing the calendar without closing it. Defaults to none for
// callers that don't filter by category (e.g. My Tickets).
selectedCategories?: EventCategory[]
}>(),
{
selectedCategories: () => [],
},
)
const emit = defineEmits<{
'update:open': [value: boolean]
selectDate: [date: Date]
'toggle-category': [category: EventCategory]
}>()
const { t } = useI18n()
const isOpen = computed({
get: () => props.open,
set: (v) => emit('update:open', v),
})
function categoryLabel(cat: EventCategory): string {
return t(`events.categories.${cat}`, cat)
}
function onSelectDate(date: Date) {
emit('selectDate', date)
isOpen.value = false
}
</script>
<template>
<DialogRoot v-model:open="isOpen">
<DialogPortal>
<!-- Frosted backdrop: a light tint + blur so the feed shows
through (softly blurred) rather than being hidden behind an
opaque dark overlay. -->
<DialogOverlay class="fixed inset-0 z-50 bg-background/20 backdrop-blur-md" />
<DialogContent
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-sm -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background/70 p-6 shadow-lg backdrop-blur-xl"
>
<div class="flex flex-col gap-1.5 text-center sm:text-left">
<DialogTitle class="text-lg font-semibold leading-none tracking-tight">
{{ title }}
</DialogTitle>
<DialogDescription class="text-sm text-muted-foreground">
{{ description }}
</DialogDescription>
</div>
<!-- Active category filter — only the selected categories, each
removable. Clicking deselects via the parent's toggle, which
reactively re-narrows the calendar dots without closing. -->
<div
v-if="selectedCategories.length"
class="flex flex-wrap items-center gap-1.5"
>
<span class="text-xs text-muted-foreground">
{{ t('events.filters.filteringBy') }}
</span>
<Badge
v-for="cat in selectedCategories"
:key="cat"
variant="secondary"
class="cursor-pointer gap-1 text-xs select-none hover:opacity-80 transition-opacity"
:aria-label="t('events.filters.removeCategory', { category: categoryLabel(cat) })"
@click="emit('toggle-category', cat)"
>
{{ categoryLabel(cat) }}
<X class="w-3 h-3" />
</Badge>
</div>
<EventCalendarView :events="events" picker-mode @select-date="onSelectDate" />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none"
aria-label="Close"
>
<X class="w-4 h-4" />
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View file

@ -12,10 +12,6 @@ import type { Event } from '../types/event'
const props = defineProps<{ const props = defineProps<{
events: Event[] events: Event[]
/** When true, render only the month grid for date-picking — no
* selected-day events panel — and emit selectDate on every day tap
* (used inside the calendar popup). */
pickerMode?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -72,21 +68,13 @@ function getDotCount(date: Date): number {
return Math.min(getEventsForDay(date).length, 3) return Math.min(getEventsForDay(date).length, 3)
} }
// Default the selection to today so the calendar opens on today's events const selectedDay = ref<Date | null>(null)
// rather than an empty panel (currentMonth already starts on this month).
const selectedDay = ref<Date | null>(new Date())
const selectedDayEvents = computed(() => { const selectedDayEvents = computed(() => {
if (!selectedDay.value) return [] if (!selectedDay.value) return []
return getEventsForDay(selectedDay.value) return getEventsForDay(selectedDay.value)
}) })
function selectDay(date: Date) { function selectDay(date: Date) {
// Picker mode: every tap selects + emits (parent closes the popup).
if (props.pickerMode) {
selectedDay.value = date
emit('selectDate', date)
return
}
if (selectedDay.value && isSameDay(selectedDay.value, date)) { if (selectedDay.value && isSameDay(selectedDay.value, date)) {
selectedDay.value = null selectedDay.value = null
} else { } else {
@ -107,7 +95,7 @@ function nextMonth() {
</script> </script>
<template> <template>
<div class="space-y-2"> <div class="space-y-4">
<!-- Month navigation --> <!-- Month navigation -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth"> <Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
@ -135,7 +123,7 @@ function nextMonth() {
<button <button
v-for="date in calendarDays" v-for="date in calendarDays"
:key="date.toISOString()" :key="date.toISOString()"
class="h-12 flex flex-col items-center justify-center relative p-0.5 rounded-lg transition-colors" class="aspect-square flex flex-col items-center justify-center relative p-1 rounded-lg transition-colors"
:class="{ :class="{
'text-muted-foreground/40': !isSameMonth(date, currentMonth), 'text-muted-foreground/40': !isSameMonth(date, currentMonth),
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay), 'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
@ -157,9 +145,8 @@ function nextMonth() {
</button> </button>
</div> </div>
<!-- Selected day events (hidden in picker mode — the popup just <!-- Selected day events -->
picks a day and closes). --> <div v-if="selectedDay" class="border-t pt-4 space-y-2">
<div v-if="selectedDay && !pickerMode" class="border-t pt-4 space-y-2">
<h3 class="text-sm font-medium text-muted-foreground"> <h3 class="text-sm font-medium text-muted-foreground">
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }} {{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
<span v-if="selectedDayEvents.length > 0" class="ml-1"> <span v-if="selectedDayEvents.length > 0" class="ml-1">

View file

@ -6,17 +6,12 @@ import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next' import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
import BookmarkButton from './BookmarkButton.vue' import BookmarkButton from './BookmarkButton.vue'
import OrganizerCard from './OrganizerCard.vue'
import { useDateLocale } from '../composables/useDateLocale' import { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets' import { useOwnedTickets } from '../composables/useOwnedTickets'
import type { Event } from '../types/event' import type { Event } from '../types/event'
const props = defineProps<{ const props = defineProps<{
event: Event event: Event
/** Render a compact row: no hero image, no summary, single-line
* title, tighter padding. Used by the Hosting view where the
* host already knows what their events look like. */
compact?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -57,64 +52,42 @@ const priceDisplay = computed(() => {
return `${info.price} ${info.currency}` return `${info.price} ${info.currency}`
}) })
const placeholderBg = computed(() => {
// Generate a consistent hue from the event title
const hash = props.event.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const hue = hash % 360
return `hsl(${hue}, 40%, 85%)`
})
const isPast = computed(() => { const isPast = computed(() => {
const a = props.event const a = props.event
const end = a.endDate ?? a.startDate const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now() return end.getTime() < Date.now()
}) })
// Pending / rejected events get a washed-out look so the user
// sees at a glance the event isn't live, not just the small badge.
const isNonApproved = computed(
() => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved',
)
</script> </script>
<template> <template>
<!-- `min-w-0`: the card is a grid item, and a grid item's default
`min-width: auto` resolves to its min-content width. The nowrap
`truncate` rows below (location, organizer) contribute their
full text width to that, so a long address would widen the
whole column past the viewport on phones. Letting the item
shrink is what makes `truncate` actually truncate. -->
<Card <Card
class="relative min-w-0 cursor-pointer hover:shadow-lg transition-shadow duration-200" class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
@click="emit('click', event)" @click="emit('click', event)"
> >
<!-- Wash-out wrapper. The pending/rejected status badge below sits <!-- Image / Placeholder -->
OUTSIDE this wrapper so it stays in full color and reads <div class="relative aspect-[16/9] overflow-hidden">
clearly even when the card is dimmed + desaturated. -->
<div
class="transition-opacity duration-200"
:class="[
compact ? 'flex flex-row' : 'flex flex-col',
isNonApproved ? 'opacity-50 grayscale hover:opacity-90' : '',
]"
>
<!-- Compact thumbnail — small square preview on the left of the
row when the event carries an image. `self-center` keeps it
vertically centered against a taller content column so we
don't get a top-anchored thumb with dead space below. -->
<img
v-if="compact && event.image"
:src="event.image"
:alt="event.title"
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
loading="lazy"
/>
<!-- Image with overlaid badges. Cards without an image (or in
compact mode) skip the hero area entirely and surface their
badges inline at the top of the content block — the solid-
color placeholder + calendar glyph wasn't communicating
anything the title + details don't already. -->
<div v-if="event.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg">
<img <img
v-if="event.image"
:src="event.image" :src="event.image"
:alt="event.title" :alt="event.title"
class="w-full h-full object-cover" class="w-full h-full object-cover"
loading="lazy" loading="lazy"
/> />
<div
v-else
class="w-full h-full flex items-center justify-center"
:style="{ backgroundColor: placeholderBg }"
>
<Calendar class="w-12 h-12 text-foreground/20" />
</div>
<!-- Category badge --> <!-- Category badge -->
<Badge <Badge
@ -144,13 +117,27 @@ const isNonApproved = computed(
{{ priceDisplay }} {{ priceDisplay }}
</Badge> </Badge>
<!-- Past badge — shown when the event has already ended. The <!-- Pending/rejected overlay for the creator's own non-approved
pending/rejected status badge that used to share this slot drafts. Only present when the event originated from a
is now an absolute overlay on Card root, above the wash-out, local LNbits event (Nostr-sourced events have no
so we still suppress Past when isNonApproved (the status lnbitsStatus). -->
badge is more actionable in that case). -->
<Badge <Badge
v-if="isPast && !isNonApproved" v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="absolute bottom-2 left-2 text-xs capitalize"
>
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<!-- Past badge — shown when the event has already ended.
Only relevant on the feed when the "Past events" filter
chip is toggled on (otherwise these cards aren't rendered);
on the detail page the card view isn't used. Suppressed
when a pending/rejected status badge is taking the same
slot — that case is the creator's own past draft, which is
vanishingly rare and the status hint is more actionable. -->
<Badge
v-if="isPast && !(event.lnbitsStatus && event.lnbitsStatus !== 'approved')"
variant="outline" variant="outline"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur" class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
> >
@ -159,71 +146,27 @@ const isNonApproved = computed(
</Badge> </Badge>
</div> </div>
<CardContent <CardContent class="p-4 flex-1 flex flex-col gap-2">
:class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'" <!-- Title + Bookmark -->
>
<!-- Inline badge row (no-image variant + compact variant). Same
badges as the image-overlay set, stacked horizontally at the
top of the content area. The "Yours" chip is dropped in
compact mode since every card in the hosting view is owned. -->
<div v-if="!event.image || compact" class="flex flex-wrap items-center gap-1.5">
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
{{ categoryLabel }}
</Badge>
<Badge v-if="priceDisplay" class="text-xs">
{{ priceDisplay }}
</Badge>
<Badge v-if="event.isMine && !compact" variant="outline" class="text-xs gap-1">
<User class="w-3 h-3" />
Yours
</Badge>
<Badge
v-if="isPast && !isNonApproved"
variant="outline"
class="text-xs gap-1"
>
<History class="w-3 h-3" />
{{ t('events.filters.past', 'Past') }}
</Badge>
</div>
<!-- Title + Bookmark. Compact mode hides the bookmark (host's
own event — bookmarking it would be noise) and clamps the
title to a single line. -->
<div class="flex items-start gap-1"> <div class="flex items-start gap-1">
<h3 <h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
:class="[
'font-semibold text-foreground leading-tight flex-1',
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
]"
>
{{ event.title }} {{ event.title }}
</h3> </h3>
<BookmarkButton <BookmarkButton
v-if="!compact"
:pubkey="event.organizer.pubkey" :pubkey="event.organizer.pubkey"
:d-tag="event.id" :d-tag="event.id"
/> />
</div> </div>
<!-- Summary (hidden in compact mode) --> <!-- Summary -->
<p <p
v-if="event.summary && !compact" v-if="event.summary"
class="text-sm text-muted-foreground line-clamp-2" class="text-sm text-muted-foreground line-clamp-2"
> >
{{ event.summary }} {{ event.summary }}
</p> </p>
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'"> <div class="mt-auto space-y-1.5 pt-2">
<!-- Organizer — small avatar + display name. Hidden in compact
mode (host's own roster, no need to tell them whose event
it is) and on cards the user already owns. -->
<OrganizerCard
v-if="!compact"
:pubkey="event.organizer.pubkey"
compact
/>
<!-- Date/Time --> <!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground"> <div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" /> <Calendar class="w-3.5 h-3.5 shrink-0" />
@ -251,7 +194,7 @@ const isNonApproved = computed(
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }} {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span> </span>
<span v-else-if="event.ticketInfo.available > 0"> <span v-else-if="event.ticketInfo.available > 0">
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }} {{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
</span> </span>
<span v-else class="text-destructive font-medium"> <span v-else class="text-destructive font-medium">
{{ t('events.detail.soldOut') }} {{ t('events.detail.soldOut') }}
@ -273,22 +216,5 @@ const isNonApproved = computed(
</div> </div>
</div> </div>
</CardContent> </CardContent>
</div>
<!-- Status badge — absolutely positioned on Card root so it sits
ABOVE the wash-out wrapper and keeps its full color.
Pending + rejected both lean on the destructive token so the
non-approved state reads as "needs attention" in every theme;
the label text differentiates the two specific states.
Bottom-right with a slight downward spill so it anchors
visually without competing with the category chip in the
badge row (full cards) or the thumbnail (compact cards). -->
<Badge
v-if="isNonApproved"
variant="destructive"
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
>
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
</Card> </Card>
</template> </template>

View file

@ -7,10 +7,6 @@ import type { Event } from '../types/event'
defineProps<{ defineProps<{
events: Event[] events: Event[]
isLoading?: boolean isLoading?: boolean
/** Render compact rows instead of full-image cards. Used by the
* Hosting view so an operator can scan their roster of events
* without the visual weight of hero images they already recognize. */
compact?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -43,24 +39,20 @@ const { t } = useI18n()
class="flex flex-col items-center justify-center py-16 text-center" class="flex flex-col items-center justify-center py-16 text-center"
> >
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" /> <CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<h3 class="text-lg font-medium text-foreground"> <h3 class="text-lg font-medium text-foreground mb-1">
{{ t('events.noEvents') }} {{ t('events.noEvents') }}
</h3> </h3>
<p class="text-sm text-muted-foreground">
{{ t('events.search.noResults') }}
</p>
</div> </div>
<!-- Event grid — compact mode collapses to a single column of <!-- Event grid -->
tight rows; default mode is the responsive card grid. The <div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
compact gap is bumped a notch so the status badge spilling
past the card's bottom edge has room to sit between cards. -->
<div
v-else
:class="compact ? 'flex flex-col gap-4' : 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'"
>
<EventCard <EventCard
v-for="event in events" v-for="event in events"
:key="event.nostrEventId" :key="event.nostrEventId"
:event="event" :event="event"
:compact="compact"
@click="emit('select', event)" @click="emit('select', event)"
/> />
</div> </div>

View file

@ -7,13 +7,8 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Search, X, MapPin, Calendar } from 'lucide-vue-next' import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch' import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService } from '@/modules/base/nostr/ProfileService'
import type { Event } from '../types/event' import type { Event } from '../types/event'
/** Event enriched with its resolved organizer display name for search. */
type SearchableEvent = Event & { organizerName: string }
const props = defineProps<{ const props = defineProps<{
events: Event[] events: Event[]
}>() }>()
@ -27,13 +22,12 @@ const { dateLocale } = useDateLocale()
const isOpen = ref(false) const isOpen = ref(false)
const inputRef = ref<HTMLInputElement | null>(null) const inputRef = ref<HTMLInputElement | null>(null)
const searchOptions: FuzzySearchOptions<SearchableEvent> = { const searchOptions: FuzzySearchOptions<Event> = {
fuseOptions: { fuseOptions: {
keys: [ keys: [
{ name: 'title', weight: 0.5 }, { name: 'title', weight: 0.5 },
{ name: 'summary', weight: 0.2 }, { name: 'summary', weight: 0.2 },
{ name: 'description', weight: 0.15 }, { name: 'description', weight: 0.15 },
{ name: 'organizerName', weight: 0.1 },
{ name: 'location', weight: 0.1 }, { name: 'location', weight: 0.1 },
{ name: 'tags', weight: 0.05 }, { name: 'tags', weight: 0.05 },
], ],
@ -45,20 +39,7 @@ const searchOptions: FuzzySearchOptions<SearchableEvent> = {
resultLimit: 8, resultLimit: 8,
} }
// Organizer display names aren't stored on the event (they're fetched const eventsRef = computed(() => props.events)
// per-pubkey into the shared ProfileService cache). Read the resolved
// name from that same reactive cache so search matches it; the corpus
// recomputes as kind-0 metadata lands.
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
function organizerNameFor(pubkey: string): string {
const p = profileService?.profiles.get(pubkey)
return p?.display_name ?? p?.name ?? ''
}
const searchCorpus = computed<SearchableEvent[]>(() =>
props.events.map((e) => ({ ...e, organizerName: organizerNameFor(e.organizer.pubkey) })),
)
const { const {
searchQuery, searchQuery,
@ -66,7 +47,7 @@ const {
isSearching, isSearching,
clearSearch, clearSearch,
setSearchQuery, setSearchQuery,
} = useFuzzySearch(searchCorpus, searchOptions) } = useFuzzySearch(eventsRef, searchOptions)
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0) const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0) const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
@ -113,18 +94,6 @@ function handleClickOutside(e: MouseEvent) {
watch(isOpen, (open) => { watch(isOpen, (open) => {
if (open) { if (open) {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
// Warm the shared profile cache for every organizer in the current
// set so their names become searchable (fetches dedupe in the
// service; the corpus reacts as kind-0 metadata arrives).
if (profileService) {
const seen = new Set<string>()
for (const e of props.events) {
const pk = e.organizer.pubkey
if (seen.has(pk) || profileService.profiles.get(pk)) continue
seen.add(pk)
void profileService.getProfile(pk)
}
}
} else { } else {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
} }

View file

@ -3,37 +3,15 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { User } from 'lucide-vue-next' import { User } from 'lucide-vue-next'
import { useOrganizerProfile } from '../composables/useOrganizerProfile' import { useOrganizerProfile } from '../composables/useOrganizerProfile'
const props = withDefaults( const props = defineProps<{
defineProps<{ pubkey: string
pubkey: string }>()
/** Compact row variant — small avatar, single-line "By <name>".
* Used on the events feed card where the organizer is a hint, not
* the focus. Default (full) is used on the detail page. */
compact?: boolean
}>(),
{ compact: false },
)
const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey) const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
</script> </script>
<template> <template>
<!-- Compact: tiny avatar + "By <name>" on a single line --> <div class="flex items-center gap-3">
<div v-if="compact" class="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
<Avatar class="h-4 w-4 shrink-0">
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
<AvatarFallback class="bg-primary/10">
<User class="w-2.5 h-2.5 text-primary" />
</AvatarFallback>
</Avatar>
<span class="truncate">
<template v-if="isLoading">Loading…</template>
<template v-else>{{ displayName }}</template>
</span>
</div>
<!-- Full (default): 10x10 avatar with name + nip05/pubkey -->
<div v-else class="flex items-center gap-3">
<Avatar class="h-10 w-10"> <Avatar class="h-10 w-10">
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" /> <AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
<AvatarFallback class="bg-primary/10"> <AvatarFallback class="bg-primary/10">
@ -42,14 +20,14 @@ const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
</Avatar> </Avatar>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-medium text-foreground truncate"> <p class="text-sm font-medium text-foreground truncate">
<template v-if="isLoading">Loading…</template> <template v-if="isLoading">Loading...</template>
<template v-else>{{ displayName }}</template> <template v-else>{{ displayName }}</template>
</p> </p>
<p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate"> <p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate">
{{ profile.nip05 }} {{ profile.nip05 }}
</p> </p>
<p v-else class="text-xs text-muted-foreground font-mono truncate"> <p v-else class="text-xs text-muted-foreground font-mono truncate">
{{ pubkey.slice(0, 16) }}… {{ pubkey.slice(0, 16) }}...
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,153 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Plus, Trash2 } from 'lucide-vue-next'
import { normalizePromoCode, validatePromoRows, type PromoCodeRow } from '../lib/promoCodes'
/**
* Organizer-side promo code editor: one row per code with percentage,
* optional redemption cap and an active switch. Plain `v-model` over a
* `PromoCodeRow[]` (the parent keeps it as a ref next to its vee-validate
* form, the same way categories are handled); validation is the pure
* `validatePromoRows` so the parent can gate submit on the same rules.
*/
const props = defineProps<{
modelValue: PromoCodeRow[]
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [rows: PromoCodeRow[]] }>()
const { t } = useI18n()
const errors = computed(() => validatePromoRows(props.modelValue))
function update(index: number, patch: Partial<PromoCodeRow>) {
emit(
'update:modelValue',
props.modelValue.map((row, i) => (i === index ? { ...row, ...patch } : row)),
)
}
function add() {
emit('update:modelValue', [
...props.modelValue,
{ code: '', discount_percent: null, max_uses: null, active: true },
])
}
function remove(index: number) {
emit('update:modelValue', props.modelValue.filter((_, i) => i !== index))
}
/** Empty input → null (so "unset" never becomes 0); otherwise a number. */
function toNumberOrNull(value: string | number): number | null {
const str = String(value).trim()
if (str === '') return null
const n = Number(str)
return Number.isFinite(n) ? n : null
}
</script>
<template>
<div class="space-y-3">
<p class="text-xs text-muted-foreground">{{ t('events.create.promo.hint') }}</p>
<p v-if="modelValue.length === 0" class="text-sm text-muted-foreground">
{{ t('events.create.promo.empty') }}
</p>
<div
v-for="(row, index) in modelValue"
:key="index"
class="rounded-md border border-border p-3 space-y-2"
>
<div class="grid grid-cols-[minmax(0,1fr)_5.5rem_5.5rem] gap-2">
<div class="space-y-1">
<Label :for="`promo-code-${index}`" class="text-xs">{{ t('events.create.promo.code') }}</Label>
<Input
:id="`promo-code-${index}`"
:model-value="row.code"
:placeholder="t('events.create.promo.codePlaceholder')"
class="font-mono uppercase"
autocapitalize="characters"
autocomplete="off"
spellcheck="false"
:disabled="disabled"
@update:model-value="update(index, { code: normalizePromoCode(String($event)) })"
/>
<p v-if="errors[index]?.code" class="text-xs text-destructive">
{{ t(errors[index].code!) }}
</p>
</div>
<div class="space-y-1">
<Label :for="`promo-pct-${index}`" class="text-xs">{{ t('events.create.promo.discount') }}</Label>
<Input
:id="`promo-pct-${index}`"
type="number"
inputmode="numeric"
min="1"
max="100"
step="1"
:model-value="row.discount_percent ?? ''"
:disabled="disabled"
@update:model-value="update(index, { discount_percent: toNumberOrNull($event) })"
/>
<p v-if="errors[index]?.discount_percent" class="text-xs text-destructive">
{{ t(errors[index].discount_percent!) }}
</p>
</div>
<div class="space-y-1">
<Label :for="`promo-max-${index}`" class="text-xs">{{ t('events.create.promo.maxUses') }}</Label>
<Input
:id="`promo-max-${index}`"
type="number"
inputmode="numeric"
min="1"
step="1"
placeholder="∞"
:model-value="row.max_uses ?? ''"
:disabled="disabled"
@update:model-value="update(index, { max_uses: toNumberOrNull($event) })"
/>
<p v-if="errors[index]?.max_uses" class="text-xs text-destructive">
{{ t(errors[index].max_uses!) }}
</p>
</div>
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Switch
:id="`promo-active-${index}`"
:model-value="row.active"
:disabled="disabled"
@update:model-value="update(index, { active: Boolean($event) })"
/>
<Label :for="`promo-active-${index}`" class="text-xs">{{ t('events.create.promo.active') }}</Label>
<span v-if="(row.used_count ?? 0) > 0" class="text-xs text-muted-foreground">
· {{ t('events.create.promo.used', { count: row.used_count }) }}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-destructive"
:disabled="disabled"
:aria-label="t('events.create.promo.removeLabel', { code: row.code || '…' })"
@click="remove(index)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" class="gap-1.5" :disabled="disabled" @click="add">
<Plus class="w-4 h-4" />
{{ t('events.create.promo.add') }}
</Button>
</div>
</template>

View file

@ -1,29 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { useTicketPurchase } from '../composables/useTicketPurchase' import { useTicketPurchase } from '../composables/useTicketPurchase'
import { usePromoCode } from '../composables/usePromoCode'
import { normalizePromoCode } from '../lib/promoCodes'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, Landmark, Minus, Plus, Copy, Check, Loader2, Mail, Tag, X } from 'lucide-vue-next' import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting' import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, { import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry, type PaymentMethod as PaymentMethodEntry,
@ -31,9 +15,6 @@ import PaymentMethodSelector, {
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue' import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders' import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion' import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
import { effectivePaymentMethods } from '../lib/paymentMethods'
import { frontendRoot } from '../lib/frontendRoot'
import { PENDING_CHECKOUT_KEY, type PendingCheckout } from '../lib/pendingCheckout'
interface Props { interface Props {
event: { event: {
@ -44,9 +25,6 @@ interface Props {
/** Whether the event accepts fiat payments. From v1.4.0+ */ /** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean allow_fiat?: boolean
fiat_currency?: string fiat_currency?: string
/** Rails the organizer enabled (`tickets_payment_methods`). Absent
* on legacy events — falls back to the allow_fiat rule. */
payment_methods?: string[]
} }
isOpen: boolean isOpen: boolean
} }
@ -57,9 +35,8 @@ interface Emits {
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const { t } = useI18n()
const { isAuthenticated, userDisplay, currentUser } = useAuth() const { isAuthenticated, userDisplay } = useAuth()
const { const {
isLoading, isLoading,
error, error,
@ -68,6 +45,7 @@ const {
qrCode, qrCode,
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
canPurchase,
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,
purchaseTicketForEvent, purchaseTicketForEvent,
@ -76,9 +54,7 @@ const {
resetPaymentState, resetPaymentState,
cleanup, cleanup,
purchasedTicketIds, purchasedTicketIds,
ticketQRCodes, showTicketQR
showTicketQR,
finalizePurchasedTickets
} = useTicketPurchase() } = useTicketPurchase()
const MAX_QUANTITY = 10 const MAX_QUANTITY = 10
@ -94,57 +70,6 @@ function increaseQuantity() {
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value) const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
// Free events (price 0): no invoice is minted — the backend issues the
// tickets already-paid. Drop the payment-method selector and price line
// and label the CTA "Get ticket" instead of "Proceed".
const isFree = computed(() => props.event.price_per_ticket <= 0)
// Guests may buy paid tickets (name + email identity); free claims stay
// login-gated so an anonymous script can't drain a free event's
// capacity (events#29).
const guestNeedsLogin = computed(() => isFree.value && !isAuthenticated.value)
// --- Promo code -----------------------------------------------------
// Server-validated preview (`POST /promo/validate`); the purchase
// endpoint re-checks the code anyway, so the client never trusts its
// own arithmetic for what gets charged.
const promo = usePromoCode(() => props.event.id, quantity)
const {
input: promoInput,
state: promoState,
isChecking: promoChecking,
codeToSend: promoCodeToSend,
} = promo
const hasDiscount = computed(
() => promoState.value.status === 'applied' && promoState.value.total < promoState.value.subtotal,
)
const promoDiscountAmount = computed(() =>
promoState.value.status === 'applied' ? promoState.value.discount : 0,
)
/** What the buyer will actually be charged for the current quantity. */
const effectiveTotal = computed(() =>
promoState.value.status === 'applied' ? promoState.value.total : totalPrice.value,
)
const isZeroTotal = computed(() => effectiveTotal.value <= 0)
// A 100 %-off code turns a paid ticket into a free claim, which stays
// login-gated (events#29). Prompt inline — swapping the whole form would
// hide the Remove button the guest needs to back out of the code.
const guestNeedsLoginForDiscount = computed(
() => !isFree.value && isZeroTotal.value && !isAuthenticated.value,
)
function onPromoInput(event: Event) {
const el = event.target as HTMLInputElement
const value = normalizePromoCode(el.value)
el.value = value
promoInput.value = value
}
function isPromoError(err: unknown): boolean {
return err instanceof Error && /promo code/i.test(err.message)
}
async function copyInvoice() { async function copyInvoice() {
if (!paymentRequest.value) return if (!paymentRequest.value) return
try { try {
@ -157,69 +82,16 @@ async function copyInvoice() {
} }
} }
// --- Contact (delivery email) form ---------------------------------
// Every ticket carries an email so the extension can deliver it (and
// so Stripe's checkout is prefilled + locked to the same address).
// Guests additionally need a name — that pair is their identity on
// the backend. Confirm-email guards against typos on the one field a
// guest cannot fix afterwards.
const contactSchema = computed(() =>
toTypedSchema(
z
.object({
name: isAuthenticated.value
? z.string().optional().default('')
: z.string().trim().min(1, t('events.purchase.contact.nameRequired')).max(100),
email: z.string().trim().email(t('events.purchase.contact.emailInvalid')),
confirmEmail: z.string().trim(),
})
.superRefine((v, ctx) => {
if (v.email.toLowerCase() !== v.confirmEmail.toLowerCase()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmEmail'],
message: t('events.purchase.contact.emailMismatch'),
})
}
}),
),
)
const form = useForm({
validationSchema: contactSchema,
initialValues: { name: '', email: '', confirmEmail: '' },
})
// The dialog stays mounted across opens — reseed on every open so a
// login/logout between purchases is reflected.
watch(
() => props.isOpen,
(open) => {
if (!open) return
form.resetForm({
values: { name: '', email: currentUser.value?.email ?? '', confirmEmail: '' },
})
promo.clear()
},
{ immediate: true },
)
const { providers, providerMeta } = useFiatProviders() const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion() const { convert } = usePriceConversion()
// Empty until the dialog opens: the watcher below picks the default per const selectedMethodId = ref<string>('lightning')
// rail availability + auth state, so a guest lands on Card, not Lightning. const fiatRedirectUrl = ref<string | null>(null)
const selectedMethodId = ref<string>('') const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false) const isFiatPending = ref(false)
const fiatError = ref<string | null>(null) const fiatError = ref<string | null>(null)
const enabledRails = computed(() => const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
effectivePaymentMethods({
allowFiat: props.event.allow_fiat,
paymentMethods: props.event.payment_methods,
}),
)
const canChooseFiat = computed(() => enabledRails.value.includes('fiat'))
const isPriceInSats = computed( const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats', () => props.event.currency === 'sat' || props.event.currency === 'sats',
) )
@ -247,181 +119,126 @@ function iconFor(hint: 'card' | 'bank' | 'wallet') {
} }
const paymentMethods = computed<PaymentMethodEntry[]>(() => { const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const entries: PaymentMethodEntry[] = [] const lightning: PaymentMethodEntry = {
if (enabledRails.value.includes('lightning')) { id: 'lightning',
entries.push({ rail: 'lightning',
id: 'lightning', label: 'Lightning',
rail: 'lightning', icon: Zap,
label: 'Lightning', available: true,
icon: Zap, badge:
available: true, !isPriceInSats.value && lightningSats.value
badge: ? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
!isPriceInSats.value && lightningSats.value : undefined,
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined,
})
} }
if (canChooseFiat.value) { if (!props.event.allow_fiat) return [lightning]
if (providers.value.length > 0) {
for (const id of providers.value) {
const meta = providerMeta(id)
entries.push({
id: `fiat:${id}`,
rail: 'fiat',
provider: id,
label: meta.label,
icon: iconFor(meta.icon),
available: true,
})
}
} else {
// Guests (and sessions without provider info) can't enumerate the
// organizer's providers. Show a generic Card button and let the
// backend pick the organizer's default at request time.
entries.push({
id: 'fiat',
rail: 'fiat',
label: t('events.purchase.card'),
icon: CreditCard,
available: true,
})
}
}
return entries
})
// Default rail: guests lean to card when it's on offer (no wallet to if (providers.value.length > 0) {
// pay a bolt11 with); everyone else takes the first enabled rail. const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
watch( const meta = providerMeta(id)
[paymentMethods, () => props.isOpen, isAuthenticated], return {
([methods, open, authed]) => { id: `fiat:${id}`,
if (!open || methods.length === 0) return rail: 'fiat',
const current = methods.find((m) => m.id === selectedMethodId.value) provider: id,
if (current) return label: meta.label,
const preferred = !authed ? methods.find((m) => m.rail === 'fiat') : undefined icon: iconFor(meta.icon),
selectedMethodId.value = (preferred ?? methods[0]).id available: true,
}, }
{ immediate: true }, })
) return [lightning, ...fiatRails]
}
// Degenerate fallback — allow_fiat is on but the buyer's session
// can't enumerate the organizer's providers. Show a generic Card
// button and let the backend pick a default at request time.
return [
lightning,
{
id: 'fiat',
rail: 'fiat',
label: 'Card',
icon: CreditCard,
available: true,
},
]
})
const selectedMethod = computed(() => const selectedMethod = computed(() =>
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0], paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
) )
const onSubmit = form.handleSubmit(async (contact) => { async function handlePurchase() {
await handlePurchase(contact) if (!canPurchase.value) return
})
async function handlePurchase(contact: { name?: string; email: string }) {
fiatError.value = null fiatError.value = null
const method = selectedMethod.value const method = selectedMethod.value
if (!method) return if (!method) return
const isGuest = !isAuthenticated.value // Lightning path: the composable just creates the invoice + starts
const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined // polling. The buyer picks "Pay with my LNbits wallet" or "Open in
const promoCode = promoCodeToSend.value // external wallet" on the invoice screen (restaurant pattern), so
// no auto-pay here.
if (guestNeedsLoginForDiscount.value) return
// Nothing to charge (100 %-off code on a paid event): skip the rail
// choice entirely — the backend issues the tickets already-paid and
// the composable lands on the success screen from `invoice.paid`.
if (isZeroTotal.value && !isFree.value) {
try {
await purchaseTicketForEvent(props.event.id, {
quantity: quantity.value,
contact: guestContact,
email: contact.email,
promoCode,
})
} catch (err) {
console.error('Error claiming discounted ticket:', err)
if (isPromoError(err)) void promo.revalidate()
}
return
}
// Lightning path: the composable creates the invoice + starts polling.
// The buyer pays from their LNbits wallet or an external one on the
// invoice screen (restaurant pattern), so no auto-pay here.
if (method.rail === 'lightning') { if (method.rail === 'lightning') {
try { try {
await purchaseTicketForEvent(props.event.id, { await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
quantity: quantity.value,
contact: guestContact,
email: contact.email,
promoCode,
})
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
if (isPromoError(err)) void promo.revalidate()
} }
return return
} }
// Fiat path: POST with the chosen provider, remember what we're // Fiat path: composable can't drive it (no QR / no bolt11). Hit the
// waiting for, then hand the tab to the provider's checkout. Stripe // API directly with the chosen provider, then redirect the buyer to
// brings the buyer back to the event page with ?checkout=success& // the provider's checkout URL. Payment confirmation happens via
// tickets=… (or ?checkout=cancelled) — see EventDetailPage. // webhook on the backend and shows up next time the buyer reloads
// MyTickets.
try { try {
isFiatPending.value = true isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = isGuest ? undefined : (lnbitsAPI?.getAccessToken?.() || undefined) const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const invoice = await ticketApi.requestTicket(props.event.id, { const currentUser = (lnbitsAPI?.currentUser?.value) || null
userId: isGuest ? undefined : currentUser.value?.id, const userId = currentUser?.id
name: guestContact?.name, if (!userId) {
email: contact.email, fiatError.value = 'Missing user id'
return
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
accessToken, accessToken,
frontendUrl: frontendRoot(), {
paymentMethod: 'fiat', paymentMethod: 'fiat',
fiatProvider: method.provider, fiatProvider: method.provider,
quantity: quantity.value, quantity: quantity.value,
promoCode, },
}) )
// The backend may still short-circuit to the free path (e.g. a code
// that became 100 % between preview and pay): no checkout URL, the
// tickets are already issued — show them instead of an error.
if (invoice.paid) {
paymentHash.value = invoice.paymentHash
await finalizePurchasedTickets(invoice.ticketIds ?? [])
isFiatPending.value = false
return
}
if (!invoice.isFiat || !invoice.fiatPaymentRequest) { if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = t('events.purchase.noCheckoutUrl') fiatError.value = 'Fiat provider did not return a checkout URL.'
isFiatPending.value = false
return return
} }
const pending: PendingCheckout = { fiatRedirectUrl.value = invoice.fiatPaymentRequest
eventId: props.event.id, fiatProviderLabel.value = invoice.fiatProvider
paymentHash: invoice.paymentHash, ? providerMeta(invoice.fiatProvider).label
ticketIds: invoice.ticketIds ?? [], : method.label
email: contact.email,
ts: Date.now(),
}
try {
localStorage.setItem(PENDING_CHECKOUT_KEY, JSON.stringify(pending))
} catch {
// Storage unavailable (private mode) — the return URL still
// carries the ticket ids, so the flow survives without it.
}
window.location.assign(invoice.fiatPaymentRequest)
// Keep the spinner up until the navigation lands.
} catch (err) { } catch (err) {
fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed') fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
} finally {
isFiatPending.value = false isFiatPending.value = false
if (isPromoError(err)) void promo.revalidate()
} }
} }
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
}
function handleClose() { function handleClose() {
emit('update:isOpen', false) emit('update:isOpen', false)
resetPaymentState() resetPaymentState()
promo.clear() selectedMethodId.value = 'lightning'
selectedMethodId.value = '' fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null fiatError.value = null
isFiatPending.value = false
quantity.value = 1 quantity.value = 1
copiedInvoice.value = false copiedInvoice.value = false
} }
@ -440,43 +257,35 @@ onUnmounted(() => {
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }} {{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<span v-if="isFree"> <span v-if="quantity > 1">
<template v-if="quantity > 1">{{ quantity }} free tickets for</template> {{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
<template v-else>Get a free ticket for</template> {{ formatEventPrice(totalPrice, event.currency) }}
<strong>{{ event.name }}</strong>
</span> </span>
<span v-else> <span v-else>
<template v-if="quantity > 1">{{ quantity }} tickets for</template> Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
<template v-else>Purchase a ticket for</template>
<strong>{{ event.name }}</strong> ·
<s v-if="hasDiscount" class="text-muted-foreground">{{ formatEventPrice(totalPrice, event.currency) }}</s>
<span :class="{ 'text-primary font-medium': hasDiscount }">
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
</span>
</span> </span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<!-- Free tickets need an account (anti-abuse). Paid tickets don't. --> <!-- Authentication Check -->
<div v-if="guestNeedsLogin" class="py-4 text-center space-y-4"> <div v-if="!isAuthenticated" class="py-4 text-center space-y-4">
<div class="flex justify-center"> <div class="flex justify-center">
<User class="w-12 h-12 text-muted-foreground" /> <User class="w-12 h-12 text-muted-foreground" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<h3 class="text-lg font-semibold">{{ t('events.purchase.loginRequiredTitle') }}</h3> <h3 class="text-lg font-semibold">Login Required</h3>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{{ t('events.purchase.loginRequiredFree') }} Please log in to your account to purchase tickets using your wallet.
</p> </p>
</div> </div>
<div class="flex gap-2 justify-center"> <Button @click="handleClose" variant="outline">
<Button @click="() => $router.push('/login')">{{ t('events.detail.logIn') }}</Button> Close
<Button @click="handleClose" variant="outline">Close</Button> </Button>
</div>
</div> </div>
<!-- Contact + payment details, then submit --> <!-- User Info and Purchase -->
<form v-else-if="!paymentHash" class="py-4 space-y-4" @submit="onSubmit"> <div v-else-if="!paymentHash" class="py-4 space-y-4">
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3"> <div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<User class="w-4 h-4 text-muted-foreground" /> <User class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Purchasing as:</span> <span class="text-sm font-medium">Purchasing as:</span>
@ -493,108 +302,8 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Contact: who the ticket is for + where to send it --> <!-- Wallet Information -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3"> <div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<Mail class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('events.purchase.contact.title') }}</span>
</div>
<FormField v-if="!isAuthenticated" v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.name') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" autocomplete="name" :placeholder="t('events.purchase.contact.namePlaceholder')" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.email') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="email" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
</FormControl>
<FormDescription class="text-xs">{{ t('events.purchase.contact.emailHint') }}</FormDescription>
<p class="text-xs font-medium text-destructive">{{ t('events.purchase.contact.spamHint') }}</p>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="confirmEmail">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.confirmEmail') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Promo code (paid events only). Apply asks the backend for
the discounted total; the badge + struck-through price
reflect the server's numbers, never a client guess. -->
<div v-if="!isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="flex items-center gap-2">
<Tag class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('events.purchase.promo.label') }}</span>
</div>
<div v-if="promoState.status === 'applied' || promoState.status === 'unverified'" class="flex items-center justify-between gap-2">
<Badge :variant="promoState.status === 'applied' ? 'default' : 'secondary'" class="font-mono">
<template v-if="promoState.status === 'applied'">
{{ t('events.purchase.promo.applied', { code: promoState.code, percent: promoState.discountPercent }) }}
</template>
<template v-else>{{ promoState.code }}</template>
</Badge>
<Button type="button" variant="ghost" size="sm" class="gap-1 h-7" @click="promo.clear()">
<X class="w-3.5 h-3.5" />
{{ t('events.purchase.promo.remove') }}
</Button>
</div>
<div v-else class="flex gap-2">
<Input
:model-value="promoInput"
:placeholder="t('events.purchase.promo.placeholder')"
class="font-mono uppercase"
autocapitalize="characters"
autocomplete="off"
spellcheck="false"
:disabled="promoChecking"
@input="onPromoInput"
@keydown.enter.prevent="promo.apply()"
/>
<Button
type="button"
variant="outline"
class="shrink-0"
:disabled="!promoInput || promoChecking"
@click="promo.apply()"
>
<Loader2 v-if="promoChecking" class="w-4 h-4 mr-2 animate-spin" />
{{ promoChecking ? t('events.purchase.promo.checking') : t('events.purchase.promo.apply') }}
</Button>
</div>
<p v-if="promoState.status === 'invalid'" class="text-xs text-destructive">
{{ t('events.purchase.promo.invalid') }}
</p>
<p v-else-if="promoState.status === 'unverified'" class="text-xs text-muted-foreground">
{{ t('events.purchase.promo.unverified') }}
</p>
<Alert v-if="guestNeedsLoginForDiscount" class="mt-2">
<User class="w-4 h-4" />
<AlertDescription class="flex flex-col gap-2">
<span>{{ t('events.purchase.promo.loginRequiredDiscount') }}</span>
<Button type="button" size="sm" class="w-fit" @click="() => $router.push('/login')">
{{ t('events.detail.logIn') }}
</Button>
</AlertDescription>
</Alert>
</div>
<!-- Wallet Information (logged-in Lightning buyers) -->
<div v-if="isAuthenticated && selectedMethod?.rail === 'lightning' && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Wallet class="w-4 h-4 text-muted-foreground" /> <Wallet class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Wallet Status:</span> <span class="text-sm font-medium">Wallet Status:</span>
@ -613,7 +322,7 @@ onUnmounted(() => {
<Badge v-else variant="secondary" class="text-xs">Empty</Badge> <Badge v-else variant="secondary" class="text-xs">Empty</Badge>
</div> </div>
</div> </div>
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-primary"> <div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600">
<Zap class="w-4 h-4" /> <Zap class="w-4 h-4" />
<span>Auto-payment available</span> <span>Auto-payment available</span>
</div> </div>
@ -631,7 +340,7 @@ onUnmounted(() => {
</div> </div>
<!-- Quantity selector — backend caps at 10. One invoice for <!-- Quantity selector — backend caps at 10. One invoice for
the whole purchase, N ticket rows (one per attendee). --> the whole purchase, one ticket row representing N seats. -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span> <span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -666,36 +375,28 @@ onUnmounted(() => {
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} {{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span> </span>
<span class="text-sm font-medium"> <span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
<template v-if="isFree">Free</template>
<template v-else-if="hasDiscount">
<s class="text-muted-foreground font-normal mr-1">{{ formatEventPrice(totalPrice, event.currency) }}</s>
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
</template>
<template v-else>{{ formatEventPrice(totalPrice, event.currency) }}</template>
</span>
</div>
<div v-if="hasDiscount && promoState.status === 'applied'" class="flex justify-between text-primary">
<span class="text-sm">{{ t('events.purchase.promo.discountLine', { code: promoState.code }) }}</span>
<span class="text-sm font-medium">−{{ formatEventPrice(promoDiscountAmount, event.currency) }}</span>
</div> </div>
<PriceConversionPreview <PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency" v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="effectiveTotal" :amount="totalPrice"
from="sat" from="sat"
:to="event.fiat_currency" :to="event.fiat_currency"
prefix="Equivalent ~" prefix="Equivalent ~"
suffix=" if paid by card" suffix=" if paid in fiat"
/> />
</div> </div>
</div> </div>
<!-- Payment method selector — one button per enabled rail / <!-- Payment method selector (only shown when fiat is enabled
configured fiat provider. Hidden when only one rail is on on the event). Buttons surface one per configured fiat
(nothing to choose) and for free tickets. --> provider so "Stripe" / "PayPal" / "Square" stand alongside
<div v-if="paymentMethods.length > 1 && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-2"> Lightning rather than collapsing into a single "Fiat"
catch-all. Hidden entirely for Lightning-only events to
keep the dialog uncluttered. -->
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Payment method</div> <div class="text-sm font-medium">Payment method</div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails. Both methods charge the same amount via different rails.
@ -713,29 +414,39 @@ onUnmounted(() => {
{{ error || fiatError }} {{ error || fiatError }}
</div> </div>
<!-- Fiat checkout panel — shown after a successful fiat
POST when we have a provider URL to redirect to. -->
<div v-if="fiatRedirectUrl" class="space-y-3">
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
<p class="text-xs text-muted-foreground">
Opens the provider's checkout in a new tab. Your ticket
appears in My Tickets once the payment settles.
</p>
</div>
<Button @click="openFiatCheckout" class="w-full">
<ExternalLink class="w-4 h-4 mr-2" />
Open {{ fiatProviderLabel }} checkout
</Button>
</div>
<Button <Button
type="submit" v-else
:disabled="isLoading || isFiatPending || promoChecking || guestNeedsLoginForDiscount || (paymentMethods.length === 0 && !isZeroTotal)" @click="handlePurchase"
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full" class="w-full"
> >
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<template v-else-if="isZeroTotal">
<Ticket class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
</template>
<template v-else-if="selectedMethod?.rail === 'fiat'"> <template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" /> <CreditCard class="w-4 h-4 mr-2" />
{{ t('events.purchase.continueTo', { provider: selectedMethod.label }) }} Continue to {{ selectedMethod.label }} checkout
</template> </template>
<template v-else> <template v-else>
<Zap class="w-4 h-4 mr-2" /> <Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }} {{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
</template> </template>
</Button> </Button>
<p v-if="selectedMethod?.rail === 'fiat' && !isZeroTotal" class="text-xs text-muted-foreground text-center"> </div>
{{ t('events.purchase.fiatRedirectHint') }}
</p>
</form>
<!-- Lightning invoice — restaurant-style. Shows QR + amount, <!-- Lightning invoice — restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the with both pay paths visible at once: tap-to-pay from the
@ -746,13 +457,8 @@ onUnmounted(() => {
<div class="text-center space-y-1"> <div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3> <h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
<template v-if="hasWalletWithBalance"> Scan with any Lightning wallet, or tap the button below to
Scan with any Lightning wallet, or tap the button below to pay from your LNbits wallet.
pay from your LNbits wallet.
</template>
<template v-else>
Scan with any Lightning wallet, or open it in your wallet app.
</template>
</p> </p>
</div> </div>
@ -771,7 +477,7 @@ onUnmounted(() => {
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<span class="text-xs text-muted-foreground">Amount</span> <span class="text-xs text-muted-foreground">Amount</span>
<span class="font-mono text-sm font-semibold text-primary"> <span class="font-mono text-sm font-semibold text-primary">
{{ formatEventPrice(effectiveTotal, event.currency) }} {{ formatEventPrice(totalPrice, event.currency) }}
<span v-if="quantity > 1" class="text-muted-foreground font-normal"> <span v-if="quantity > 1" class="text-muted-foreground font-normal">
({{ quantity }} tickets) ({{ quantity }} tickets)
</span> </span>
@ -836,20 +542,21 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Success state. Logged-in buyers get routed to My Tickets <!-- Success state. QRs live in My Tickets — no need to
(QRs live there); guests have no My Tickets, so their QRs pre-render them here; this view's job is to confirm the
render right here alongside the "emailed to" note. --> purchase landed and route the buyer to where they actually
interact with their tickets. -->
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4"> <div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
<div class="flex justify-center"> <div class="flex justify-center">
<Ticket class="w-12 h-12 text-primary" /> <Ticket class="w-12 h-12 text-green-600" />
</div> </div>
<div class="text-center space-y-2"> <div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-primary"> <h3 class="text-lg font-semibold text-green-600">
{{ purchasedTicketIds.length > 1 {{ purchasedTicketIds.length > 1
? `${purchasedTicketIds.length} tickets purchased!` ? `${purchasedTicketIds.length} tickets purchased!`
: 'Ticket purchased!' }} : 'Ticket purchased!' }}
</h3> </h3>
<p v-if="isAuthenticated" class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
<span v-if="purchasedTicketIds.length > 1"> <span v-if="purchasedTicketIds.length > 1">
Each attendee gets their own scannable QR in My Tickets — Each attendee gets their own scannable QR in My Tickets —
hand them out independently for the door scan. hand them out independently for the door scan.
@ -858,31 +565,10 @@ onUnmounted(() => {
Your ticket is now in My Tickets. Your ticket is now in My Tickets.
</span> </span>
</p> </p>
<p v-if="form.values.email" class="text-sm text-muted-foreground">
{{ t('events.checkout.emailedTo', { email: form.values.email }) }}
</p>
</div>
<div v-if="!isAuthenticated" class="w-full space-y-3">
<div
v-for="(id, idx) in purchasedTicketIds"
:key="id"
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
>
<span v-if="purchasedTicketIds.length > 1" class="text-xs text-muted-foreground">
{{ t('events.checkout.ticketN', { n: idx + 1, total: purchasedTicketIds.length }) }}
</span>
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
<img v-if="ticketQRCodes[id]" :src="ticketQRCodes[id]" :alt="`Ticket ${id}`" class="block h-40 w-40" />
</div>
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
{{ t('events.checkout.viewTicket') }}
</RouterLink>
</div>
</div> </div>
<div class="space-y-3 w-full"> <div class="space-y-3 w-full">
<Button v-if="isAuthenticated" @click="() => $router.push('/my-tickets')" class="w-full"> <Button @click="() => $router.push('/my-tickets')" class="w-full">
View My Tickets View My Tickets
</Button> </Button>
<Button variant="outline" @click="handleClose" class="w-full"> <Button variant="outline" @click="handleClose" class="w-full">

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Check, HelpCircle, X } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { useRSVP } from '../composables/useRSVP'
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
const props = defineProps<{
pubkey: string
dTag: string
kind?: number
}>()
const router = useRouter()
const { t } = useI18n()
const { isAuthenticated } = useAuth()
const { getMyRSVP, getRSVPCount, setRSVP, isPending } = useRSVP()
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const myStatus = computed(() => getMyRSVP(eventKind.value, props.pubkey, props.dTag))
const goingCount = computed(() => getRSVPCount(eventKind.value, props.pubkey, props.dTag))
const pending = computed(() => isPending(eventKind.value, props.pubkey, props.dTag))
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
{ status: 'accepted', labelKey: 'events.detail.going', icon: Check },
{ status: 'tentative', labelKey: 'events.detail.maybe', icon: HelpCircle },
{ status: 'declined', labelKey: 'events.detail.notGoing', icon: X },
]
const statusLabel: Record<RSVPStatus, string> = {
accepted: "You're going",
tentative: 'Marked as maybe',
declined: "You're not going",
}
async function handleClick(status: RSVPStatus) {
if (!isAuthenticated.value) {
toast.info('Log in to RSVP', {
action: {
label: 'Log in',
onClick: () => router.push('/login'),
},
})
return
}
const published = await setRSVP(eventKind.value, props.pubkey, props.dTag, status)
if (published) {
toast.success(statusLabel[published])
} else if (!pending.value) {
// setRSVP returned null AND we're no longer pending → publish failed
// (vs. throttled, where pending was true at the time of the click).
toast.error("Couldn't save RSVP — try again")
}
}
</script>
<template>
<div class="space-y-2">
<div class="flex gap-2">
<Button
v-for="btn in buttons"
:key="btn.status"
:variant="myStatus === btn.status ? 'default' : 'outline'"
:disabled="pending"
size="sm"
class="flex-1 gap-1.5"
@click="handleClick(btn.status)"
>
<component :is="btn.icon" class="w-3.5 h-3.5" />
{{ t(btn.labelKey) }}
</Button>
</div>
<p v-if="goingCount > 0" class="text-xs text-muted-foreground">
{{ goingCount }} {{ goingCount === 1 ? 'person' : 'people' }} going
</p>
</div>
</template>

View file

@ -1,17 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { History } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { TemporalFilter } from '../types/filters' import type { TemporalFilter } from '../types/filters'
const props = defineProps<{ const props = defineProps<{
modelValue: TemporalFilter modelValue: TemporalFilter
showPast: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: TemporalFilter] 'update:modelValue': [value: TemporalFilter]
'toggle-past': []
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@ -26,33 +23,16 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
</script> </script>
<template> <template>
<!-- pb-1 pr-1 keep the theme's offset drop-shadow (neobrut casts a hard 4px <div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
shadow down and to the right) from being clipped at the scroll box's
bottom/right edges (overflow-x-auto forces overflow-y to auto). -->
<div class="flex gap-2 overflow-x-auto pb-1 pr-1" style="-ms-overflow-style: none; scrollbar-width: none;">
<Button <Button
v-for="option in options" v-for="option in options"
:key="option.value" :key="option.value"
:variant="props.modelValue === option.value ? 'default' : 'outline'" :variant="props.modelValue === option.value ? 'default' : 'outline'"
size="sm" size="sm"
class="text-xs shrink-0" class="rounded-full text-xs shrink-0"
@click="emit('update:modelValue', option.value)" @click="emit('update:modelValue', option.value)"
> >
{{ t(option.labelKey) }} {{ t(option.labelKey) }}
</Button> </Button>
<!-- Past pill sits at the end of the same scrollable strip so it's
discoverable alongside the time-window pills, without claiming
a dropdown row of its own. Composes orthogonally with the
temporal pills: e.g. "This Week" + Past narrows to days
already past this week. -->
<Button
:variant="props.showPast ? 'default' : 'outline'"
size="sm"
class="text-xs shrink-0 gap-1.5"
@click="emit('toggle-past')"
>
<History class="w-3 h-3" />
{{ t('events.filters.past', 'Past') }}
</Button>
</div> </div>
</template> </template>

View file

@ -3,7 +3,6 @@ import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing' import { signEventViaLnbits } from '@/lib/nostr/signing'
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
/** /**
* NIP-51 Bookmarks (kind 10003) for saving favorite events. * NIP-51 Bookmarks (kind 10003) for saving favorite events.
@ -22,16 +21,12 @@ interface BookmarkState {
bookmarkedCoords: Set<string> bookmarkedCoords: Set<string>
/** The latest bookmark event we've seen */ /** The latest bookmark event we've seen */
lastEventId: string | null lastEventId: string | null
/** `created_at` of the latest bookmark event — used to publish a
* strictly-newer timestamp so relays push the update to open subs. */
lastCreatedAt: number | null
} }
// Shared state across all component instances // Shared state across all component instances
const state = ref<BookmarkState>({ const state = ref<BookmarkState>({
bookmarkedCoords: new Set(), bookmarkedCoords: new Set(),
lastEventId: null, lastEventId: null,
lastCreatedAt: null,
}) })
const isLoaded = ref(false) const isLoaded = ref(false)
@ -70,7 +65,7 @@ export function useBookmarks() {
}], }],
onEvent: (event: NostrEvent) => { onEvent: (event: NostrEvent) => {
// Only process if newer than what we have // Only process if newer than what we have
if (state.value.lastCreatedAt != null && event.created_at <= state.value.lastCreatedAt) return if (state.value.lastEventId && event.created_at <= (state.value as any).lastCreatedAt) return
const coords = new Set<string>() const coords = new Set<string>()
for (const tag of event.tags) { for (const tag of event.tags) {
@ -81,8 +76,8 @@ export function useBookmarks() {
state.value = { state.value = {
bookmarkedCoords: coords, bookmarkedCoords: coords,
lastEventId: event.id, lastEventId: event.id,
lastCreatedAt: event.created_at,
} }
;(state.value as any).lastCreatedAt = event.created_at
isLoaded.value = true isLoaded.value = true
}, },
onEose: () => { onEose: () => {
@ -93,20 +88,9 @@ export function useBookmarks() {
/** /**
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list. * Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
*
* Updates local state OPTIMISTICALLY so the UI (heart fill) responds
* instantly, then signs + publishes in the background. Signing routes
* through the remote LNbits signer and publishing hits relays, so
* awaiting both before flipping state made the heart lag ~1s. On
* failure the optimistic change is rolled back. Resolves to whether
* the change was persisted.
*/ */
async function toggleBookmark( async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
eventKind: number, if (!isAuthenticated.value || !currentUser.value?.pubkey) return
pubkey: string,
dTag: string,
): Promise<boolean> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return false
const coord = `${eventKind}:${pubkey}:${dTag}` const coord = `${eventKind}:${pubkey}:${dTag}`
const newCoords = new Set(state.value.bookmarkedCoords) const newCoords = new Set(state.value.bookmarkedCoords)
@ -117,29 +101,12 @@ export function useBookmarks() {
newCoords.add(coord) newCoords.add(coord)
} }
// Optimistic flip — preserve the prior state so we can roll back if // Build and publish updated bookmark list
// signing or publishing fails. Keep lastEventId/lastCreatedAt until
// the real event is confirmed.
const prevState = state.value
state.value = {
bookmarkedCoords: newCoords,
lastEventId: prevState.lastEventId,
lastCreatedAt: prevState.lastCreatedAt,
}
function rollback() {
state.value = prevState
}
// Build and publish updated bookmark list. Use a strictly-monotonic
// created_at so a same-second re-toggle still outranks the prior
// version and relays push it to open subscriptions (a bare
// floor(Date.now()/1000) can tie and be silently dropped).
const tags: string[][] = Array.from(newCoords).map(c => ['a', c]) const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
const template: EventTemplate = { const template: EventTemplate = {
kind: BOOKMARK_KIND, kind: BOOKMARK_KIND,
created_at: monotonicCreatedAt(prevState.lastCreatedAt), created_at: Math.floor(Date.now() / 1000),
content: '', content: '',
tags, tags,
} }
@ -149,28 +116,19 @@ export function useBookmarks() {
signedEvent = await signEventViaLnbits(template) signedEvent = await signEventViaLnbits(template)
} catch (err) { } catch (err) {
console.error('[useBookmarks] signEventViaLnbits failed:', err) console.error('[useBookmarks] signEventViaLnbits failed:', err)
rollback() return
return false
} }
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB) const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) { if (!relayHub) return
rollback()
return false
}
const result = await relayHub.publishEvent(signedEvent) const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) { if (result.success > 0) {
state.value = { state.value = {
bookmarkedCoords: newCoords, bookmarkedCoords: newCoords,
lastEventId: signedEvent.id, lastEventId: signedEvent.id,
lastCreatedAt: template.created_at,
} }
return true
} }
rollback()
return false
} }
onMounted(() => { onMounted(() => {

View file

@ -19,46 +19,36 @@ export function useEventDetail(eventId: string) {
) )
async function load() { async function load() {
// Already in cache
if (event.value) return
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE) const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
if (!nostrService) { if (!nostrService) {
error.value = 'Events service not available' error.value = 'Events service not available'
return return
} }
// Scope both the subscription and the one-shot query to this
// event's d-tag. Without this scope, the query asks every
// relay for every kind-31922/31923 event and races a 5s timeout
// to find ours — on a cold page refresh that race is often lost
// even when the event is reachable.
const detailFilters = { dTags: [eventId] }
// Subscribe for LIVE updates regardless of cache state. NIP-52
// calendar events are replaceable, so when the events extension
// republishes after a ticket sells (updating tickets_sold /
// tickets_available — see events services.py), the new version
// arrives here and the reactive `event` (and its ticket counts)
// updates without a reload. Subscribing only on a cache miss meant
// arriving from the feed (event already cached) left the detail
// page with no live subscription, so counts went stale until reload.
if (unsubscribe) unsubscribe() // avoid leaking a sub if load() re-runs
unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => {
store.upsertEvent(incoming)
if (incoming.id === eventId) {
isLoading.value = false
}
},
detailFilters
)
// Already cached — the subscription above keeps it fresh; skip the
// one-shot query + loading state.
if (event.value) return
try { try {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
// Scope both the subscription and the one-shot query to this
// event's d-tag. Without this scope, the query asks every
// relay for every kind-31922/31923 event and races a 5s timeout
// to find ours — on a cold page refresh that race is often lost
// even when the event is reachable.
const detailFilters = { dTags: [eventId] }
unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => {
store.upsertEvent(incoming)
if (incoming.id === eventId) {
isLoading.value = false
}
},
detailFilters
)
const results = await nostrService.queryCalendarEvents(detailFilters) const results = await nostrService.queryCalendarEvents(detailFilters)
store.upsertEvents(results) store.upsertEvents(results)

View file

@ -1,32 +1,42 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { import {
startOfDay, endOfDay, startOfWeek, endOfWeek, startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays, startOfMonth, endOfMonth, addDays, isSameDay,
} from 'date-fns' } from 'date-fns'
import type { Event } from '../types/event' import type { Event } from '../types/event'
import type { EventCategory } from '../types/category' import type { EventCategory } from '../types/category'
import type { TemporalFilter, EventFilters } from '../types/filters' import type { TemporalFilter, EventFilters } from '../types/filters'
import { DEFAULT_FILTERS } from '../types/filters' import { DEFAULT_FILTERS } from '../types/filters'
// Filter state is hoisted to module scope so every `useEvents()` /
// `useEventFilters()` call shares the same refs. The bottom-nav
// Hosting tab in events-app/App.vue and the feed view in
// EventsPage.vue both rely on this — without a shared instance,
// tapping Hosting toggled a private ref the page never saw.
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<EventCategory[]>([])
// A specific day picked from the calendar popup. When set it takes
// priority over the temporal pills + past/upcoming split (browse any
// single day). Cleared independently of categories.
const selectedDate = ref<Date | undefined>(undefined)
const onlyOwnedTickets = ref(false)
const onlyHosting = ref(false)
const showPast = ref(false)
/** /**
* Composable for managing event filter state and applying filters reactively. * Composable for managing event filter state and applying filters reactively.
*/ */
export function useEventFilters() { export function useEventFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<EventCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined)
/**
* When true, the feed is narrowed to events the current user
* holds at least one paid ticket for. Crossed with the
* `ownedEventIds` set from useOwnedTickets in useEvents
* (this composable stays free of ticket fetching).
*/
const onlyOwnedTickets = ref(false)
/**
* When true, the feed is narrowed to events the current user
* is hosting (organizer pubkey matches the signed-in user, or the
* row is a local LNbits draft of theirs). Reads `event.isMine`
* which `useEvents.tagOwnership()` populates.
*/
const onlyHosting = ref(false)
/**
* When false (default), events that have already ended are
* hidden from the feed. Toggling on includes them so the user can
* browse past events. The date-picker overrides this — picking a
* specific past date shows that day's events regardless,
* mirroring how it overrides the temporal pills.
*/
const showPast = ref(false)
const filters = computed<EventFilters>(() => ({ const filters = computed<EventFilters>(() => ({
temporal: temporal.value, temporal: temporal.value,
@ -39,10 +49,10 @@ export function useEventFilters() {
function applyFilters(events: Event[]): Event[] { function applyFilters(events: Event[]): Event[] {
let result = events let result = events
// Specific date filter (from DatePickerStrip) takes priority over
// temporal. Picking a date also bypasses the past/upcoming split
// so the user can browse events for any day they choose.
if (selectedDate.value) { if (selectedDate.value) {
// Specific day picked from the calendar popup — takes priority over
// the temporal pills and bypasses the past/upcoming split so any
// day (past or future) can be browsed.
const dayStart = startOfDay(selectedDate.value) const dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value) const dayEnd = endOfDay(selectedDate.value)
result = result.filter(a => { result = result.filter(a => {
@ -50,9 +60,8 @@ export function useEventFilters() {
return a.startDate <= dayEnd && eventEnd >= dayStart return a.startDate <= dayEnd && eventEnd >= dayStart
}) })
} else { } else {
// Temporal filter (preset pills). // Temporal filter
result = applyTemporalFilter(result, temporal.value) result = applyTemporalFilter(result, temporal.value)
// Past/upcoming split — the chip narrows to one side of "now", // Past/upcoming split — the chip narrows to one side of "now",
// mirroring the "My tickets" / "Hosting" mental model. Default // mirroring the "My tickets" / "Hosting" mental model. Default
// (showPast=false) is upcoming-only; toggling on flips to // (showPast=false) is upcoming-only; toggling on flips to
@ -84,16 +93,16 @@ export function useEventFilters() {
function setTemporal(value: TemporalFilter) { function setTemporal(value: TemporalFilter) {
temporal.value = value temporal.value = value
selectedDate.value = undefined // a preset pill clears the day pick selectedDate.value = undefined // clear date pick when using temporal pills
} }
function selectDate(date: Date) { function selectDate(date: Date) {
selectedDate.value = date if (selectedDate.value && isSameDay(selectedDate.value, date)) {
temporal.value = 'all' // a specific day overrides the temporal pill selectedDate.value = undefined // toggle off
} } else {
selectedDate.value = date
function clearSelectedDate() { temporal.value = 'all' // clear temporal pill when picking a specific date
selectedDate.value = undefined }
} }
function toggleCategory(category: EventCategory) { function toggleCategory(category: EventCategory) {
@ -154,7 +163,6 @@ export function useEventFilters() {
applyFilters, applyFilters,
setTemporal, setTemporal,
selectDate, selectDate,
clearSelectedDate,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets, toggleOwnedTickets,

Some files were not shown because too many files have changed in this diff Show more