Compare commits

..

No commits in common. "feat/events-organizer-sender" and "main" have entirely different histories.

113 changed files with 1827 additions and 5734 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,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",
@ -95,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",
@ -109,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,16 +96,16 @@
<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>
@ -122,6 +118,7 @@
step="0.01" step="0.01"
/> />
</FormControl> </FormControl>
<FormDescription>Amount in selected currency</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@ -131,7 +128,7 @@
<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>
@ -141,17 +138,18 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>Currency for this income</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
</div>
<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

@ -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,116 +1,36 @@
<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
bottom while only the region above it scrolls. The sheet host already
has overflow-y-auto, but with an exact-fit child it never triggers —
the inner flex-1 region owns the scroll instead. -->
<div class="flex h-full flex-col">
<!-- Scrollable region: everything above the pinned footer. min-h-0 lets
this flex child shrink below its content height so it can scroll. -->
<div class="flex-1 min-h-0 overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle> <SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
<SheetDescription v-if="isAuthenticated"> <SheetDescription v-if="isAuthenticated">
@ -121,157 +41,46 @@ async function onLogout() {
</SheetDescription> </SheetDescription>
</SheetHeader> </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">
<Avatar class="h-12 w-12 shrink-0">
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" /> <AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback> <AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
</Avatar> </Avatar>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</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"> <p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p>
@{{ 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>
</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) --> <!-- Cross-app links + global preferences (always visible, auth or not) -->
<div class="mt-4"> <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 <a
:href="hubRootUrl" :href="hubRootUrl"
class="flex items-center justify-center gap-3 px-3 py-1.5 hover:bg-accent rounded-md transition-colors" class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors"
:aria-label="t('common.nav.backToHub')" :aria-label="t('common.nav.backToHub')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img src="@brand-hub-logo" :alt="t('common.nav.backToHub')" class="w-8 h-8 shrink-0" /> <Home class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span> <span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
</div> </div>
</a> </a>
<!-- Logged-out: prominent log-in CTA --> <PreferencesRow layout="list" />
<div v-if="!isAuthenticated"> </div>
<Separator class="mb-2" />
<!-- 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"> <Button class="w-full" @click="goLogin">
<LogIn class="mr-2 h-4 w-4" /> <LogIn class="mr-2 h-4 w-4" />
{{ t('common.nav.login') }} {{ t('common.nav.login') }}
</Button> </Button>
</div> </div>
<!-- Logged-in: log-out button stays visible without opening the edit popup. --> <!-- Logged-in: full profile management form -->
<div v-else> <div v-else class="mt-6">
<Separator class="mb-2" /> <Separator class="mb-4" />
<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>
<!-- Edit-profile popup (pencil button in the identity card) — the full
form lives here so the sheet stays scannable. Outside the flex root:
its content portals to <body>, so it's not part of the sheet flow. -->
<Dialog v-model:open="editProfileOpen">
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
<DialogHeader>
<DialogTitle>{{ t('common.nav.editProfile', 'Edit profile') }}</DialogTitle>
<DialogDescription>
{{ t('common.nav.editProfileDescription', 'Update your display name and profile picture.') }}
</DialogDescription>
</DialogHeader>
<ProfileSettings /> <ProfileSettings />
</DialogContent> </div>
</Dialog>
</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

@ -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'),
}, },
isActive: () => inFeedRoute() && !onlyHosting.value, })
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
},
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',
@ -69,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',
@ -103,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',
@ -129,55 +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.',
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',
@ -249,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',
@ -69,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',
@ -103,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',
@ -129,53 +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.',
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',
@ -249,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',
@ -69,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',
@ -103,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',
@ -129,55 +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.',
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',
@ -249,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
@ -70,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
@ -104,53 +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
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
@ -226,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

@ -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,26 +17,22 @@
<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"
@ -40,7 +45,6 @@
accept="image/*" accept="image/*"
/> />
</div> </div>
</div>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@ -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,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

@ -41,15 +41,7 @@ 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'
@ -71,10 +63,6 @@ interface Props {
autoApprove?: boolean autoApprove?: boolean
} }
// Instance name used in the From display name of ticket emails
// (the LNbits site title on the backend; the standalone's brand here).
const siteName = (import.meta.env.VITE_APP_NAME as string) || 'Events'
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:open': [value: boolean] 'update:open': [value: boolean]
@ -136,25 +124,14 @@ 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),
email_notifications: z.boolean().default(false), email_notifications: z.boolean().default(false),
nostr_notifications: z.boolean().default(false), nostr_notifications: z.boolean().default(false),
copy_to_organizer: z.boolean().default(false),
notification_subject: z.string().max(200).default(''), notification_subject: z.string().max(200).default(''),
notification_body: z.string().max(2000).default(''), notification_body: z.string().max(2000).default(''),
organizer_name: z.string().trim().max(100).default(''),
reply_to_email: z
.string()
.trim()
.default('')
.refine((v) => v === '' || z.string().email().safeParse(v).success, 'Invalid email address'),
}) })
.superRefine((v, ctx) => { .superRefine((v, ctx) => {
// End must not precede start. Compare on the folded date+time // End must not precede start. Compare on the folded date+time
@ -175,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'],
@ -196,56 +173,17 @@ 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,
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
copy_to_organizer: false,
notification_subject: '', notification_subject: '',
notification_body: '', notification_body: '',
organizer_name: '',
reply_to_email: '',
} }
}) })
/** 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
} }
@ -305,17 +243,14 @@ 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,
email_notifications: event.extra?.email_notifications ?? false, email_notifications: event.extra?.email_notifications ?? false,
nostr_notifications: event.extra?.nostr_notifications ?? false, nostr_notifications: event.extra?.nostr_notifications ?? false,
copy_to_organizer: event.extra?.copy_to_organizer ?? false,
notification_subject: event.extra?.notification_subject ?? '', notification_subject: event.extra?.notification_subject ?? '',
notification_body: event.extra?.notification_body ?? '', notification_body: event.extra?.notification_body ?? '',
organizer_name: event.extra?.organizer_name ?? '',
reply_to_email: event.extra?.reply_to_email ?? '',
}) })
selectedCategories.value = [...(event.categories ?? [])] selectedCategories.value = [...(event.categories ?? [])]
if (event.banner) { if (event.banner) {
@ -419,12 +354,11 @@ 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
@ -437,14 +371,10 @@ const onSubmit = form.handleSubmit(async (formValues) => {
// survive the round-trip. // survive the round-trip.
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,
copy_to_organizer: formValues.copy_to_organizer,
notification_subject: formValues.notification_subject, notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body, notification_body: formValues.notification_body,
organizer_name: formValues.organizer_name,
reply_to_email: formValues.reply_to_email || null,
} }
if (isEditMode.value) { if (isEditMode.value) {
@ -502,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" />
@ -681,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" />
@ -698,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" />
@ -709,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>
@ -730,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>
<FormLabel class="text-sm font-medium">Payment methods</FormLabel> <p class="text-sm font-medium">Payment methods</p>
<FormDescription class="text-xs"> <p class="text-xs text-muted-foreground">
Pick the rails buyers can pay with. Turn Lightning off for a Lightning is always available. Enable fiat to also accept
card-only sale. card and bank payments through your configured provider.
</FormDescription> </p>
</div> </div>
<div class="space-y-2"> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<FormField <Zap class="w-4 h-4" />
v-for="opt in PAYMENT_METHOD_OPTIONS" <span>Lightning — always on</span>
:key="opt.id" </div>
v-slot="{ value, handleChange }" <FiatToggleField
type="checkbox" allow-fiat-field="allow_fiat"
:value="opt.id" fiat-currency-field="fiat_currency"
:unchecked-value="false" :denomination="form.values.currency ?? 'sat'"
name="payment_methods" :available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
>
<FormItem class="flex flex-row items-center gap-3 space-y-0 rounded-md border p-3">
<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" :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>
</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
@ -846,18 +711,6 @@ const handleOpenChange = (open: boolean) => {
</FormControl> </FormControl>
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ value, handleChange }" name="copy_to_organizer">
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3 sm:col-span-2">
<div class="space-y-0.5">
<FormLabel class="text-sm">Send me a copy of each ticket email</FormLabel>
<FormDescription class="text-xs">Goes to the reply-to address below, or your account email.</FormDescription>
</div>
<FormControl>
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div> </div>
<FormField v-slot="{ componentField }" name="notification_subject"> <FormField v-slot="{ componentField }" name="notification_subject">
@ -883,58 +736,15 @@ const handleOpenChange = (open: boolean) => {
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Sender identity: the From address stays the instance
mailbox (that is what DKIM signs); the organizer only
controls the display name and where replies land. -->
<FormField v-slot="{ componentField }" name="organizer_name">
<FormItem>
<FormLabel class="text-sm">Organizer name</FormLabel>
<FormControl>
<Input placeholder="Château du Faune" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">
Ticket emails arrive from "Organizer name via {{ siteName }}".
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="reply_to_email">
<FormItem>
<FormLabel class="text-sm">Reply-to email</FormLabel>
<FormControl>
<Input type="email" inputmode="email" autocapitalize="none" placeholder="you@example.com" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">
Where attendee replies go. Leave blank to use your account email.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<!-- Actions. Stack full-width on narrow viewports so a wide <!-- Actions -->
localized "Submit Event" label can't push the Cancel <div class="flex justify-end gap-3 pt-2">
button out of the dialog. Submit reads top-down on <Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
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,26 +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 {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { useTicketPurchase } from '../composables/useTicketPurchase' import { useTicketPurchase } from '../composables/useTicketPurchase'
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 } 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,
@ -28,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: {
@ -41,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
} }
@ -54,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,
@ -65,6 +45,7 @@ const {
qrCode, qrCode,
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
canPurchase,
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,
purchaseTicketForEvent, purchaseTicketForEvent,
@ -73,7 +54,6 @@ const {
resetPaymentState, resetPaymentState,
cleanup, cleanup,
purchasedTicketIds, purchasedTicketIds,
ticketQRCodes,
showTicketQR showTicketQR
} = useTicketPurchase() } = useTicketPurchase()
@ -90,16 +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)
async function copyInvoice() { async function copyInvoice() {
if (!paymentRequest.value) return if (!paymentRequest.value) return
try { try {
@ -112,68 +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: '' },
})
},
{ 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',
) )
@ -201,9 +119,7 @@ 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')) {
entries.push({
id: 'lightning', id: 'lightning',
rail: 'lightning', rail: 'lightning',
label: 'Lightning', label: 'Lightning',
@ -213,134 +129,116 @@ const paymentMethods = computed<PaymentMethodEntry[]>(() => {
!isPriceInSats.value && lightningSats.value !isPriceInSats.value && lightningSats.value
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats` ? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined, : undefined,
})
} }
if (canChooseFiat.value) { if (!props.event.allow_fiat) return [lightning]
if (providers.value.length > 0) { if (providers.value.length > 0) {
for (const id of providers.value) { const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
const meta = providerMeta(id) const meta = providerMeta(id)
entries.push({ return {
id: `fiat:${id}`, id: `fiat:${id}`,
rail: 'fiat', rail: 'fiat',
provider: id, provider: id,
label: meta.label, label: meta.label,
icon: iconFor(meta.icon), icon: iconFor(meta.icon),
available: true, available: true,
})
} }
} else { })
// Guests (and sessions without provider info) can't enumerate the return [lightning, ...fiatRails]
// organizer's providers. Show a generic Card button and let the }
// backend pick the organizer's default at request time.
entries.push({ // 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', id: 'fiat',
rail: 'fiat', rail: 'fiat',
label: t('events.purchase.card'), label: 'Card',
icon: CreditCard, icon: CreditCard,
available: true, available: true,
})
}
}
return entries
})
// Default rail: guests lean to card when it's on offer (no wallet to
// pay a bolt11 with); everyone else takes the first enabled rail.
watch(
[paymentMethods, () => props.isOpen, isAuthenticated],
([methods, open, authed]) => {
if (!open || methods.length === 0) return
const current = methods.find((m) => m.id === selectedMethodId.value)
if (current) return
const preferred = !authed ? methods.find((m) => m.rail === 'fiat') : undefined
selectedMethodId.value = (preferred ?? methods[0]).id
}, },
{ immediate: 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
// external wallet" on the invoice screen (restaurant pattern), so
// Lightning path: the composable creates the invoice + starts polling. // no auto-pay here.
// 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,
})
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
} }
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,
}) },
)
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
} }
} }
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()
selectedMethodId.value = '' selectedMethodId.value = 'lightning'
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
} }
@ -361,10 +259,7 @@ onUnmounted(() => {
<DialogDescription> <DialogDescription>
<span v-if="quantity > 1"> <span v-if="quantity > 1">
{{ quantity }} tickets for <strong>{{ event.name }}</strong> · {{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }} {{ formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else-if="isFree">
Get a free ticket for <strong>{{ event.name }}</strong>
</span> </span>
<span v-else> <span v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }} Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
@ -372,26 +267,25 @@ onUnmounted(() => {
</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>
@ -408,45 +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>
<!-- Wallet Information (logged-in Lightning buyers) -->
<div v-if="isAuthenticated && selectedMethod?.rail === 'lightning' && !isFree" 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>
@ -465,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>
@ -483,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">
@ -518,9 +375,9 @@ 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">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span> <span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
</div> </div>
<PriceConversionPreview <PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency" v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
@ -528,15 +385,18 @@ onUnmounted(() => {
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 && !isFree" 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.
@ -554,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 || paymentMethods.length === 0 && !isFree" @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="isFree">
<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' && !isFree" 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
@ -587,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>
@ -677,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.
@ -699,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,12 +19,19 @@ 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
} }
try {
isLoading.value = true
error.value = null
// Scope both the subscription and the one-shot query to this // Scope both the subscription and the one-shot query to this
// event's d-tag. Without this scope, the query asks every // event's d-tag. Without this scope, the query asks every
// relay for every kind-31922/31923 event and races a 5s timeout // relay for every kind-31922/31923 event and races a 5s timeout
@ -32,15 +39,6 @@ export function useEventDetail(eventId: string) {
// even when the event is reachable. // even when the event is reachable.
const detailFilters = { dTags: [eventId] } 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( unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => { (incoming) => {
store.upsertEvent(incoming) store.upsertEvent(incoming)
@ -51,14 +49,6 @@ export function useEventDetail(eventId: string) {
detailFilters detailFilters
) )
// Already cached — the subscription above keeps it fresh; skip the
// one-shot query + loading state.
if (event.value) return
try {
isLoading.value = true
error.value = null
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) {
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
selectedDate.value = undefined // toggle off
} else {
selectedDate.value = date selectedDate.value = date
temporal.value = 'all' // a specific day overrides the temporal pill temporal.value = 'all' // clear temporal pill when picking a specific date
} }
function clearSelectedDate() {
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,

View file

@ -1,148 +0,0 @@
import { reactive } from 'vue'
import type { Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
/**
* Live "like" counts for events. A like == the event appearing in a
* user's NIP-51 bookmark list (kind 10003) — the same action the heart
* performs (and what the Favorites page reads).
*
* Counting bookmark lists by `#a` alone can't see un-likes: when someone
* removes an event their new (replaceable) list no longer contains the
* coord, so it stops matching the `#a` filter and never arrives. To make
* decrements real-time too we ALSO watch every known liker by `authors`,
* and track each author's current set of liked coords — so a list that
* drops a coord is delivered and we remove that author from the count.
*
* - `#a` filter → discovers NEW likers (lists containing a tracked coord).
* - `authors` filter → catches updates (add AND remove) from anyone we've
* already counted, including their last un-like.
*
* Both feed one handler that diffs the author's previous vs current liked
* coords. The current user's own like/un-like also updates instantly via
* setSelf(), kept consistent with the same per-author bookkeeping.
*/
const BOOKMARK_KIND = 10003
const counts = reactive(new Map<string, number>()) // coord -> count (reactive, for the UI)
const authorsByCoord = new Map<string, Set<string>>() // coord -> pubkeys who like it (count source)
const authorCoords = new Map<string, Set<string>>() // pubkey -> tracked coords in their latest list
const authorSeenAt = new Map<string, number>() // pubkey -> created_at of latest processed list
const knownAuthors = new Set<string>() // everyone we've seen like a tracked coord
const tracked = new Set<string>()
let unsubscribe: (() => void) | null = null
let resubTimer: ReturnType<typeof setTimeout> | null = null
function addAuthor(coord: string, pubkey: string) {
let set = authorsByCoord.get(coord)
if (!set) {
set = new Set()
authorsByCoord.set(coord, set)
}
if (!set.has(pubkey)) {
set.add(pubkey)
counts.set(coord, set.size)
}
}
function removeAuthor(coord: string, pubkey: string) {
const set = authorsByCoord.get(coord)
if (set && set.delete(pubkey)) counts.set(coord, set.size)
}
function handleEvent(event: NostrEvent) {
const pk = event.pubkey
const seenAt = authorSeenAt.get(pk) ?? -1
// Ignore strictly-older replaceable lists; reprocess same/newer (so a
// re-delivery after we start tracking a new coord still credits it).
if (event.created_at < seenAt) return
authorSeenAt.set(pk, Math.max(seenAt, event.created_at))
// Tracked coords this list currently references.
const next = new Set<string>()
for (const tag of event.tags) {
if (tag[0] === 'a' && tracked.has(tag[1])) next.add(tag[1])
}
const prev = authorCoords.get(pk) ?? new Set<string>()
// Removals (un-likes): present before, gone now.
for (const coord of prev) if (!next.has(coord)) removeAuthor(coord, pk)
// Additions (likes): new this time.
for (const coord of next) if (!prev.has(coord)) addAuthor(coord, pk)
authorCoords.set(pk, next)
// Watch this liker by author from now on so their future un-like (which
// wouldn't match the #a filter) still reaches us.
if (next.size && !knownAuthors.has(pk)) {
knownAuthors.add(pk)
scheduleResubscribe()
}
}
function resubscribe() {
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) {
scheduleResubscribe() // relay hub not registered yet — retry shortly
return
}
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
const coords = [...tracked]
if (coords.length === 0) return
const filters: Record<string, unknown>[] = [{ kinds: [BOOKMARK_KIND], '#a': coords }]
// Also watch known likers by author, so an un-like (a list that drops
// the coord, no longer matching #a) is still delivered and decrements.
if (knownAuthors.size) filters.push({ kinds: [BOOKMARK_KIND], authors: [...knownAuthors] })
unsubscribe = relayHub.subscribe({
id: 'event-likes-aggregate',
filters,
onEvent: (event: NostrEvent) => handleEvent(event),
})
}
function scheduleResubscribe() {
if (resubTimer) clearTimeout(resubTimer)
// Debounced so a burst of mounting hearts / discovered likers results
// in one (re)subscribe.
resubTimer = setTimeout(resubscribe, 300)
}
export function useEventLikes() {
/** Register an event coordinate so its like count is fetched + kept live. */
function track(coord: string) {
if (!coord || tracked.has(coord)) return
tracked.add(coord)
scheduleResubscribe()
}
/** Reactive like count for a coordinate (0 when none/unknown). */
function likeCount(coord: string): number {
return counts.get(coord) ?? 0
}
/**
* Reflect the current user's own like state immediately (optimistic),
* kept consistent with the per-author bookkeeping so the real event
* round-tripping back reconciles to a no-op.
*/
function setSelf(coord: string, pubkey: string, liked: boolean) {
if (!coord || !pubkey) return
const set = authorCoords.get(pubkey) ?? new Set<string>()
const had = set.has(coord)
if (liked && !had) {
set.add(coord)
authorCoords.set(pubkey, set)
addAuthor(coord, pubkey)
} else if (!liked && had) {
set.delete(coord)
authorCoords.set(pubkey, set)
removeAuthor(coord, pubkey)
}
}
return { track, likeCount, setSelf }
}

View file

@ -1,6 +1,6 @@
import { computed, onMounted, ref } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService, UserProfile } from '@/modules/base/nostr/ProfileService' import type { Event as NostrEvent } from 'nostr-tools'
export interface OrganizerProfile { export interface OrganizerProfile {
pubkey: string pubkey: string
@ -14,92 +14,134 @@ export interface OrganizerProfile {
website?: string website?: string
} }
function fromUserProfile(p: UserProfile): OrganizerProfile { // Global cache of fetched profiles
return { const profileCache = ref<Map<string, OrganizerProfile>>(new Map())
pubkey: p.pubkey,
name: p.name,
displayName: p.display_name,
about: p.about,
picture: p.picture,
nip05: p.nip05,
}
}
/** /**
* Composable for fetching and displaying organizer profiles (NIP-01 kind 0). * Composable for fetching and displaying organizer profiles (NIP-01 kind 0).
* * Uses its own relay subscription to avoid depending on the nostr-feed module.
* Routes through the centralized ProfileService (registered by the base
* module) so:
* - the cache is shared with every other module that reads kind-0
* metadata (nostr-feed, market, chat),
* - the subscription waits for the relay hub to actually be connected
* before firing (the previous local impl threw synchronously when a
* component mounted before connection — silently leaving the user
* with a pubkey-truncated fallback even after profiles arrived),
* - duplicate fetches for the same pubkey collapse into one
* subscription.
*/ */
export function useOrganizerProfile(pubkey: string) { export function useOrganizerProfile(pubkey: string) {
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE) const profile = ref<OrganizerProfile | null>(profileCache.value.get(pubkey) ?? null)
const isLoading = ref(false) const isLoading = ref(!profile.value)
let unsubscribe: (() => void) | null = null
// Reactive read from the shared ProfileService cache. Updates the function load() {
// moment a kind-0 lands for this pubkey, regardless of which module if (profileCache.value.has(pubkey)) {
// triggered the fetch. profile.value = profileCache.value.get(pubkey)!
const profile = computed<OrganizerProfile | null>(() => {
if (!profileService) return null
const p = profileService.profiles.get(pubkey)
return p ? fromUserProfile(p) : null
})
const displayName = computed(() => {
const p = profile.value
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
})
onMounted(async () => {
if (!profileService || profile.value) return
isLoading.value = true
try {
await profileService.getProfile(pubkey)
} finally {
isLoading.value = false isLoading.value = false
return
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) {
isLoading.value = false
return
}
unsubscribe = relayHub.subscribe({
id: `profile-${pubkey}-${Date.now()}`,
filters: [{
kinds: [0],
authors: [pubkey],
limit: 1,
}],
onEvent: (event: NostrEvent) => {
try {
const metadata = JSON.parse(event.content)
const p: OrganizerProfile = {
pubkey,
name: metadata.name,
displayName: metadata.display_name,
about: metadata.about,
picture: metadata.picture,
banner: metadata.banner,
nip05: metadata.nip05,
lud16: metadata.lud16,
website: metadata.website,
}
profileCache.value.set(pubkey, p)
profile.value = p
} catch {
// invalid metadata JSON
}
isLoading.value = false
},
onEose: () => {
isLoading.value = false
},
})
}
onMounted(() => {
load()
})
onUnmounted(() => {
if (unsubscribe) {
unsubscribe()
} }
}) })
return { return {
profile, profile,
isLoading, isLoading,
displayName, get displayName() {
const p = profile.value
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
},
} }
} }
/** /**
* Batch-fetch profiles for multiple pubkeys (for event cards). * Batch-fetch profiles for multiple pubkeys (for event cards).
*
* Thin wrapper around ProfileService.fetchProfiles so callers don't
* have to know the service token. Useful for warming the cache before
* a list of cards mounts.
*/ */
export function useBatchProfiles() { export function useBatchProfiles() {
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
function fetchProfiles(pubkeys: string[]) { function fetchProfiles(pubkeys: string[]) {
if (!profileService || pubkeys.length === 0) return const uncached = pubkeys.filter(pk => !profileCache.value.has(pk))
void profileService.fetchProfiles(pubkeys) if (uncached.length === 0) return
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return
relayHub.subscribe({
id: `batch-profiles-${Date.now()}`,
filters: [{
kinds: [0],
authors: uncached,
}],
onEvent: (event: NostrEvent) => {
try {
const metadata = JSON.parse(event.content)
profileCache.value.set(event.pubkey, {
pubkey: event.pubkey,
name: metadata.name,
displayName: metadata.display_name,
about: metadata.about,
picture: metadata.picture,
banner: metadata.banner,
nip05: metadata.nip05,
lud16: metadata.lud16,
website: metadata.website,
})
} catch {
// skip invalid
}
},
})
} }
function getProfile(pubkey: string): OrganizerProfile | undefined { function getProfile(pubkey: string): OrganizerProfile | undefined {
const p = profileService?.profiles.get(pubkey) return profileCache.value.get(pubkey)
return p ? fromUserProfile(p) : undefined
} }
function getDisplayName(pubkey: string): string { function getDisplayName(pubkey: string): string {
const p = profileService?.profiles.get(pubkey) const p = profileCache.value.get(pubkey)
return p?.display_name ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}` return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
} }
return { return {
profiles: profileCache,
fetchProfiles, fetchProfiles,
getProfile, getProfile,
getDisplayName, getDisplayName,

View file

@ -0,0 +1,248 @@
import { ref, onMounted, onUnmounted } from 'vue'
import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing'
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
/**
* NIP-52 RSVP (kind 31925) for responding to calendar events.
*
* Each RSVP is an addressable event with:
* d-tag: unique identifier for this RSVP
* a-tag: reference to the calendar event (kind:pubkey:d-tag)
* status tag: 'accepted' | 'declined' | 'tentative'
*/
interface RSVPEntry {
status: RSVPStatus
eventId: string
createdAt: number
}
// Cache: eventCoord -> user's own (latest) RSVP entry
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
// Cache: eventCoord -> (pubkey -> latest RSVP entry from that pubkey).
// NIP-52 RSVPs (kind 31925) are replaceable per (kind, pubkey, d-tag), so a
// user's earlier RSVP for an event is superseded by their later one. The
// "going" count is derived from this map (count of pubkeys whose *latest*
// RSVP has status === 'accepted'), not by summing every accepted event seen
// — that would double-count replacements and never decrement on flip.
const rsvpStates = ref<Map<string, Map<string, RSVPEntry>>>(new Map())
const isLoaded = ref(false)
// Coords with an in-flight publish — used to disable RSVP buttons so fast
// clicks don't race each other.
const pendingCoords = ref<Set<string>>(new Set())
// Last successfully-published `created_at` per coord. NIP-01 created_at is
// integer seconds, so two clicks in the same wall-clock second produce the
// same timestamp and most relays treat the second one as a duplicate /
// older replacement and silently drop it. We bump past the previous
// timestamp so each click is strictly newer.
const lastPublishAt = new Map<string, number>()
function upsertRSVPState(coord: string, pubkey: string, entry: RSVPEntry) {
let inner = rsvpStates.value.get(coord)
if (!inner) {
inner = new Map()
}
const existing = inner.get(pubkey)
if (existing && existing.createdAt >= entry.createdAt) return
inner.set(pubkey, entry)
// Re-set on the outer map so the ref's reactive proxy notifies dependents
// (Vue 3's deep reevent doesn't reach into nested Map values).
rsvpStates.value.set(coord, inner)
}
export function useRSVP() {
const { isAuthenticated, currentUser } = useAuth()
let unsubscribe: (() => void) | null = null
/**
* Get the user's RSVP status for an event.
*/
function getMyRSVP(eventKind: number, pubkey: string, dTag: string): RSVPStatus | null {
const coord = `${eventKind}:${pubkey}:${dTag}`
return rsvpCache.value.get(coord)?.status ?? null
}
/**
* RSVP count for an event = number of pubkeys whose latest RSVP for
* this event has status 'accepted'.
*/
function getRSVPCount(eventKind: number, pubkey: string, dTag: string): number {
const coord = `${eventKind}:${pubkey}:${dTag}`
const inner = rsvpStates.value.get(coord)
if (!inner) return 0
let count = 0
for (const entry of inner.values()) {
if (entry.status === 'accepted') count++
}
return count
}
/**
* Load the user's RSVPs and counts for visible events from relays.
*/
function loadRSVPs() {
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return
// Subscribe to all RSVPs (for counts) and filter user's own
unsubscribe = relayHub.subscribe({
id: `rsvps-${Date.now()}`,
filters: [{
kinds: [NIP52_KINDS.RSVP],
limit: 500,
}],
onEvent: (event: NostrEvent) => {
const aTag = event.tags.find(t => t[0] === 'a')?.[1]
if (!aTag) return
const statusTag = event.tags.find(t => t[0] === 'status')?.[1] as RSVPStatus | undefined
// Also check 'l' tag pattern used by some clients
const lStatus = event.tags.find(t => t[0] === 'l' && t[2] === 'status')?.[1] as RSVPStatus | undefined
const status = statusTag ?? lStatus
if (!status || !['accepted', 'declined', 'tentative'].includes(status)) return
const entry: RSVPEntry = {
status,
eventId: event.id,
createdAt: event.created_at,
}
// Per-pubkey latest-wins state — drives the count.
upsertRSVPState(aTag, event.pubkey, entry)
// User's own RSVP cache (used by getMyRSVP).
if (currentUser.value?.pubkey && event.pubkey === currentUser.value.pubkey) {
const existing = rsvpCache.value.get(aTag)
if (!existing || event.created_at > existing.createdAt) {
rsvpCache.value.set(aTag, entry)
}
}
},
onEose: () => {
isLoaded.value = true
},
})
}
/**
* Whether a publish is currently in flight for the given event. Bind
* to the RSVP buttons' `:disabled` so users can't queue racing clicks.
*/
function isPending(eventKind: number, pubkey: string, dTag: string): boolean {
const coord = `${eventKind}:${pubkey}:${dTag}`
return pendingCoords.value.has(coord)
}
/**
* Publish an RSVP for an event.
* Clicking the same status again removes the RSVP (publishes 'declined').
*
* Returns the status that was published on success, or null if the publish
* was rejected, blocked, or threw — caller should toast accordingly.
*/
async function setRSVP(
eventKind: number,
eventPubkey: string,
eventDTag: string,
status: RSVPStatus
): Promise<RSVPStatus | null> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
const coord = `${eventKind}:${eventPubkey}:${eventDTag}`
// Throttle: refuse a second click while the first is still publishing.
if (pendingCoords.value.has(coord)) return null
// Toggle: if already this status, decline instead.
const currentStatus = getMyRSVP(eventKind, eventPubkey, eventDTag)
const newStatus = currentStatus === status ? 'declined' : status
const dTag = `rsvp-${eventDTag}`
// Strictly-monotonic created_at per coord so two clicks in the same
// wall-clock second don't both stamp the same timestamp (relays would
// dedupe the second one as a non-newer replacement).
const now = Math.floor(Date.now() / 1000)
const previous = lastPublishAt.get(coord) ?? 0
const createdAt = Math.max(now, previous + 1)
const template: EventTemplate = {
kind: NIP52_KINDS.RSVP,
created_at: createdAt,
content: '',
tags: [
['d', dTag],
['a', coord],
['status', newStatus],
['L', 'status'],
['l', newStatus, 'status'],
['p', eventPubkey],
],
}
let signedEvent: NostrEvent
try {
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useRSVP] signEventViaLnbits failed:', err)
return null
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return null
pendingCoords.value.add(coord)
try {
const result = await relayHub.publishEvent(signedEvent)
if (!result || result.success <= 0) {
// No relay accepted the event — leave caches untouched so the UI
// continues to reflect the last known-good state.
return null
}
const entry: RSVPEntry = {
status: newStatus,
eventId: signedEvent.id,
createdAt: signedEvent.created_at,
}
// Update both the user-scoped cache and the all-users state so the
// count flips immediately rather than waiting for the relay to echo
// our own event back through the subscription.
rsvpCache.value.set(coord, entry)
if (currentUser.value.pubkey) {
upsertRSVPState(coord, currentUser.value.pubkey, entry)
}
lastPublishAt.set(coord, signedEvent.created_at)
return newStatus
} finally {
pendingCoords.value.delete(coord)
}
}
onMounted(() => {
if (!isLoaded.value) {
loadRSVPs()
}
})
onUnmounted(() => {
if (unsubscribe) {
unsubscribe()
}
})
return {
getMyRSVP,
getRSVPCount,
setRSVP,
isPending,
isLoaded,
loadRSVPs,
}
}

View file

@ -5,35 +5,12 @@ import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from './useOwnedTickets'
import { frontendRoot } from '../lib/frontendRoot'
import { renderTicketQr } from '../lib/ticketQr'
/** Guest identity for a purchase without an LNbits account. */
export interface PurchaseContact {
name: string
email: string
}
export interface PurchaseTicketOptions {
quantity?: number
/** Guest buyer (no account). Required when not authenticated. */
contact?: PurchaseContact
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */
email?: string
}
export function useTicketPurchase() { export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
// Refresh the shared owned-tickets singleton after a purchase so the
// feed/calendar "My tickets" filter and EventCard owned badges update
// without a reload — purchase is exactly the "consumer that mutates
// the ticket set" useOwnedTickets's docs anticipate.
const { refresh: refreshOwnedTickets } = useOwnedTickets()
// Async operations // Async operations
const purchaseOperation = useAsyncOperation() const purchaseOperation = useAsyncOperation()
@ -80,7 +57,11 @@ export function useTicketPurchase() {
async function generateTicketQRCode(ticketId: string) { async function generateTicketQRCode(ticketId: string) {
try { try {
const dataUrl = await renderTicketQr(ticketId, { width: 160 }) const ticketUrl = `ticket://${ticketId}`
const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, {
width: 128,
margin: 2
})
ticketQRCode.value = dataUrl ticketQRCode.value = dataUrl
return dataUrl return dataUrl
} catch (error) { } catch (error) {
@ -106,47 +87,11 @@ export function useTicketPurchase() {
* have to take it as an argument from the UI. */ * have to take it as an argument from the UI. */
const currentEventId = ref<string | null>(null) const currentEventId = ref<string | null>(null)
/**
* Advance to the ticket-QR success state for the given row ids. Shared
* by the Lightning-poll path (payment confirmed) and the free-ticket
* path (issued + paid server-side, no invoice) so both render one
* scannable QR per attendee identically.
*/
async function finalizePurchasedTickets(ids: string[]) {
// Ticket row(s) now exist — refresh the shared owned-tickets state so
// the feed/calendar My-tickets filter and owned badges reflect the
// purchase immediately (no reload). Runs in parallel with QR gen.
void refreshOwnedTickets()
if (ids.length > 0) {
purchasedTicketIds.value = ids
purchasedTicketId.value = ids[0]
const qrMap: Record<string, string> = {}
for (const id of ids) {
const dataUrl = await generateTicketQRCode(id)
if (dataUrl) qrMap[id] = dataUrl
}
ticketQRCodes.value = qrMap
ticketQRCode.value = qrMap[ids[0]] ?? null
showTicketQR.value = true
}
toast.success(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
}
async function purchaseTicketForEvent( async function purchaseTicketForEvent(
eventId: string, eventId: string,
options: PurchaseTicketOptions = {}, options: { quantity?: number } = {},
) { ) {
// Guests are identified by name + email; logged-in buyers by user id. if (!canPurchase.value || !currentUser.value) {
// Either is enough for a paid ticket — free tickets stay login-gated
// in the dialog (events#29: no anonymous free issuance).
const isGuest = !canPurchase.value || !currentUser.value
if (isGuest && !options.contact) {
throw new Error('User must be authenticated to purchase tickets') throw new Error('User must be authenticated to purchase tickets')
} }
@ -166,32 +111,20 @@ export function useTicketPurchase() {
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || '' const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const invoice = await ticketApi.requestTicket(eventId, { const invoice = await ticketApi.requestTicket(
userId: isGuest ? undefined : currentUser.value!.id, eventId,
name: isGuest ? options.contact?.name : undefined, currentUser.value!.id,
email: isGuest ? options.contact?.email : options.email, accessToken,
accessToken: isGuest ? undefined : accessToken, { quantity: options.quantity },
frontendUrl: frontendRoot(), )
quantity: options.quantity,
})
// Free tickets (price 0 / 100%-off promo): the backend already // Backend now returns either a Lightning invoice or a fiat
// issued + marked them paid, so there's no invoice to settle. Skip // checkout URL (post-events-v1.4.0). This composable only knows
// the QR / payment-poll and jump straight to the ticket-QR success // how to drive the Lightning path; fiat would need a separate
// state with the ids returned inline. // redirect-to-provider flow that lives in PurchaseTicketDialog
if (invoice.paid) { // (it has the user-visible payment-method selector). Reject the
paymentHash.value = invoice.paymentHash // fiat response here so callers get a clear error instead of a
await finalizePurchasedTickets(invoice.ticketIds ?? []) // silent broken QR.
return invoice
}
// Otherwise the backend returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows how
// to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog (it
// has the user-visible payment-method selector). Reject the fiat
// response here so callers get a clear error instead of a silent
// broken QR.
if (invoice.isFiat || !invoice.paymentRequest) { if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error( throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' + 'This event uses fiat checkout. Use the purchase dialog ' +
@ -247,13 +180,32 @@ export function useTicketPurchase() {
// Multi-ticket purchases come back with `ticketIds` (N rows // Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include // sharing one invoice). Single-ticket purchases include
// `ticketId` only. // `ticketId` only. Render one QR per row so each attendee
// has their own scannable code at the door.
const ids = result.ticketIds && result.ticketIds.length > 0 const ids = result.ticketIds && result.ticketIds.length > 0
? result.ticketIds ? result.ticketIds
: result.ticketId : result.ticketId
? [result.ticketId] ? [result.ticketId]
: [] : []
await finalizePurchasedTickets(ids)
if (ids.length > 0) {
purchasedTicketIds.value = ids
purchasedTicketId.value = ids[0]
const qrMap: Record<string, string> = {}
for (const id of ids) {
const dataUrl = await generateTicketQRCode(id)
if (dataUrl) qrMap[id] = dataUrl
}
ticketQRCodes.value = qrMap
ticketQRCode.value = qrMap[ids[0]] ?? null
showTicketQR.value = true
}
toast.success(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
} }
} catch (err) { } catch (err) {
console.error('Error checking payment status:', err) console.error('Error checking payment status:', err)

View file

@ -188,38 +188,6 @@ export function useTicketScanner(eventId: Ref<string>) {
isPaused.value = false isPaused.value = false
} }
/**
* Mark a ticket as registered without going through the camera —
* used when the host knows the attendee in person or accepts an
* alternate proof of identity. Same backend endpoint as a scan
* (so it also gates on event ownership and rejects unpaid /
* already-registered tickets), but skips the scanner pause +
* full-screen banner since the operator initiated the action
* from the roster directly. Refreshes stats on success.
*/
async function registerManually(
ticketId: string,
): Promise<{ ok: boolean; error?: string }> {
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) return { ok: false, error: 'No wallet admin key available' }
try {
await ticketApi.registerTicket(ticketId, adminKey)
// Mirror the session-local dedup the scan path uses so a
// subsequent QR scan of the same ticket reports "Already
// scanned" instead of round-tripping a duplicate register.
if (!scanned.value.some(r => r.ticketId === ticketId)) {
scanned.value = [
{ ticketId, name: null, registeredAt: new Date().toISOString() },
...scanned.value,
]
}
await refreshStats()
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
function clearScanned() { function clearScanned() {
scanned.value = [] scanned.value = []
lastScan.value = null lastScan.value = null
@ -242,6 +210,5 @@ export function useTicketScanner(eventId: Ref<string>) {
onDecode, onDecode,
resume, resume,
clearScanned, clearScanned,
registerManually,
} }
} }

View file

@ -33,6 +33,15 @@ export const eventsModule = createModulePlugin({
requiresAuth: false, requiresAuth: false,
}, },
}, },
{
path: '/events/calendar',
name: 'events-calendar',
component: () => import('./views/EventsCalendarPage.vue'),
meta: {
title: 'Calendar',
requiresAuth: false,
},
},
{ {
path: '/events/map', path: '/events/map',
name: 'events-map', name: 'events-map',
@ -51,18 +60,6 @@ export const eventsModule = createModulePlugin({
requiresAuth: false, requiresAuth: false,
}, },
}, },
{
// Public ticket page — target of the emailed ticket link. Listed
// before /events/:id for readability; the router can't confuse the
// two anyway (different segment counts).
path: '/events/ticket/:ticketId',
name: 'event-ticket',
component: () => import('./views/TicketPage.vue'),
meta: {
title: 'Ticket',
requiresAuth: false,
},
},
{ {
path: '/events/:id', path: '/events/:id',
name: 'event-detail', name: 'event-detail',

View file

@ -1,16 +0,0 @@
import { describe, it, expect } from 'vitest'
import { frontendRoot } from './frontendRoot'
describe('frontendRoot', () => {
it('joins origin and base path without a trailing slash', () => {
expect(frontendRoot('https://demo.aiolabs.dev', '/events/')).toBe('https://demo.aiolabs.dev/events')
})
it('collapses a root base to the bare origin', () => {
expect(frontendRoot('http://localhost:5173', '/')).toBe('http://localhost:5173')
})
it('tolerates a base without leading slash', () => {
expect(frontendRoot('https://app.ariege.io', 'oyez/')).toBe('https://app.ariege.io/oyez')
})
})

View file

@ -1,20 +0,0 @@
/**
* This app's public root — origin + Vite base path, no trailing slash.
*
* Sent to the events extension as `frontend_url` so the backend can
* compose the Stripe success / cancel URLs and the emailed ticket link
* under the webapp (e.g. `https://demo.aiolabs.dev/events`,
* `https://app.ariege.io/oyez`) instead of the LNbits host. The
* extension appends the same path shapes the LNbits UI uses
* (`/events/{event_id}`, `/events/ticket/{ticket_id}`), which the events
* router serves under that base.
*
* Parameters exist for tests; callers use the defaults.
*/
export function frontendRoot(
origin: string = window.location.origin,
base: string = import.meta.env.BASE_URL ?? '/',
): string {
const path = base.startsWith('/') ? base : `/${base}`
return `${origin}${path}`.replace(/\/+$/, '')
}

View file

@ -1,21 +0,0 @@
import { describe, it, expect } from 'vitest'
import { effectivePaymentMethods } from './paymentMethods'
describe('effectivePaymentMethods', () => {
it('falls back to the legacy allowFiat rule when no list is published', () => {
expect(effectivePaymentMethods({ allowFiat: false })).toEqual(['lightning'])
expect(effectivePaymentMethods({ allowFiat: true })).toEqual(['lightning', 'fiat'])
expect(effectivePaymentMethods(undefined)).toEqual(['lightning'])
})
it('honours an explicit list, including fiat-only events', () => {
expect(effectivePaymentMethods({ allowFiat: true, paymentMethods: ['fiat'] })).toEqual(['fiat'])
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['lightning', 'fiat'] }))
.toEqual(['lightning', 'fiat'])
})
it('drops unknown rails and falls back when nothing known remains', () => {
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain'] })).toEqual(['lightning'])
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain', 'fiat'] })).toEqual(['fiat'])
})
})

View file

@ -1,22 +0,0 @@
export type PaymentRailId = 'lightning' | 'fiat'
const KNOWN_RAILS: PaymentRailId[] = ['lightning', 'fiat']
/**
* Rails a buyer may use for an event.
*
* Mirrors `effective_payment_methods()` in the events extension: an
* explicit `paymentMethods` list wins; otherwise the legacy rule applies
* — Lightning always, fiat only when the organizer set `allowFiat`.
* Unknown rail ids are dropped so a future backend value can't render
* a button the dialog has no handler for.
*/
export function effectivePaymentMethods(
info: { allowFiat?: boolean; paymentMethods?: string[] } | null | undefined,
): PaymentRailId[] {
const explicit = (info?.paymentMethods ?? []).filter(
(m): m is PaymentRailId => (KNOWN_RAILS as string[]).includes(m),
)
if (explicit.length > 0) return explicit
return info?.allowFiat ? ['lightning', 'fiat'] : ['lightning']
}

View file

@ -1,38 +0,0 @@
/**
* What the buyer is waiting on while the tab is away at the fiat
* provider's checkout. Written right before the redirect; read back by
* EventDetailPage when Stripe returns with `?checkout=success` (the
* URL carries the ticket ids too — this record is the belt-and-braces
* copy that also remembers the delivery email for the confirmation
* screen). Cleared once the return dialog sees the tickets paid, or on
* `?checkout=cancelled`.
*/
export const PENDING_CHECKOUT_KEY = 'events:pending-checkout'
export interface PendingCheckout {
eventId: string
paymentHash: string
ticketIds: string[]
email?: string
ts: number
}
export function readPendingCheckout(): PendingCheckout | null {
try {
const raw = localStorage.getItem(PENDING_CHECKOUT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PendingCheckout
if (!parsed || typeof parsed.eventId !== 'string') return null
return parsed
} catch {
return null
}
}
export function clearPendingCheckout(): void {
try {
localStorage.removeItem(PENDING_CHECKOUT_KEY)
} catch {
// ignore
}
}

View file

@ -1,61 +0,0 @@
import brandLogo from '@brand-app-logo?url'
/**
* Render the scannable `ticket://<id>` QR every ticket surface uses
* (purchase success, Stripe return dialog, ticket page, My Tickets).
*
* Error-correction level H (30 % recoverable) leaves room for the brand
* logo the events extension also composites into its emailed PNG, so
* the in-app and emailed images look alike. The overlay is best-effort:
* if canvas or the logo asset is unavailable the plain QR is returned.
*/
export async function renderTicketQr(
ticketId: string,
options: { width?: number; logo?: boolean } = {},
): Promise<string> {
const width = options.width ?? 200
const qrcode = await import('qrcode')
const plain = await qrcode.toDataURL(`ticket://${ticketId}`, {
width,
margin: 2,
errorCorrectionLevel: 'H',
color: { dark: '#000000', light: '#FFFFFF' },
})
if (options.logo === false) return plain
try {
return await overlayLogo(plain, width)
} catch {
return plain
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`Could not load ${src}`))
img.src = src
})
}
async function overlayLogo(qrDataUrl: string, width: number): Promise<string> {
if (typeof document === 'undefined') return qrDataUrl
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = width
const ctx = canvas.getContext('2d')
if (!ctx) return qrDataUrl
const [qr, logo] = await Promise.all([loadImage(qrDataUrl), loadImage(brandLogo)])
ctx.drawImage(qr, 0, 0, width, width)
// Logo ≤ 20 % of the QR width on a white pad ≈ 26 % — well inside the
// 30 % damage budget of level H, matching the backend's PNG endpoint.
const logoSize = Math.round(width * 0.2)
const padSize = Math.round(width * 0.26)
const center = width / 2
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(center - padSize / 2, center - padSize / 2, padSize, padSize)
ctx.drawImage(logo, center - logoSize / 2, center - logoSize / 2, logoSize, logoSize)
return canvas.toDataURL('image/png')
}

View file

@ -1,92 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TicketApiService } from './TicketApiService'
const BASE = 'https://lnbits.test'
function okJson(body: unknown) {
return { ok: true, json: async () => body } as Response
}
describe('TicketApiService.requestTicket', () => {
const fetchMock = vi.fn()
let api: TicketApiService
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
})
afterEach(() => vi.unstubAllGlobals())
function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
}
it('sends name + email and no user_id / Authorization for guests', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', fiat_payment_request: 'https://checkout', is_fiat: true, ticket_ids: ['a', 'b'] }))
const res = await api.requestTicket('evt', {
name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events',
paymentMethod: 'fiat', quantity: 2,
})
const { url, body, headers } = lastCall()
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`)
expect(body).toEqual({
name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events',
payment_method: 'fiat', quantity: 2,
})
expect(body.user_id).toBeUndefined()
expect(headers.Authorization).toBeUndefined()
expect(headers['X-API-KEY']).toBe('inst-key')
expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] })
})
it('sends user_id + email with a Bearer token for logged-in buyers', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
await api.requestTicket('evt', {
userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173',
})
const { body, headers } = lastCall()
expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' })
expect(headers.Authorization).toBe('Bearer tok')
})
it('surfaces the backend detail string as the error message', async () => {
fetchMock.mockResolvedValue({ ok: false, json: async () => ({ detail: 'Payment method not enabled for this event.' }) } as Response)
await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' }))
.rejects.toThrow('Payment method not enabled for this event.')
})
})
describe('TicketApiService.getTicket / resendTicketEmail', () => {
const fetchMock = vi.fn()
let api: TicketApiService
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' })
})
afterEach(() => vi.unstubAllGlobals())
it('maps the anonymous PublicTicket shape', async () => {
fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' }))
const t = await api.getTicket('tid')
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`)
expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' })
})
it('maps the structured resend result and tolerates the legacy bare-ticket shape', async () => {
const ticket = { id: 'tid', wallet: 'w', event: 'evt', name: 'G', email: 'g@x.io', user_id: null, registered: false, paid: true, time: 't', reg_timestamp: 'r', extra: {} }
fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } }))
const r = await api.resendTicketEmail('tid', 'admin')
expect(r.ticket.id).toBe('tid')
expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' })
expect(r.nostr.attempted).toBe(false)
fetchMock.mockResolvedValueOnce(okJson(ticket))
const legacy = await api.resendTicketEmail('tid', 'admin')
expect(legacy.ticket.id).toBe('tid')
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
})
})

View file

@ -3,10 +3,8 @@ import type {
EventTicketExtra, EventTicketExtra,
CreateTicketRequest, CreateTicketRequest,
PaymentMethod, PaymentMethod,
PublicTicket,
TicketPurchaseInvoice, TicketPurchaseInvoice,
TicketPaymentStatus, TicketPaymentStatus,
TicketResendResult,
TicketedEvent, TicketedEvent,
CreateEventRequest, CreateEventRequest,
} from '../types/ticket' } from '../types/ticket'
@ -16,27 +14,6 @@ export interface TicketApiConfig {
apiKey: string apiKey: string
} }
/** Options for `TicketApiService.requestTicket`. */
export interface RequestTicketOptions {
/** LNbits user id — logged-in buyers. */
userId?: string
/** Guest buyer name (required together with `email` when no `userId`). */
name?: string
/** Delivery email — required for guests, optional for logged-in buyers. */
email?: string
/** Bearer token; forwarded only when present. */
accessToken?: string
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
frontendUrl?: string
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
}
/** /**
* Database-backed ticketing API service. * Database-backed ticketing API service.
* Talks to the LNbits events extension for ticket inventory, * Talks to the LNbits events extension for ticket inventory,
@ -80,27 +57,29 @@ export class TicketApiService {
* = follow-the-URL string from the configured fiat provider). The * = follow-the-URL string from the configured fiat provider). The
* `isFiat` flag is the discriminator. * `isFiat` flag is the discriminator.
* *
* Identity: pass `userId` for a logged-in buyer (plus `email` so the
* ticket can be delivered by mail), or `name` + `email` for a guest.
* The endpoint itself is anonymous; `accessToken` is only forwarded
* when present.
*
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
* the fiat path (the event must have that rail enabled — the backend * the fiat path (requires the event to have `allow_fiat=true`).
* 400s otherwise). `fiatProvider` is optional — backend picks the * `fiatProvider` is optional — backend picks the user's configured
* organizer's configured default when omitted. `frontendUrl` is this * default when omitted.
* app's root (see `lib/frontendRoot.ts`) and drives the Stripe *
* return URLs + emailed ticket link. * Additional ticket metadata (promo code, refund address, nostr
* identifier for DM delivery) can be supplied via `options`.
*/ */
async requestTicket( async requestTicket(
eventId: string, eventId: string,
options: RequestTicketOptions = {}, userId: string,
accessToken: string,
options: {
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
} = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = {} const body: CreateTicketRequest = { user_id: userId }
if (options.userId) body.user_id = options.userId
if (options.name) body.name = options.name
if (options.email) body.email = options.email
if (options.frontendUrl) body.frontend_url = options.frontendUrl
if (options.paymentMethod) body.payment_method = options.paymentMethod if (options.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode if (options.promoCode) body.promo_code = options.promoCode
@ -108,14 +87,14 @@ export class TicketApiService {
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity if (options.quantity && options.quantity > 1) body.quantity = options.quantity
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}`
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/${eventId}`, `/events/api/v1/tickets/${eventId}`,
{ {
method: 'POST', method: 'POST',
headers, headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(body), body: JSON.stringify(body),
} }
) )
@ -126,40 +105,9 @@ export class TicketApiService {
fiatPaymentRequest: data.fiat_payment_request ?? undefined, fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined, fiatProvider: data.fiat_provider ?? undefined,
isFiat: Boolean(data.is_fiat), isFiat: Boolean(data.is_fiat),
// Free tickets: backend returns paid=true with the row ids inline
// and no invoice to settle.
paid: Boolean(data.paid),
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
} }
} }
/**
* Anonymous read of one ticket — paid / registered state only, no
* PII. Used by the Stripe return dialog to poll settlement and by
* the public ticket page the emailed link opens.
*/
async getTicket(ticketId: string): Promise<PublicTicket> {
const t = await this.request(`/events/api/v1/tickets/${ticketId}`, { method: 'GET' })
return {
eventId: t.event,
name: t.name ?? null,
registered: Boolean(t.registered),
paid: Boolean(t.paid),
time: t.time,
regTimestamp: t.reg_timestamp,
}
}
/**
* Anonymous public event detail (`PublicEvent`). Note the backend
* answers 410 for sold-out / closed events — callers that only need
* a display name should try the Nostr store first and treat a
* failure here as non-fatal.
*/
async getPublicEvent(eventId: string): Promise<TicketedEvent> {
return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' })
}
/** /**
* Check whether a ticket payment has been confirmed. * Check whether a ticket payment has been confirmed.
*/ */
@ -274,30 +222,22 @@ export class TicketApiService {
/** /**
* Resend the ticket confirmation email for a paid ticket. Requires * Resend the ticket confirmation email for a paid ticket. Requires
* the event's wallet admin key (organizer-only). Returns the updated * the event's wallet admin key (organizer-only). Returns the updated
* Ticket plus per-channel delivery results (events ext >= v1.6.1-aio.8; * Ticket with the `email_notification_sent` flag refreshed.
* older extensions returned the bare Ticket, which is tolerated).
* *
* Endpoint added upstream in v1.6.1 (PR #51); structured result in v1.6.8. * Endpoint added upstream in v1.6.1 (PR #51).
*/ */
async resendTicketEmail( async resendTicketEmail(
ticketId: string, ticketId: string,
adminKey: string, adminKey: string,
): Promise<TicketResendResult> { ): Promise<EventTicket> {
const r = await this.request( const t = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`, `/events/api/v1/tickets/${ticketId}/resend-email`,
{ {
method: 'POST', method: 'POST',
headers: { 'X-API-KEY': adminKey }, headers: { 'X-API-KEY': adminKey },
} }
) )
const t = r.ticket ?? r
const delivery = (d: any) => ({
attempted: Boolean(d?.attempted),
sent: Boolean(d?.sent),
error: d?.error ?? null,
})
return { return {
ticket: {
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
eventId: t.event, eventId: t.event,
@ -309,9 +249,6 @@ export class TicketApiService {
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
},
email: delivery(r.email),
nostr: delivery(r.nostr),
} }
} }

View file

@ -1,185 +0,0 @@
import { beforeEach, describe, it, expect } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useEventsStore, eventCoordinate, eventKind } from './events'
import type { Event } from '../types/event'
// Minimal Event factory — only the fields the store touches matter; the
// rest are filled with inert defaults and cast to the full type.
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 'd-tag-1',
nostrEventId: 'nostr-id',
type: 'time',
organizer: { pubkey: 'pubkey-alice' },
title: 'Test Event',
description: '',
startDate: new Date('2026-07-01T18:00:00Z'),
tags: [],
isPrivate: false,
createdAt: new Date('2026-06-01T00:00:00Z'),
...overrides,
} as Event
}
describe('eventKind / eventCoordinate', () => {
it('maps date events to 31922 and time events to 31923', () => {
expect(eventKind(makeEvent({ type: 'date' }))).toBe(31922)
expect(eventKind(makeEvent({ type: 'time' }))).toBe(31923)
})
it('builds kind:pubkey:d-tag coordinates', () => {
const e = makeEvent({ type: 'time', id: 'abc', organizer: { pubkey: 'pk' } })
expect(eventCoordinate(e)).toBe('31923:pk:abc')
})
it('distinguishes same d-tag across authors', () => {
const a = makeEvent({ id: 'same', organizer: { pubkey: 'alice' } })
const b = makeEvent({ id: 'same', organizer: { pubkey: 'mallory' } })
expect(eventCoordinate(a)).not.toBe(eventCoordinate(b))
})
})
describe('useEventsStore.upsertEvent', () => {
beforeEach(() => setActivePinia(createPinia()))
it('keeps the newer version of the same coordinate', () => {
const store = useEventsStore()
const older = makeEvent({ createdAt: new Date('2026-06-01T00:00:00Z'), title: 'old' })
const newer = makeEvent({ createdAt: new Date('2026-06-02T00:00:00Z'), title: 'new' })
store.upsertEvent(older)
store.upsertEvent(newer)
expect(store.events).toHaveLength(1)
expect(store.getEventById('d-tag-1')?.title).toBe('new')
})
it('ignores an older version of the same coordinate', () => {
const store = useEventsStore()
const newer = makeEvent({ createdAt: new Date('2026-06-02T00:00:00Z'), title: 'new' })
const older = makeEvent({ createdAt: new Date('2026-06-01T00:00:00Z'), title: 'old' })
store.upsertEvent(newer)
store.upsertEvent(older)
expect(store.events).toHaveLength(1)
expect(store.getEventById('d-tag-1')?.title).toBe('new')
})
it('does NOT let a different author overwrite a same-d-tag event (cross-author hijack)', () => {
const store = useEventsStore()
const legit = makeEvent({
id: 'concert',
organizer: { pubkey: 'alice' },
title: 'Alice concert',
createdAt: new Date('2026-06-01T00:00:00Z'),
})
// Mallory republishes the same d-tag with a newer created_at — must
// NOT clobber Alice's event; both are kept under their own coordinate.
const impostor = makeEvent({
id: 'concert',
organizer: { pubkey: 'mallory' },
title: 'Mallory hijack',
createdAt: new Date('2026-06-10T00:00:00Z'),
})
store.upsertEvent(legit)
store.upsertEvent(impostor)
expect(store.events).toHaveLength(2)
expect(store.getByCoordinate('31923:alice:concert')?.title).toBe('Alice concert')
expect(store.getByCoordinate('31923:mallory:concert')?.title).toBe('Mallory hijack')
})
})
describe('useEventsStore.upsertEvent draft↔published reconciliation', () => {
beforeEach(() => setActivePinia(createPinia()))
// A provisional draft is the caller's own LNbits event surfaced via REST
// before its NIP-52 event is on a relay: empty pubkey, isMine, no ticket
// info. The relay copy lands under the real publisher pubkey (which is
// NOT the user's login key), so the two have different coordinates.
function makeDraft(overrides: Partial<Event> = {}): Event {
return makeEvent({
id: 'my-event',
organizer: { pubkey: '' },
isMine: true,
lnbitsStatus: 'approved',
createdAt: new Date('2026-06-01T00:00:00Z'),
...overrides,
})
}
function makePublished(overrides: Partial<Event> = {}): Event {
return makeEvent({
id: 'my-event',
organizer: { pubkey: 'the-architect' }, // resolve_for_wallet key
createdAt: new Date('2026-06-02T00:00:00Z'),
...overrides,
})
}
it('collapses to one card when the published copy arrives after the draft', () => {
const store = useEventsStore()
store.upsertEvent(makeDraft())
store.upsertEvent(makePublished())
expect(store.events).toHaveLength(1)
const only = store.getEventById('my-event')
expect(only?.organizer.pubkey).toBe('the-architect')
// Ownership carries over even though the publisher key != login key.
expect(only?.isMine).toBe(true)
})
it('collapses to one card when the draft arrives after the published copy', () => {
const store = useEventsStore()
store.upsertEvent(makePublished())
store.upsertEvent(makeDraft())
expect(store.events).toHaveLength(1)
const only = store.getEventById('my-event')
expect(only?.organizer.pubkey).toBe('the-architect')
expect(only?.isMine).toBe(true)
})
it('keeps the draft alone while no published copy exists (pending review)', () => {
const store = useEventsStore()
store.upsertEvent(makeDraft({ lnbitsStatus: 'proposed' }))
expect(store.events).toHaveLength(1)
expect(store.getEventById('my-event')?.lnbitsStatus).toBe('proposed')
})
it('does not fold across two genuinely different authors (no empty pubkey)', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'concert', organizer: { pubkey: 'alice' } }))
store.upsertEvent(makeEvent({ id: 'concert', organizer: { pubkey: 'bob' } }))
expect(store.events).toHaveLength(2)
})
})
describe('useEventsStore lookups & removal', () => {
beforeEach(() => setActivePinia(createPinia()))
it('getEventById resolves by d-tag (route identifier)', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'party', organizer: { pubkey: 'alice' } }))
expect(store.getEventById('party')?.id).toBe('party')
expect(store.getEventById('missing')).toBeUndefined()
})
it('getEventById returns the newest when a d-tag is shared across authors', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'a' }, title: 'older', createdAt: new Date('2026-06-01') }))
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'b' }, title: 'newer', createdAt: new Date('2026-06-05') }))
expect(store.getEventById('x')?.title).toBe('newer')
})
it('removeEvent deletes every coordinate sharing the d-tag', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'a' } }))
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'b' } }))
expect(store.events).toHaveLength(2)
store.removeEvent('x')
expect(store.events).toHaveLength(0)
})
})

View file

@ -3,50 +3,12 @@ import { ref, computed } from 'vue'
import type { Event } from '../types/event' import type { Event } from '../types/event'
import type { TicketedEvent } from '../types/ticket' import type { TicketedEvent } from '../types/ticket'
/** NIP-52 calendar event kinds. Date-based = 31922, time-based = 31923. */
export const EVENT_KIND_DATE = 31922
export const EVENT_KIND_TIME = 31923
/** The NIP-52 kind for an event, derived from its date/time type. */
export function eventKind(event: Pick<Event, 'type'>): number {
return event.type === 'date' ? EVENT_KIND_DATE : EVENT_KIND_TIME
}
/**
* Addressable-event coordinate `kind:pubkey:d-tag` (NIP-01 `a` tag form).
*
* NIP-52 calendar events are *addressable* (parameterized-replaceable):
* their d-tag is scoped to the **author**, so the replacement key MUST
* include the pubkey. Keying by the bare d-tag alone lets a different
* author publishing the same d-tag overwrite a legit event in the store.
* This mirrors NDK's `event.coordinate()` and welshman's `eventsByAddress`.
*/
export function eventCoordinate(
event: Pick<Event, 'type' | 'organizer' | 'id'>,
): string {
return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}`
}
/**
* A *provisional draft* is the caller's own LNbits event surfaced via REST
* (`loadOwnEvents` → `ticketedEventToEvent`) before its NIP-52 event is on
* a relay. It has no Nostr author yet, so its coordinate carries an empty
* pubkey. The relay-published copy of the same event lands under the real
* publisher's coordinate (`resolve_for_wallet`, which is NOT the user's
* Nostr login key), so the two never share a coordinate and must be
* reconciled by d-tag — see {@link upsertEvent}.
*/
function isProvisionalDraft(event: Pick<Event, 'organizer'>): boolean {
return event.organizer.pubkey === ''
}
/** /**
* Pinia store for cached events from Nostr relays. * Pinia store for cached events from Nostr relays.
* Deduplicates by NIP-52 addressable coordinate (kind:pubkey:d-tag). * Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
*/ */
export const useEventsStore = defineStore('events', () => { export const useEventsStore = defineStore('events', () => {
// State — keyed by addressable coordinate, NOT bare d-tag, so two // State
// authors using the same d-tag are stored independently.
const eventsMap = ref<Map<string, Event>>(new Map()) const eventsMap = ref<Map<string, Event>>(new Map())
const isLoading = ref(false) const isLoading = ref(false)
const lastUpdated = ref<Date | null>(null) const lastUpdated = ref<Date | null>(null)
@ -81,58 +43,14 @@ export const useEventsStore = defineStore('events', () => {
/** /**
* Add or update an event in the store. * Add or update an event in the store.
* * Deduplicates by id (d-tag). Newer events replace older ones.
* Deduplicates by addressable coordinate (kind:pubkey:d-tag). A newer
* version (by `created_at`) replaces an older one *for the same
* coordinate only* — a same-d-tag event from a different author lands
* under its own coordinate and never clobbers another author's event.
*
* Draft↔published reconciliation: a provisional draft (the caller's own
* REST event, empty-pubkey coordinate) and the relay-published copy of
* the same event have *different* coordinates, so without intervention a
* creator would see their own event twice once it's published. We keep a
* single card per d-tag: the published copy wins (real author, live
* ticket counts) and inherits the draft's `isMine` so the creator keeps
* the Yours badge + Hosting filter — the publisher key differs from their
* Nostr login key, so ownership can't be re-derived by pubkey match.
*/ */
function upsertEvent(event: Event) { function upsertEvent(event: Event) {
const incomingIsDraft = isProvisionalDraft(event) const existing = eventsMap.value.get(event.id)
// Look for an existing entry for the same d-tag with the OPPOSITE // Only update if this is a newer version
// draft/published status — the pair that needs reconciling.
let pairKey: string | undefined
let pair: Event | undefined
for (const [k, e] of eventsMap.value) {
if (e.id === event.id && isProvisionalDraft(e) !== incomingIsDraft) {
pairKey = k
pair = e
break
}
}
if (pair && pairKey) {
if (incomingIsDraft) {
// Published copy already present — fold the draft's ownership into
// it and drop the draft (don't add a second card).
if (event.isMine && !pair.isMine) {
eventsMap.value.set(pairKey, { ...pair, isMine: true })
lastUpdated.value = new Date()
}
return
}
// Incoming is the published copy superseding a draft — inherit the
// draft's ownership, then remove the draft so only one card remains.
if (pair.isMine) event.isMine = true
eventsMap.value.delete(pairKey)
}
const key = eventCoordinate(event)
const existing = eventsMap.value.get(key)
// Only update if this is a newer version of the same coordinate.
if (!existing || event.createdAt >= existing.createdAt) { if (!existing || event.createdAt >= existing.createdAt) {
eventsMap.value.set(key, event) eventsMap.value.set(event.id, event)
lastUpdated.value = new Date() lastUpdated.value = new Date()
} }
} }
@ -147,13 +65,10 @@ export const useEventsStore = defineStore('events', () => {
} }
/** /**
* Remove an event by its d-tag. Deletes every stored coordinate whose * Remove an event from the store.
* d-tag matches (normally one — our calendar events are single-publisher).
*/ */
function removeEvent(id: string) { function removeEvent(id: string) {
for (const [key, event] of eventsMap.value) { eventsMap.value.delete(id)
if (event.id === id) eventsMap.value.delete(key)
}
} }
/** /**
@ -165,28 +80,10 @@ export const useEventsStore = defineStore('events', () => {
} }
/** /**
* Get a single event by its full addressable coordinate (kind:pubkey:d-tag). * Get a single event by its id (d-tag).
* The precise, unambiguous lookup.
*/
function getByCoordinate(coordinate: string): Event | undefined {
return eventsMap.value.get(coordinate)
}
/**
* Get a single event by its d-tag (the route identifier).
*
* Calendar events in this app are single-publisher, so a d-tag resolves
* to one event in practice. If multiple authors ever share a d-tag, the
* newest (by `created_at`) wins — deterministic rather than first-seen.
* Use {@link getByCoordinate} when the author is known.
*/ */
function getEventById(id: string): Event | undefined { function getEventById(id: string): Event | undefined {
let match: Event | undefined return eventsMap.value.get(id)
for (const event of eventsMap.value.values()) {
if (event.id !== id) continue
if (!match || event.createdAt >= match.createdAt) match = event
}
return match
} }
return { return {
@ -207,7 +104,6 @@ export const useEventsStore = defineStore('events', () => {
upsertEvents, upsertEvents,
removeEvent, removeEvent,
clearAll, clearAll,
getByCoordinate,
getEventById, getEventById,
} }
}) })

View file

@ -78,14 +78,10 @@ export interface EventTicketInfo {
available?: number available?: number
/** Running paid count. */ /** Running paid count. */
sold: number sold: number
/** Total capacity (available + sold). Undefined means unlimited. */
total?: number
/** Whether the organizer enabled fiat checkout. */ /** Whether the organizer enabled fiat checkout. */
allowFiat: boolean allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */ /** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string fiatCurrency?: string
/** Rails the organizer enabled (see TicketTags.paymentMethods). */
paymentMethods?: string[]
} }
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined { function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
@ -95,12 +91,8 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
currency: ticket.currency, currency: ticket.currency,
available: ticket.available, available: ticket.available,
sold: ticket.sold, sold: ticket.sold,
// Capacity isn't published directly; derive it from remaining + sold.
// Undefined `available` means unlimited, so total stays undefined too.
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
allowFiat: ticket.allowFiat, allowFiat: ticket.allowFiat,
fiatCurrency: ticket.fiatCurrency, fiatCurrency: ticket.fiatCurrency,
paymentMethods: ticket.paymentMethods,
} }
} }

View file

@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest'
import { parseCalendarTimeEvent, NIP52_KINDS } from './nip52'
import type { NostrEvent } from 'nostr-tools'
function makeEvent(extraTags: string[][]): NostrEvent {
return {
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
id: 'id',
pubkey: 'pk',
created_at: 1_700_000_000,
content: '',
sig: '',
tags: [
['d', 'evt1'],
['title', 'Test'],
['start', '1700000000'],
['tickets_currency', 'EUR'],
['tickets_price', '20'],
...extraTags,
],
} as NostrEvent
}
describe('parseTicketTags — tickets_payment_methods', () => {
it('parses the comma list into paymentMethods', () => {
const parsed = parseCalendarTimeEvent(makeEvent([['tickets_payment_methods', 'lightning, fiat']]))
expect(parsed?.ticket?.paymentMethods).toEqual(['lightning', 'fiat'])
})
it('leaves paymentMethods undefined when the tag is absent (legacy events)', () => {
const parsed = parseCalendarTimeEvent(makeEvent([['tickets_allow_fiat', 'true']]))
expect(parsed?.ticket?.allowFiat).toBe(true)
expect(parsed?.ticket?.paymentMethods).toBeUndefined()
})
})

View file

@ -36,10 +36,6 @@ export interface TicketTags {
allowFiat: boolean allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */ /** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string fiatCurrency?: string
/** Rails the organizer enabled, from `tickets_payment_methods`
* ("lightning,fiat"). Undefined on events published before the
* tag existed — callers fall back to the allowFiat rule. */
paymentMethods?: string[]
} }
/** /**
@ -141,7 +137,6 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
const soldStr = getTagValue(tags, 'tickets_sold') const soldStr = getTagValue(tags, 'tickets_sold')
const priceStr = getTagValue(tags, 'tickets_price') const priceStr = getTagValue(tags, 'tickets_price')
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat') const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
const paymentMethodsStr = getTagValue(tags, 'tickets_payment_methods')
return { return {
available: availableStr != null ? Number(availableStr) : undefined, available: availableStr != null ? Number(availableStr) : undefined,
@ -150,9 +145,6 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
currency, currency,
allowFiat: allowFiatStr === 'true', allowFiat: allowFiatStr === 'true',
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'), fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
paymentMethods: paymentMethodsStr != null
? paymentMethodsStr.split(',').map((m) => m.trim()).filter(Boolean)
: undefined,
} }
} }

View file

@ -26,18 +26,6 @@ export interface EventExtra {
nostr_notifications: boolean nostr_notifications: boolean
notification_subject: string notification_subject: string
notification_body: string notification_body: string
/** Shown as "<organizer_name> via <site>" in the From line of ticket
* emails. Optional. */
organizer_name?: string
/** Reply-To for ticket emails; null/absent = the organizer's LNbits
* account email. */
reply_to_email?: string | null
/** BCC every ticket email to the reply-to address. */
copy_to_organizer?: boolean
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
* Same shape as upstream v2's `extra.payment_methods`. */
payment_methods?: string[]
} }
export interface EventTicketExtra { export interface EventTicketExtra {
@ -93,12 +81,10 @@ export interface TicketPurchaseRequest {
} }
/** /**
* Server response from `POST /tickets/{event_id}`. One of three shapes: * Server response from `POST /tickets/{event_id}`. Either Lightning
* - Lightning: `paymentRequest` = bolt11 (`isFiat` false, `paid` false) * (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
* - Fiat: `fiatPaymentRequest` = a URL the buyer follows with `fiatProvider` * the buyer follows to complete payment with `fiatProvider`).
* - Free: `paid` true with no `paymentRequest` — tickets already issued + * `isFiat` is the discriminator.
* paid server-side (price 0 / 100%-off promo); `ticketIds` carries the
* scannable rows so the client skips the payment step entirely.
*/ */
export interface TicketPurchaseInvoice { export interface TicketPurchaseInvoice {
paymentHash: string paymentHash: string
@ -106,10 +92,6 @@ export interface TicketPurchaseInvoice {
fiatPaymentRequest?: string fiatPaymentRequest?: string
fiatProvider?: string fiatProvider?: string
isFiat: boolean isFiat: boolean
/** Free tickets: already issued + paid, no invoice to settle. */
paid?: boolean
/** Row ids returned inline for the free path (no poll needed). */
ticketIds?: string[]
} }
export interface TicketPaymentStatus { export interface TicketPaymentStatus {
@ -177,22 +159,17 @@ export interface CreateEventRequest {
} }
/** /**
* Body for `POST /tickets/{event_id}`. Identity is `user_id` (logged-in * Body for `POST /tickets/{event_id}`. Either `user_id` OR the
* buyer, optionally with `email` for ticket delivery) OR the * `name`+`email` pair is required (backend root_validator enforces
* `name`+`email` pair (guest buyer). `nostr_identifier` opts the buyer * mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM
* into Nostr DM delivery when the event has nostr_notifications * delivery when the event has nostr_notifications enabled. The
* enabled. The `payment_method` + `fiat_provider` pair selects between * `payment_method` + `fiat_provider` pair selects between Lightning
* Lightning and fiat checkout. `frontend_url` is this app's root so the * and fiat checkout.
* backend can build the Stripe return URLs and the emailed ticket link
* under the webapp instead of the LNbits host.
*/ */
export interface CreateTicketRequest { export interface CreateTicketRequest {
name?: string name?: string
email?: string email?: string
user_id?: string user_id?: string
/** App root (origin + base path, no trailing slash). Origin must be
* on the LNbits instance's CORS allowlist or the backend 400s. */
frontend_url?: string
promo_code?: string promo_code?: string
refund_address?: string refund_address?: string
nostr_identifier?: string nostr_identifier?: string
@ -201,33 +178,3 @@ export interface CreateTicketRequest {
/** Number of tickets on this invoice (backend bounds 1..10). */ /** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number quantity?: number
} }
/**
* Anonymous view of a ticket from `GET /tickets/{ticket_id}`. The
* backend deliberately omits id / email / user_id; the id is whatever
* the caller asked for.
*/
export interface PublicTicket {
eventId: string
name: string | null
registered: boolean
paid: boolean
time: string
regTimestamp: string
}
export interface NotificationDeliveryResult {
attempted: boolean
sent: boolean
error: string | null
}
/**
* Structured response of `POST /tickets/{ticket_id}/resend-email`
* (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8).
*/
export interface TicketResendResult {
ticket: EventTicket
email: NotificationDeliveryResult
nostr: NotificationDeliveryResult
}

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useElementVisibility } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
@ -13,10 +12,10 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useEventDetail } from '../composables/useEventDetail' import { useEventDetail } from '../composables/useEventDetail'
import BookmarkButton from '../components/BookmarkButton.vue' import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue' import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue' import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import CheckoutReturnDialog from '../components/CheckoutReturnDialog.vue' import { NIP52_KINDS } from '../types/nip52'
import { clearPendingCheckout, readPendingCheckout } from '../lib/pendingCheckout'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useEventsStore } from '../stores/events' import { useEventsStore } from '../stores/events'
import { useOwnedTickets } from '../composables/useOwnedTickets' import { useOwnedTickets } from '../composables/useOwnedTickets'
@ -119,7 +118,6 @@ const purchaseEvent = computed(() => {
currency: a.ticketInfo.currency, currency: a.ticketInfo.currency,
allow_fiat: a.ticketInfo.allowFiat, allow_fiat: a.ticketInfo.allowFiat,
fiat_currency: a.ticketInfo.fiatCurrency, fiat_currency: a.ticketInfo.fiatCurrency,
payment_methods: a.ticketInfo.paymentMethods,
} }
}) })
@ -146,10 +144,7 @@ const isPast = computed(() => {
const showPurchaseDialog = ref(false) const showPurchaseDialog = ref(false)
function openPurchaseDialog() { function openPurchaseDialog() {
// Paid tickets are open to guests (name + email identity). Free if (!isAuthenticated.value) {
// claims still need an account so seats can't be drained anonymously.
const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0
if (!isAuthenticated.value && isFreeEvent) {
toastService.info(t('events.detail.loginToBuyTickets'), { toastService.info(t('events.detail.loginToBuyTickets'), {
action: { action: {
label: t('events.detail.logIn'), label: t('events.detail.logIn'),
@ -161,75 +156,6 @@ function openPurchaseDialog() {
showPurchaseDialog.value = true showPurchaseDialog.value = true
} }
// --- Return from the fiat provider ---------------------------------
// Stripe sends the buyer back here with ?checkout=success&tickets=a,b
// (ids pre-generated by the extension before the session was created)
// or ?checkout=cancelled. The query is stripped immediately so the
// ticket ids don't linger in history; the localStorage record written
// before the redirect is the fallback source and carries the email.
const showReturnDialog = ref(false)
const returnTicketIds = ref<string[]>([])
const returnEmail = ref<string | undefined>(undefined)
function handleCheckoutReturn() {
const status = route.query.checkout
if (status !== 'success' && status !== 'cancelled') return
const pending = readPendingCheckout()
const samePurchase = pending?.eventId === eventId
void router.replace({ query: {} })
if (status === 'cancelled') {
clearPendingCheckout()
toastService.info(t('events.checkout.cancelled'))
return
}
const fromQuery = String(route.query.tickets ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const ids = fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : []
if (ids.length === 0) return
returnTicketIds.value = ids
returnEmail.value = samePurchase ? pending?.email : undefined
showReturnDialog.value = true
}
onMounted(handleCheckoutReturn)
// --- Sticky buy bar --------------------------------------------------
// The ticket block sits right under the date/location so it is above the
// fold on most screens, but long descriptions still scroll it away. When
// it leaves the viewport a compact bar with the price + the same CTA
// pins above the bottom nav so the primary action is never more than a
// tap away. Hidden while a dialog is open (the dialog is the action).
const ticketBlockRef = ref<HTMLElement | null>(null)
const ticketBlockVisible = useElementVisibility(ticketBlockRef)
const priceLabel = computed(() => {
const info = event.value?.ticketInfo
if (!info) return ''
return info.price === 0 ? t('events.detail.free') : `${info.price} ${info.currency}`
})
const buyLabel = computed(() =>
ownedPaidCount.value > 0
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
: t('events.detail.buyTicket', 'Buy ticket'),
)
const showStickyCta = computed(
() =>
!!event.value?.ticketInfo &&
!ownedLnbitsEvent.value &&
!isPast.value &&
(canBuyTicket.value || ownedPaidCount.value > 0) &&
!ticketBlockVisible.value &&
!showPurchaseDialog.value &&
!showReturnDialog.value,
)
// Re-fetch the user's tickets when the purchase dialog closes (the // Re-fetch the user's tickets when the purchase dialog closes (the
// buyer may have just paid). The inventory side updates automatically // buyer may have just paid). The inventory side updates automatically
// via the relay republish from the events extension. // via the relay republish from the events extension.
@ -243,15 +169,42 @@ function goToMyTickets() {
</script> </script>
<template> <template>
<div class="container mx-auto py-6 px-4 max-w-3xl" :class="{ 'pb-24': showStickyCta }"> <div class="container mx-auto py-6 px-4 max-w-3xl">
<!-- Top bar — back-link only. Edit moves into the title row as a <!-- Top bar -->
prominent icon button; Scan moves into the tickets section <div class="flex items-center justify-between mb-4">
where it replaces the Buy-ticket CTA for the host. -->
<div class="flex items-center mb-4">
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack"> <Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" /> <ArrowLeft class="w-4 h-4" />
Back Back
</Button> </Button>
<div class="flex items-center gap-1.5">
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
size="sm"
class="gap-1.5"
@click="openScannerPage"
aria-label="Scan tickets"
>
<ScanLine class="w-4 h-4" />
Scan
</Button>
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
size="sm"
class="gap-1.5"
@click="openEditDialog"
aria-label="Edit event"
>
<Pencil class="w-4 h-4" />
Edit
</Button>
<BookmarkButton
v-if="event"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
</div> </div>
<!-- Loading --> <!-- Loading -->
@ -280,115 +233,104 @@ function goToMyTickets() {
/> />
</div> </div>
<!-- Title + bookmark + captions --> <!-- Title + Category -->
<div> <div>
<div class="flex flex-wrap items-start gap-2 mb-2"> <div class="flex items-start gap-2 mb-2">
<!-- "Yours" leads the row in the highlighted variant so the <Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
ownership signal stands out against the neutral
category/tag chips that follow. -->
<Badge
v-if="event.isMine"
variant="secondary"
class="shrink-0"
>
Yours
</Badge>
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
{{ categoryLabel }} {{ categoryLabel }}
</Badge> </Badge>
<Badge <Badge
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'" v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'" :variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="shrink-0 capitalize" class="shrink-0 mt-1 capitalize"
> >
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge> </Badge>
<Badge
v-if="event.isMine"
variant="outline"
class="shrink-0 mt-1"
>
Yours
</Badge>
<div v-for="tag in event.tags.slice(1)" :key="tag"> <div v-for="tag in event.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge> <Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div> </div>
</div> </div>
<div class="flex items-start justify-between gap-3">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground"> <h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ event.title }} {{ event.title }}
</h1> </h1>
<div class="flex items-center gap-1 shrink-0 mt-1">
<Button
v-if="ownedLnbitsEvent"
variant="default"
size="icon"
class="h-8 w-8"
:aria-label="t('events.detail.editEvent', 'Edit event')"
@click="openEditDialog"
>
<Pencil class="w-4 h-4" />
</Button>
<!-- Hosts don't need to favorite their own event — the
"Yours" badge already marks it, and the bookmark
affordance is meant for discovery, not management. -->
<BookmarkButton
v-else
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
</div>
<p v-if="event.summary" class="text-muted-foreground mt-2"> <p v-if="event.summary" class="text-muted-foreground mt-2">
{{ event.summary }} {{ event.summary }}
</p> </p>
</div>
<!-- When + Where captions --> <Separator />
<div class="mt-3 space-y-1 text-sm text-muted-foreground">
<div class="flex items-start gap-1.5"> <!-- Info section -->
<Calendar class="w-4 h-4 shrink-0 mt-0.5" /> <div class="grid gap-4 sm:grid-cols-2">
<span> <!-- When -->
{{ dateDisplay }} <div class="bg-muted/50 rounded-lg p-4 space-y-1">
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span> <div class="flex items-center gap-2 text-sm font-medium text-foreground">
</span> <Calendar class="w-4 h-4" />
{{ t('events.detail.when') }}
</div> </div>
<div v-if="event.location" class="flex items-start gap-1.5"> <p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
<MapPin class="w-4 h-4 shrink-0 mt-0.5" /> <p v-if="event.timezone" class="text-xs text-muted-foreground/70">
<span>{{ event.location }}</span> {{ event.timezone }}
</p>
</div> </div>
<!-- Where -->
<div v-if="event.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<MapPin class="w-4 h-4" />
{{ t('events.detail.location') }}
</div>
<p class="text-sm text-muted-foreground">{{ event.location }}</p>
</div> </div>
</div> </div>
<!-- Host's primary CTA is to scan tickets at the door. Lives <!-- RSVP -->
OUTSIDE the ticketInfo gate so it appears even when the <!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
event was published without AIO ticket tags — a host always (31922 for date-based, 31923 for time-based). Without this prop the
gets to scan attempts. Stays available for past events too button would default to time-based for every event, leaving RSVPs
so the host can still verify attendance after the fact. --> on date-based events pointing at a non-existent event coord. -->
<Button <RSVPButton
v-if="ownedLnbitsEvent" :pubkey="event.organizer.pubkey"
class="w-full gap-1.5" :d-tag="event.id"
size="lg" :kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
@click="openScannerPage" />
>
<ScanLine class="w-4 h-4" />
{{ t('events.detail.scanTickets', 'Scan tickets') }}
</Button>
<!-- Tickets — gated on the event carrying ticketInfo (set <!-- Tickets — gated on the event carrying ticketInfo (set
by the calendar→Event converter from the AIO custom by the calendar→Event converter from the AIO custom
tickets_* tags on the published event). Skipped for the tickets_* tags on the published event). Sections render
host entirely — they have the Scan CTA above and don't bottom-up: availability count, then existing owned
need a Buy CTA for their own event. Placed ABOVE the tickets (when count > 0) above a Purchase CTA (when
description on purpose: the buy action is the reason most capacity remains). -->
visitors open the page, and a long description must not <div v-if="event.ticketInfo" class="space-y-3">
push it below the fold (a sticky bar covers the rest). --> <div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<div <Ticket class="w-4 h-4 shrink-0" />
v-if="event.ticketInfo && !ownedLnbitsEvent" <span v-if="event.ticketInfo.available === undefined">
ref="ticketBlockRef" {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
class="space-y-3" </span>
> <span v-else-if="event.ticketInfo.available > 0">
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('events.detail.soldOut') }}
</span>
</div>
<div <div
v-if="ownedPaidCount > 0" v-if="ownedPaidCount > 0"
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3" class="bg-primary/10 border border-primary/30 rounded-lg p-4 flex items-center justify-between gap-3"
> >
<div class="flex items-center gap-2 text-sm font-medium text-foreground"> <div class="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" /> <CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }} {{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</div> </div>
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets"> <Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" /> <Ticket class="w-4 h-4" />
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }} {{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
</Button> </Button>
@ -401,25 +343,22 @@ function goToMyTickets() {
<History class="w-4 h-4 shrink-0" /> <History class="w-4 h-4 shrink-0" />
{{ t('events.detail.pastEvent', 'This event has already happened') }} {{ t('events.detail.pastEvent', 'This event has already happened') }}
</div> </div>
<div v-else-if="canBuyTicket" class="space-y-1"> <div v-else-if="canBuyTicket">
<Button <Button
class="w-full gap-1.5" class="w-full gap-1.5"
size="lg" size="lg"
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
@click="openPurchaseDialog" @click="openPurchaseDialog"
> >
<Ticket class="w-4 h-4" /> <Ticket class="w-4 h-4" />
{{ buyLabel }} {{ ownedPaidCount > 0
<span class="ml-2 opacity-80 font-normal">{{ priceLabel }}</span> ? t('events.detail.buyAnotherTicket', 'Buy another ticket')
: t('events.detail.buyTicket', 'Buy ticket') }}
<span class="ml-2 opacity-80 font-normal">
{{ event.ticketInfo.price === 0
? t('events.detail.free')
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
</span>
</Button> </Button>
<p class="text-xs text-muted-foreground text-center">
<span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else>
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
</span>
</p>
</div> </div>
<p <p
v-else-if="ownedPaidCount === 0" v-else-if="ownedPaidCount === 0"
@ -429,13 +368,6 @@ function goToMyTickets() {
</p> </p>
</div> </div>
<Separator />
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ event.description }}</p>
</div>
<PurchaseTicketDialog <PurchaseTicketDialog
v-if="purchaseEvent" v-if="purchaseEvent"
:is-open="showPurchaseDialog" :is-open="showPurchaseDialog"
@ -443,74 +375,6 @@ function goToMyTickets() {
@update:is-open="showPurchaseDialog = $event" @update:is-open="showPurchaseDialog = $event"
/> />
<CheckoutReturnDialog
:is-open="showReturnDialog"
:event-id="eventId"
:ticket-ids="returnTicketIds"
:email="returnEmail"
@update:is-open="showReturnDialog = $event"
/>
<!-- Sticky buy bar — see showStickyCta. Pinned flush above the
bottom nav via the height it publishes (--bottom-nav-height,
BottomNav.vue) and mirrors the inline CTA. -->
<!-- Teleported to <body>: inside the page the bar would inherit the
parent's space-y margin (margins offset fixed boxes too) and
float 24px above the nav. -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0"
leave-active-class="transition duration-150 ease-in"
leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="showStickyCta && event"
class="fixed inset-x-0 z-40 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
style="bottom: var(--bottom-nav-height)"
role="region"
:aria-label="t('events.detail.tickets')"
>
<div class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
<p class="text-xs text-muted-foreground truncate">
<span v-if="ownedPaidCount > 0">
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</span>
<span v-else-if="event.ticketInfo?.available === undefined">
{{ priceLabel }} · {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else>
{{ priceLabel }} · {{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
</span>
</p>
</div>
<Button
v-if="canBuyTicket"
class="gap-1.5 shrink-0"
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ buyLabel }}
</Button>
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
</Button>
</div>
</div>
</Transition>
</Teleport>
<!-- External references -->
<div v-if="event.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->
</div>
<Separator />
<!-- Organizer --> <!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4"> <div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
@ -518,6 +382,18 @@ function goToMyTickets() {
</p> </p>
<OrganizerCard :pubkey="event.organizer.pubkey" /> <OrganizerCard :pubkey="event.organizer.pubkey" />
</div> </div>
<Separator />
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ event.description }}</p>
</div>
<!-- External references -->
<div v-if="event.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->
</div>
</div> </div>
</div> </div>
</template> </template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useEvents } from '../composables/useEvents'
import EventCalendarView from '../components/EventCalendarView.vue'
import type { Event } from '../types/event'
const router = useRouter()
const { allEvents, subscribe } = useEvents()
onMounted(() => {
subscribe()
})
function handleSelectEvent(event: Event) {
router.push({ name: 'event-detail', params: { id: event.id } })
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<EventCalendarView
:events="allEvents"
@select-event="handleSelectEvent"
/>
</div>
</template>

View file

@ -34,10 +34,7 @@ onMounted(() => {
<!-- No geotagged events --> <!-- No geotagged events -->
<div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4"> <div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
<!-- opacity-30 on the element (not /30 on the colour) so the icon's <Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
overlapping fold strokes fade uniformly instead of compounding
alpha where they overlap. -->
<Map class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<p class="text-muted-foreground">No geotagged events found</p> <p class="text-muted-foreground">No geotagged events found</p>
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p> <p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
</div> </div>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -8,85 +8,44 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { SlidersHorizontal, CalendarDays, Plus, X } from 'lucide-vue-next' import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
import { format } from 'date-fns'
import brandAppLogoUrl from '@brand-app-logo?url'
import brandAppBannerUrl from '@brand-app-banner?url'
// Brand name flows through VITE_APP_NAME (set in vite.events.config.ts
// from brand.name). cfaun → "Oyez!", default → "Events", etc. Falls
// back to the i18n string only when no brand is configured (shouldn't
// happen in production builds, but defensive).
const appName: string = (import.meta.env.VITE_APP_NAME as string) || ''
// When the active brand ships a banner (wide logo+wordmark lockup), it
// replaces the logo + name pair in the header. The flag is set at build
// time; brandAppBannerUrl falls back to the logo when unset, so we only
// render the banner when the flag is truthy.
const hasBanner = (import.meta.env.VITE_APP_BANNER as string) === '1'
import { useEvents } from '../composables/useEvents' import { useEvents } from '../composables/useEvents'
import { useEventsStore } from '../stores/events' import { useAuth } from '@/composables/useAuthService'
import EventSearchOverlay from '../components/EventSearchOverlay.vue' import EventSearchOverlay from '../components/EventSearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue' import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue' import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import EventCalendarPopup from '../components/EventCalendarPopup.vue' import DatePickerStrip from '../components/DatePickerStrip.vue'
import EventList from '../components/EventList.vue' import EventList from '../components/EventList.vue'
import { useDateLocale } from '../composables/useDateLocale'
import type { Event } from '../types/event' import type { Event } from '../types/event'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { dateLocale } = useDateLocale()
const eventsStore = useEventsStore()
const { const {
events, events,
allEvents,
isLoading, isLoading,
error, error,
temporal, temporal,
selectedCategories, selectedCategories,
selectedDate,
hasActiveFilters, hasActiveFilters,
showPast, selectedDate,
onlyOwnedTickets,
onlyHosting, onlyHosting,
setTemporal, showPast,
selectDate, selectDate,
clearSelectedDate, setTemporal,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast, togglePast,
resetFilters, resetFilters,
subscribe, subscribe,
} = useEvents() } = useEvents()
const { isAuthenticated } = useAuth()
const filtersOpen = ref(false) const filtersOpen = ref(false)
const calendarOpen = ref(false)
// Events feeding the calendar popup's per-day dots. Respects the active
// category filter (so the calendar reflects what the user is browsing),
// but not the temporal/day filters — the calendar is for picking any
// date. No categories selected ⇒ all events.
const calendarEvents = computed(() =>
selectedCategories.value.length
? allEvents.value.filter(
(e) => e.category && selectedCategories.value.includes(e.category),
)
: allEvents.value,
)
// Human label for the active day filter, shown as a removable chip.
const selectedDateLabel = computed(() =>
selectedDate.value
? format(selectedDate.value, 'EEE, MMM d', { locale: dateLocale.value })
: '',
)
// Badge count on the Filters trigger so the user can see at a glance
// that hidden toggles (categories) are currently active even when the
// collapsible is closed. Past lives on the temporal strip now and
// has its own visible pressed state, so it doesn't need to count here.
const filterCount = computed(
() => selectedCategories.value.length,
)
onMounted(() => { onMounted(() => {
subscribe() subscribe()
@ -95,123 +54,85 @@ onMounted(() => {
function handleSelectEvent(event: Event) { function handleSelectEvent(event: Event) {
router.push({ name: 'event-detail', params: { id: event.id } }) router.push({ name: 'event-detail', params: { id: event.id } })
} }
// Create-activity CTA in the Hosting view. Replaces the old bottom-nav
// Create entry; shown only while the Hosting filter is active.
function openCreate() {
eventsStore.editingEvent = null
eventsStore.showCreateDialog = true
}
function onSelectDate(date: Date) {
// The popup closes itself; just apply the day filter.
selectDate(date)
}
// Safety: never let the date-picker popup persist across navigation —
// e.g. it should not reappear when returning to the feed from an event
// detail page.
onBeforeRouteLeave(() => {
calendarOpen.value = false
})
</script> </script>
<template> <template>
<!-- pt-2.5 (not py-4) so the header logo/banner sits centered on the <div class="container mx-auto py-6 px-4">
same horizontal axis as the fixed top-right profile icon. --> <!-- Page header -->
<div class="container mx-auto pt-2.5 pb-4 px-4"> <div class="mb-4">
<!-- Page header. A brand may ship a wide banner (logo + wordmark in <h1 class="text-2xl sm:text-3xl font-bold text-foreground">
one image) that replaces the logo + name pair; otherwise we show {{ t('events.title') }}
the brand-kit logo (per-standalone override or global) paired
with the standalone's localized name. Both resolve at build time
via @brand-app-banner / @brand-app-logo so deployers can override
without touching this component. -->
<h1 class="mb-3 text-xl sm:text-2xl font-bold text-foreground">
<img
v-if="hasBanner"
:src="brandAppBannerUrl"
:alt="appName || t('events.title')"
class="h-12 sm:h-14 w-auto max-w-full"
/>
<span v-else class="flex items-center gap-2">
<img
:src="brandAppLogoUrl"
:alt="appName || t('events.title')"
class="h-10 w-10 sm:h-12 sm:w-12 shrink-0"
/>
{{ appName || t('events.title') }}
</span>
</h1> </h1>
</div>
<!-- Search with dropdown overlay --> <!-- Search with dropdown overlay -->
<div class="mb-3"> <div class="mb-4">
<EventSearchOverlay <EventSearchOverlay
:events="events" :events="events"
@select="handleSelectEvent" @select="handleSelectEvent"
/> />
</div> </div>
<!-- Filters trigger + Clear-all stay stationary in a left-aligned <!-- Date picker strip (p'a semana style) -->
column; only the temporal pills scroll horizontally. The <div class="mb-4">
Filters icon (with a count badge when categories are active) <DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
opens a collapsible that hosts category chips below. Past is </div>
a pill at the end of the temporal strip and doesn't live in
the dropdown anymore. Hidden in the Hosting view — the <!-- Temporal filter pills -->
operator's roster doesn't need them. --> <div class="mb-4">
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3"> <TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
<div class="flex items-start gap-3"> </div>
<div class="shrink-0 flex flex-col items-center gap-0.5">
<CollapsibleTrigger as-child> <!-- Role + past-events filter chips. The role chips ("My tickets",
"Hosting") narrow the feed to events the signed-in user
has skin in and are hidden when logged out. The "Past events"
chip is always visible since past-browsing doesn't require an
account. -->
<div class="mb-4 flex flex-wrap gap-2">
<template v-if="isAuthenticated">
<Button <Button
variant="ghost" :variant="onlyOwnedTickets ? 'default' : 'outline'"
size="icon" size="sm"
class="rounded-full h-8 w-8 relative" class="gap-1.5"
:class="{ 'bg-accent text-accent-foreground': filtersOpen || filterCount > 0 }" @click="toggleOwnedTickets"
:aria-label="t('events.filters.filters', 'Filters')"
:aria-expanded="filtersOpen"
> >
<Ticket class="w-3.5 h-3.5" />
{{ t('events.filters.myTickets', 'My tickets') }}
</Button>
<Button
:variant="onlyHosting ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleHosting"
>
<Megaphone class="w-3.5 h-3.5" />
{{ t('events.filters.hosting', 'Hosting') }}
</Button>
</template>
<Button
:variant="showPast ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="togglePast"
>
<History class="w-3.5 h-3.5" />
{{ t('events.filters.pastEvents', 'Past events') }}
</Button>
</div>
<!-- Category filters (collapsible) -->
<Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child>
<Button variant="ghost" size="sm" class="gap-1.5 text-muted-foreground">
<SlidersHorizontal class="w-4 h-4" /> <SlidersHorizontal class="w-4 h-4" />
<span Categories
v-if="filterCount > 0" <span v-if="selectedCategories.length > 0" class="text-xs bg-primary text-primary-foreground rounded-full px-1.5">
class="absolute -top-1 -right-1 min-w-[16px] h-[16px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold flex items-center justify-center" {{ selectedCategories.length }}
>
{{ filterCount }}
</span> </span>
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': filtersOpen }" />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<Button <CollapsibleContent class="mt-2">
v-if="hasActiveFilters"
variant="ghost"
size="sm"
class="h-5 px-1 text-[10px] text-muted-foreground"
@click="resetFilters"
>
{{ t('events.filters.clearAll', 'Clear all') }}
</Button>
</div>
<div class="flex-1 min-w-0 pt-0.5">
<TemporalFilterBar
:model-value="temporal"
:show-past="showPast"
@update:model-value="setTemporal"
@toggle-past="togglePast"
/>
</div>
<!-- Calendar shortcut — opens the date-picker popup to filter the
feed to a single day. Highlighted while a day filter is
active. -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="{ 'bg-accent text-accent-foreground': selectedDate }"
:aria-label="t('events.nav.calendar')"
@click="calendarOpen = true"
>
<CalendarDays class="h-4 w-4" />
</Button>
</div>
<CollapsibleContent class="mt-3">
<CategoryFilterBar <CategoryFilterBar
:selected="selectedCategories" :selected="selectedCategories"
@toggle="toggleCategory" @toggle="toggleCategory"
@ -220,61 +141,24 @@ onBeforeRouteLeave(() => {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<!-- Active day-filter chip — removing it clears ONLY the date <!-- Active filters indicator -->
selection (categories have their own clear in the filter <div v-if="hasActiveFilters" class="flex items-center gap-2 mb-4">
dropdown). Shown when a day is picked from the calendar popup. --> <span class="text-xs text-muted-foreground">Filters active</span>
<div v-if="selectedDate" class="mb-3 flex"> <Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="resetFilters">
<Button Clear all
variant="secondary"
size="sm"
class="h-7 gap-1.5"
:aria-label="t('events.filters.clearDate', 'Clear date filter')"
@click="clearSelectedDate"
>
<CalendarDays class="w-3.5 h-3.5" />
{{ selectedDateLabel }}
<X class="w-3.5 h-3.5" />
</Button> </Button>
</div> </div>
<!-- Create-activity CTA — shown when the Hosting bottom-nav tab is
active. Replaces the dedicated Create entry that used to live
in the bottom nav; lives here so it shows up exactly when the
user is in the "events I'm running" view. -->
<Button
v-if="onlyHosting"
class="w-full mb-3 gap-1.5"
@click="openCreate"
>
<Plus class="w-4 h-4" />
{{ t('events.createNew') }}
</Button>
<!-- Error state --> <!-- Error state -->
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm"> <div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
{{ error }} {{ error }}
</div> </div>
<!-- Event feed. The Hosting view renders compact rows so the <!-- Event feed -->
operator can scan their roster without the visual weight of
hero images they already recognize. -->
<EventList <EventList
:events="events" :events="events"
:is-loading="isLoading" :is-loading="isLoading"
:compact="onlyHosting"
@select="handleSelectEvent" @select="handleSelectEvent"
/> />
<!-- Date-picker popup: month grid with per-day event dots. Picking a
day filters the feed to it and closes. -->
<EventCalendarPopup
v-model:open="calendarOpen"
:events="calendarEvents"
:selected-categories="selectedCategories"
:title="t('events.nav.calendar', 'Calendar')"
:description="t('events.calendar.pickDay', 'Pick a day to see its events')"
@select-date="onSelectDate"
@toggle-category="toggleCategory"
/>
</div> </div>
</template> </template>

View file

@ -1,119 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import { renderTicketQr } from '../lib/ticketQr'
import { RouterLink, onBeforeRouteLeave } from 'vue-router'
import { useUserTickets } from '../composables/useUserTickets' import { useUserTickets } from '../composables/useUserTickets'
import { useEvents } from '../composables/useEvents'
import { useEventsStore } from '../stores/events'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
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 { format, startOfDay, endOfDay } from 'date-fns' import { format } from 'date-fns'
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-vue-next' import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import EventCalendarPopup from '../components/EventCalendarPopup.vue'
import { useDateLocale } from '../composables/useDateLocale'
import type { Event } from '../types/event'
const { isAuthenticated, userDisplay } = useAuth() const { isAuthenticated, userDisplay } = useAuth()
const { dateLocale } = useDateLocale()
const { const {
tickets, tickets,
paidTickets,
pendingTickets,
registeredTickets,
groupedTickets, groupedTickets,
isLoading, isLoading,
error, error,
refresh refresh
} = useUserTickets() } = useUserTickets()
// Subscribe to the events feed so we can map ticket.eventId → event.title.
// The events store is shared (pinia), so if the user has already visited
// the feed this is a no-op fresh subscription; nothing depends on it
// being the canonical one.
const eventsStore = useEventsStore()
const { subscribe: subscribeToEvents } = useEvents()
function eventTitleFor(eventId: string): string | null {
return eventsStore.getEventById(eventId)?.title ?? null
}
function eventShortLabel(eventId: string): string {
return `Event: ${eventId.slice(0, 8)}…`
}
// Past/upcoming toggle. Defaults to upcoming. An event whose end (or
// start, if no end) is before now counts as past; events not yet
// resolved from relays are treated as upcoming so their tickets stay
// visible until we know otherwise.
const showPast = ref(false)
function isGroupPast(eventId: string): boolean {
const ev = eventsStore.getEventById(eventId)
if (!ev) return false
const end = ev.endDate ?? ev.startDate
return end < new Date()
}
// Calendar popup: visualise the days the user has events. Picking a day
// filters the ticket list to it (overriding the upcoming/past toggle);
// clearing it returns to the toggle.
const calendarOpen = ref(false)
const selectedDay = ref<Date | null>(null)
// The user's events (resolved from their ticket groups) — feeds the
// calendar popup's per-day dots.
const myEvents = computed<Event[]>(() => {
const out: Event[] = []
for (const g of groupedTickets.value) {
const ev = eventsStore.getEventById(g.eventId)
if (ev) out.push(ev)
}
return out
})
const selectedDayLabel = computed(() =>
selectedDay.value
? format(selectedDay.value, 'EEE, MMM d', { locale: dateLocale.value })
: '',
)
function isGroupOnDay(eventId: string, day: Date): boolean {
const ev = eventsStore.getEventById(eventId)
if (!ev) return false
const end = ev.endDate ?? ev.startDate
return ev.startDate <= endOfDay(day) && end >= startOfDay(day)
}
function onSelectDay(date: Date) {
selectedDay.value = date
}
// Don't let the calendar popup persist across navigation.
onBeforeRouteLeave(() => {
calendarOpen.value = false
})
const visibleGroups = computed(() => {
if (selectedDay.value) {
return groupedTickets.value.filter(g => isGroupOnDay(g.eventId, selectedDay.value!))
}
return groupedTickets.value.filter(g => isGroupPast(g.eventId) === showPast.value)
})
// Tab counts derived from the visible (past/upcoming-filtered) groups so
// the badges match what's actually shown.
const visibleCounts = computed(() => {
let all = 0, paid = 0, pending = 0, registered = 0
for (const g of visibleGroups.value) {
all += g.tickets.length
paid += g.paidCount
pending += g.pendingCount
registered += g.registeredCount
}
return { all, paid, pending, registered }
})
const qrCodes = ref<Record<string, string>>({}) const qrCodes = ref<Record<string, string>>({})
const currentTicketIndex = ref<Record<string, number>>({}) const currentTicketIndex = ref<Record<string, number>>({})
@ -135,7 +43,13 @@ async function generateQRCode(ticketId: string) {
if (qrCodes.value[ticketId]) return qrCodes.value[ticketId] if (qrCodes.value[ticketId]) return qrCodes.value[ticketId]
try { try {
const dataUrl = await renderTicketQr(ticketId, { width: 200 }) const qrcode = await import('qrcode')
const ticketUrl = `ticket://${ticketId}`
const dataUrl = await qrcode.toDataURL(ticketUrl, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' }
})
qrCodes.value[ticketId] = dataUrl qrCodes.value[ticketId] = dataUrl
return dataUrl return dataUrl
} catch (error) { } catch (error) {
@ -196,10 +110,6 @@ watch(groupedTickets, async (newGroups) => {
}, { immediate: true }) }, { immediate: true })
onMounted(async () => { onMounted(async () => {
// Kick off the events subscription so eventTitleFor() can resolve
// names as relay events stream in. Fire-and-forget — the QR cards
// render fine while the title is still loading.
subscribeToEvents()
if (isAuthenticated.value) { if (isAuthenticated.value) {
await refresh() await refresh()
} }
@ -246,83 +156,23 @@ onMounted(async () => {
</div> </div>
<div v-else-if="tickets.length > 0"> <div v-else-if="tickets.length > 0">
<!-- Filter row — own row, left-aligned so it clears the fixed
top-right hamburger menu. Upcoming/Past toggle by default;
when a day is picked from the calendar it's replaced by a
removable date chip (the day overrides the toggle). The
calendar button opens a popup visualising the user's event
dates. -->
<div class="mb-4 flex items-center gap-2">
<div v-if="!selectedDay" class="inline-flex rounded-md border p-0.5">
<Button
:variant="!showPast ? 'default' : 'ghost'"
size="sm"
class="h-7"
@click="showPast = false"
>
Upcoming
</Button>
<Button
:variant="showPast ? 'default' : 'ghost'"
size="sm"
class="h-7"
@click="showPast = true"
>
Past
</Button>
</div>
<Button
v-else
variant="secondary"
size="sm"
class="h-7 gap-1.5"
aria-label="Clear day filter"
@click="selectedDay = null"
>
<CalendarDays class="w-3.5 h-3.5" />
{{ selectedDayLabel }}
<X class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="{ 'bg-accent text-accent-foreground': selectedDay }"
aria-label="Open calendar"
@click="calendarOpen = true"
>
<CalendarDays class="h-4 w-4" />
</Button>
</div>
<Tabs default-value="all" class="w-full"> <Tabs default-value="all" class="w-full">
<TabsList class="grid w-full grid-cols-4"> <TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="all">All ({{ visibleCounts.all }})</TabsTrigger> <TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ visibleCounts.paid }})</TabsTrigger> <TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ visibleCounts.pending }})</TabsTrigger> <TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ visibleCounts.registered }})</TabsTrigger> <TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger>
</TabsList> </TabsList>
<!-- All Tickets Tab --> <!-- All Tickets Tab -->
<TabsContent value="all"> <TabsContent value="all">
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
<div v-if="visibleGroups.length === 0" class="text-center py-8 text-muted-foreground"> <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{{ selectedDay ? 'No tickets on this day' : (showPast ? 'No past tickets' : 'No upcoming tickets') }} <Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in visibleGroups" :key="group.eventId" class="flex flex-col">
<CardHeader> <CardHeader>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between">
<CardTitle class="text-foreground min-w-0 flex-1"> <CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<RouterLink <Badge variant="outline">
:to="{ name: 'event-detail', params: { id: group.eventId } }"
class="hover:underline truncate block"
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
>
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
</RouterLink>
</CardTitle>
<Badge variant="outline" class="shrink-0">
{{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }} {{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }}
</Badge> </Badge>
</div> </div>
@ -408,19 +258,11 @@ onMounted(async () => {
<!-- Paid, Pending, Registered tabs follow the same pattern but filter --> <!-- Paid, Pending, Registered tabs follow the same pattern but filter -->
<TabsContent value="paid"> <TabsContent value="paid">
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
<div v-if="visibleCounts.paid === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div> <div v-if="paidTickets.length === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in visibleGroups.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col"> <Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader> <CardHeader>
<CardTitle class="text-foreground min-w-0"> <CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<RouterLink
:to="{ name: 'event-detail', params: { id: group.eventId } }"
class="hover:underline truncate block"
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
>
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
</RouterLink>
</CardTitle>
<CardDescription>{{ group.paidCount }} paid ticket{{ group.paidCount !== 1 ? 's' : '' }}</CardDescription> <CardDescription>{{ group.paidCount }} paid ticket{{ group.paidCount !== 1 ? 's' : '' }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -433,19 +275,11 @@ onMounted(async () => {
<TabsContent value="pending"> <TabsContent value="pending">
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
<div v-if="visibleCounts.pending === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div> <div v-if="pendingTickets.length === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in visibleGroups.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75"> <Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
<CardHeader> <CardHeader>
<CardTitle class="text-foreground min-w-0"> <CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<RouterLink
:to="{ name: 'event-detail', params: { id: group.eventId } }"
class="hover:underline truncate block"
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
>
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
</RouterLink>
</CardTitle>
<CardDescription>{{ group.pendingCount }} pending ticket{{ group.pendingCount !== 1 ? 's' : '' }}</CardDescription> <CardDescription>{{ group.pendingCount }} pending ticket{{ group.pendingCount !== 1 ? 's' : '' }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -458,19 +292,11 @@ onMounted(async () => {
<TabsContent value="registered"> <TabsContent value="registered">
<ScrollArea class="h-[600px] w-full pr-4"> <ScrollArea class="h-[600px] w-full pr-4">
<div v-if="visibleCounts.registered === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div> <div v-if="registeredTickets.length === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in visibleGroups.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col"> <Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader> <CardHeader>
<CardTitle class="text-foreground min-w-0"> <CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<RouterLink
:to="{ name: 'event-detail', params: { id: group.eventId } }"
class="hover:underline truncate block"
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
>
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
</RouterLink>
</CardTitle>
<CardDescription>{{ group.registeredCount }} registered ticket{{ group.registeredCount !== 1 ? 's' : '' }}</CardDescription> <CardDescription>{{ group.registeredCount }} registered ticket{{ group.registeredCount !== 1 ? 's' : '' }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -482,15 +308,5 @@ onMounted(async () => {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
<!-- Calendar popup: dots show the days the user has events; picking
one filters the ticket list to that day. -->
<EventCalendarPopup
v-model:open="calendarOpen"
:events="myEvents"
title="Your event dates"
description="Pick a day to see your tickets for it"
@select-date="onSelectDay"
/>
</div> </div>
</template> </template>

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { import {
ArrowLeft, ArrowLeft,
CheckCircle2, CheckCircle2,
@ -10,20 +9,15 @@ import {
Ticket, Ticket,
ScanLine, ScanLine,
RefreshCw, RefreshCw,
UserCheck,
Search,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { format } from 'date-fns' import { format } from 'date-fns'
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 { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import QRScanner from '@/components/ui/qr-scanner.vue' import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner' import { useTicketScanner } from '../composables/useTicketScanner'
import type { EventTicket } from '../composables/useTicketScanner'
import { useEventDetail } from '../composables/useEventDetail' import { useEventDetail } from '../composables/useEventDetail'
import { useFuzzySearch } from '@/composables/useFuzzySearch'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -41,14 +35,8 @@ const {
refreshStats, refreshStats,
onDecode, onDecode,
resume, resume,
registerManually,
} = useTicketScanner(eventId) } = useTicketScanner(eventId)
// Tracks tickets currently mid-register (manual button click), so each
// row can render a per-row spinner without blocking the rest of the
// list. A Set keeps add/remove O(1).
const pendingRegister = ref<Set<string>>(new Set())
const scannerOpen = ref(true) const scannerOpen = ref(true)
const activeTab = ref<'scanner' | 'list'>('scanner') const activeTab = ref<'scanner' | 'list'>('scanner')
@ -76,55 +64,11 @@ const remainingCount = computed(() => {
return Math.max(0, soldCount.value - registeredCount.value) return Math.max(0, soldCount.value - registeredCount.value)
}) })
// Full ticket roster, sorted so unregistered (actionable) rows lead // Registered tickets only — what the "Scanned" tab shows.
// and registered rows follow most-recent-first. Powers the Tickets const registeredTickets = computed(
// tab where the host can manually register attendees who can prove () => eventStats.value?.tickets.filter(t => t.registered) ?? [],
// identity but can't present a scannable QR.
const allTickets = computed<EventTicket[]>(() => {
const list = eventStats.value?.tickets ?? []
return [...list].sort((a, b) => {
if (a.registered !== b.registered) return a.registered ? 1 : -1
if (a.registered && b.registered) {
return (b.registeredAt ?? '').localeCompare(a.registeredAt ?? '')
}
return 0
})
})
const totalTicketsCount = computed(() => eventStats.value?.tickets.length ?? 0)
const unregisteredCount = computed(
() => allTickets.value.filter(t => !t.registered).length,
) )
// Fuzzy match on holder name + ticket id. When the search box is
// empty, Fuse returns the list in its incoming order so our
// unregistered-first sort is preserved.
const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
allTickets,
{
fuseOptions: {
keys: [
{ name: 'name', weight: 0.7 },
{ name: 'id', weight: 0.3 },
],
threshold: 0.3,
ignoreLocation: true,
},
matchAllWhenSearchEmpty: true,
},
)
async function handleManualRegister(ticket: EventTicket) {
pendingRegister.value.add(ticket.id)
const res = await registerManually(ticket.id)
pendingRegister.value.delete(ticket.id)
if (res.ok) {
toast.success(`Registered ${ticket.name || ticket.id.slice(0, 8) + '…'}`)
} else {
toast.error(res.error || 'Failed to register')
}
}
function handleResult(qrText: string) { function handleResult(qrText: string) {
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5` // Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
// already throttles, and useTicketScanner.onDecode dedups the same // already throttles, and useTicketScanner.onDecode dedups the same
@ -190,7 +134,7 @@ function fmtTime(iso: string) {
<p class="text-2xl font-bold text-foreground"> <p class="text-2xl font-bold text-foreground">
{{ remainingCount ?? '—' }} {{ remainingCount ?? '—' }}
</p> </p>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Not scanned</p> <p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Remaining</p>
</div> </div>
</div> </div>
@ -212,21 +156,13 @@ function fmtTime(iso: string) {
<Tabs v-model="activeTab" class="w-full"> <Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4"> <TabsList class="grid w-full grid-cols-2 mb-4">
<!-- Icon + label wrapped in a real flex container so they <TabsTrigger value="scanner" class="gap-1.5">
share a gap and items-center alignment. TabsTrigger's
internal slot lives in an inline span, so a `gap-1.5`
on the trigger itself never reaches these two children. -->
<TabsTrigger value="scanner">
<span class="inline-flex items-center justify-center gap-1.5">
<ScanLine class="w-4 h-4" /> <ScanLine class="w-4 h-4" />
Scanner Scanner
</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="list"> <TabsTrigger value="list" class="gap-1.5">
<span class="inline-flex items-center justify-center gap-1.5">
<Ticket class="w-4 h-4" /> <Ticket class="w-4 h-4" />
Tickets ({{ totalTicketsCount }}) Scanned ({{ registeredCount }})
</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -254,83 +190,39 @@ function fmtTime(iso: string) {
<TabsContent value="list" class="mt-0"> <TabsContent value="list" class="mt-0">
<div class="space-y-3"> <div class="space-y-3">
<h2 class="text-sm font-medium text-foreground"> <h2 class="text-sm font-medium text-foreground">
{{ registeredCount }} / {{ totalTicketsCount }} registered {{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
· {{ unregisteredCount }} to go
</span>
</h2> </h2>
<!-- Fuzzy filter on holder name + ticket id (Fuse.js via <ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
useFuzzySearch). Empty query → all rows in their
sort order; typing → reordered by relevance. -->
<div v-if="allTickets.length > 0" class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
type="search"
placeholder="Search by name or ticket id…"
class="pl-8 h-9"
/>
</div>
<!-- Unregistered rows lead the list so the operator can act
on the actionable ones first; tap "Register" to mark an
attendee present without a QR (e.g. lost phone, known
in person). Failures surface as a toast; the row reverts. -->
<ScrollArea v-if="searchedTickets.length > 0" class="h-[60vh]">
<ul class="space-y-1.5 pr-3"> <ul class="space-y-1.5 pr-3">
<li <li
v-for="ticket in searchedTickets" v-for="record in registeredTickets"
:key="ticket.id" :key="record.id"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm" class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
> >
<div class="min-w-0"> <div class="min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Badge <Badge
v-if="ticket.registered && ticket.registeredAt" v-if="record.registeredAt"
variant="secondary" variant="secondary"
class="text-[10px] font-mono px-1.5" class="text-[10px] font-mono px-1.5"
> >
{{ fmtTime(ticket.registeredAt) }} {{ fmtTime(record.registeredAt) }}
</Badge> </Badge>
<span v-if="ticket.name" class="font-medium text-foreground"> <span v-if="record.name" class="font-medium text-foreground">
{{ ticket.name }} {{ record.name }}
</span> </span>
</div> </div>
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5"> <p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
{{ ticket.id }} {{ record.id }}
</p> </p>
</div> </div>
<CheckCircle2 <CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
v-if="ticket.registered"
class="w-4 h-4 text-emerald-500 shrink-0"
/>
<Button
v-else
size="sm"
variant="outline"
class="shrink-0 gap-1"
:disabled="pendingRegister.has(ticket.id)"
@click="handleManualRegister(ticket)"
>
<RefreshCw
v-if="pendingRegister.has(ticket.id)"
class="w-3.5 h-3.5 animate-spin"
/>
<UserCheck v-else class="w-3.5 h-3.5" />
Register
</Button>
</li> </li>
</ul> </ul>
</ScrollArea> </ScrollArea>
<p
v-else-if="allTickets.length === 0"
class="text-sm text-muted-foreground text-center py-12"
>
No tickets sold yet.
</p>
<p v-else class="text-sm text-muted-foreground text-center py-12"> <p v-else class="text-sm text-muted-foreground text-center py-12">
No tickets match “{{ searchQuery }}”. No tickets scanned yet.
</p> </p>
</div> </div>
</TabsContent> </TabsContent>

View file

@ -1,155 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Ticket, CheckCircle2, Clock, CreditCard, Loader2, AlertCircle } from 'lucide-vue-next'
import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { EventsNostrService } from '../services/EventsNostrService'
import { useEventsStore } from '../stores/events'
import { renderTicketQr } from '../lib/ticketQr'
import type { PublicTicket } from '../types/ticket'
/**
* Public ticket page — where the emailed link lands
* (`<frontend_url>/events/ticket/{ticket_id}`). Anonymous: the ticket
* id in the URL is the door credential, the same token the QR carries,
* exactly like the LNbits UI's own /events/ticket/{id} page.
*/
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const ticketId = route.params.ticketId as string
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const store = useEventsStore()
const ticket = ref<PublicTicket | null>(null)
const eventName = ref<string | null>(null)
const qr = ref<string | null>(null)
const isLoading = ref(true)
const notFound = ref(false)
const status = computed(() => {
if (!ticket.value) return null
if (!ticket.value.paid) return { label: t('events.ticket.pending'), icon: Clock, variant: 'secondary' as const }
if (ticket.value.registered) return { label: t('events.ticket.registered'), icon: CheckCircle2, variant: 'default' as const }
return { label: t('events.ticket.paid'), icon: CreditCard, variant: 'default' as const }
})
// Event name resolution, cheapest first: the Nostr store (d-tag ==
// LNbits event id), a scoped relay query, then the public REST event
// (which 410s for sold-out / closed events — non-fatal here).
async function resolveEventName(eventId: string) {
const cached = store.getEventById(eventId)
if (cached) {
eventName.value = cached.title
return
}
const nostr = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
if (nostr) {
try {
const results = await nostr.queryCalendarEvents({ dTags: [eventId] })
store.upsertEvents(results)
const hit = store.getEventById(eventId)
if (hit) {
eventName.value = hit.title
return
}
} catch {
// fall through
}
}
try {
const pub = await ticketApi.getPublicEvent(eventId)
eventName.value = pub.name
} catch {
eventName.value = null
}
}
onMounted(async () => {
try {
ticket.value = await ticketApi.getTicket(ticketId)
} catch {
notFound.value = true
isLoading.value = false
return
}
isLoading.value = false
void resolveEventName(ticket.value.eventId)
try {
qr.value = await renderTicketQr(ticketId, { width: 240 })
} catch {
qr.value = null
}
})
function goToEvent() {
if (ticket.value) router.push(`/events/${ticket.value.eventId}`)
else router.push('/events')
}
</script>
<template>
<div class="container mx-auto py-6 px-4 max-w-md">
<div class="flex items-center mb-4">
<Button variant="ghost" size="sm" class="gap-1.5" @click="goToEvent">
<ArrowLeft class="w-4 h-4" />
{{ t('events.ticket.backToEvent') }}
</Button>
</div>
<div v-if="isLoading" class="py-16 flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 class="w-8 h-8 animate-spin" />
<span class="text-sm">{{ t('events.ticket.loading') }}</span>
</div>
<div v-else-if="notFound" class="py-16 flex flex-col items-center gap-3 text-center">
<AlertCircle class="w-10 h-10 text-destructive" />
<p class="text-sm text-muted-foreground">{{ t('events.ticket.notFound') }}</p>
</div>
<div v-else-if="ticket" class="bg-card border border-border rounded-xl p-5 space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-lg font-semibold flex items-center gap-2">
<Ticket class="w-5 h-5 shrink-0" />
{{ t('events.ticket.title') }}
</h1>
<p class="text-sm text-muted-foreground truncate">
{{ eventName ?? t('events.ticket.event') }}
</p>
</div>
<Badge v-if="status" :variant="status.variant" class="gap-1 shrink-0">
<component :is="status.icon" class="w-3.5 h-3.5" />
{{ status.label }}
</Badge>
</div>
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
<img v-if="qr" :src="qr" :alt="`Ticket ${ticketId}`" class="block h-60 w-60" />
<div v-else class="h-60 w-60 flex items-center justify-center">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
<div class="text-sm space-y-1">
<div v-if="ticket.name" class="flex justify-between gap-3">
<span class="text-muted-foreground">{{ t('events.ticket.holder') }}</span>
<span class="font-medium truncate">{{ ticket.name }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">ID</span>
<span class="font-mono text-xs">{{ ticketId }}</span>
</div>
</div>
<p class="text-xs text-muted-foreground text-center">
{{ ticket.paid ? t('events.ticket.showAtDoor') : t('events.ticket.pending') }}
</p>
</div>
</div>
</template>

View file

@ -84,12 +84,8 @@
<!-- Step 1: Account Selection --> <!-- Step 1: 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">Select the account for this expense.</span> Select the account for this expense
<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" />
Use the "Other" account if you're not sure.
</span>
</p> </p>
<AccountSelector <AccountSelector
v-model="selectedAccount" v-model="selectedAccount"
@ -107,17 +103,18 @@
<FormLabel>Description *</FormLabel> <FormLabel>Description *</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="A detailed description of the purchase/invoice/bill, and what it was used for (event/project/etc)..." placeholder="e.g., Biocoop, Ferme des Croquantes, Foix Market, etc"
v-bind="componentField" v-bind="componentField"
rows="3" rows="3"
/> />
</FormControl> </FormControl>
<FormDescription>
Describe what this expense was for
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Amount + Currency -->
<div class="grid grid-cols-2 gap-4">
<!-- Amount --> <!-- Amount -->
<FormField v-slot="{ componentField }" name="amount"> <FormField v-slot="{ componentField }" name="amount">
<FormItem> <FormItem>
@ -131,6 +128,9 @@
step="0.01" step="0.01"
/> />
</FormControl> </FormControl>
<FormDescription>
Amount in selected currency
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@ -141,7 +141,7 @@
<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>
@ -155,21 +155,26 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>
Currency for this expense
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
</div>
<!-- Reference (optional) --> <!-- Reference (optional) -->
<FormField v-slot="{ componentField }" name="reference"> <FormField v-slot="{ componentField }" name="reference">
<FormItem> <FormItem>
<FormLabel>Reference (optional)</FormLabel> <FormLabel>Reference</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="e.g., Invoice #123, Receipt #456" placeholder="e.g., Invoice #123, Receipt #456"
v-bind="componentField" v-bind="componentField"
/> />
</FormControl> </FormControl>
<FormDescription>
Optional reference number or note
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@ -274,7 +279,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { DollarSign, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock, AlertTriangle } from 'lucide-vue-next' import { DollarSign, 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

@ -198,12 +198,13 @@ function back() {
</section> </section>
<!-- Sticky bottom bar with qty stepper + add-to-cart. <!-- Sticky bottom bar with qty stepper + add-to-cart.
Sits flush above BottomNav on every breakpoint via the height Sits flush above BottomNav (h-14 + safe-area inset) on every
the nav publishes (--bottom-nav-height, BottomNav.vue) — no breakpoint. Previously `bottom-16` left an 8px gap on mobile
hand-derived h-14 + safe-area arithmetic to drift. --> (BottomNav is h-14 = 56px, not 64px), and `sm:bottom-0` made
it overlap the nav on desktop. -->
<div <div
class="fixed inset-x-0 z-20 border-t border-border bg-background/95 px-4 py-3 backdrop-blur" class="fixed inset-x-0 z-20 border-t border-border bg-background/95 px-4 py-3 backdrop-blur"
style="bottom: var(--bottom-nav-height)" style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
> >
<div class="container mx-auto flex max-w-2xl items-center gap-3"> <div class="container mx-auto flex max-w-2xl items-center gap-3">
<div class="inline-flex items-center rounded-full border border-border bg-card"> <div class="inline-flex items-center rounded-full border border-border bg-card">

View file

@ -240,28 +240,21 @@ export default class WalletService extends BaseService {
let endpoint = '' let endpoint = ''
let body: any = {} let body: any = {}
// Determine payment type and prepare request. // Determine payment type and prepare request
// QR-scanned invoices are uppercase bech32, so detection must be if (request.destination.startsWith('ln')) {
// case-insensitive. Match BOLT11 by its HRP (lnbc / lntb / lntbs / // Lightning invoice
// lnbcrt) rather than a bare "ln" prefix, which would also swallow
// bech32 LNURLs and misroute them to the bolt11 endpoint.
const dest = request.destination.trim()
const lower = dest.toLowerCase()
if (lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lntbs') || lower.startsWith('lnbcrt')) {
// Lightning invoice (BOLT11) — send the lowercase canonical form
endpoint = `${config.api.baseUrl}/api/v1/payments` endpoint = `${config.api.baseUrl}/api/v1/payments`
body = { body = {
out: true, out: true,
bolt11: lower bolt11: request.destination
} }
} else if (dest.includes('@') || lower.startsWith('lnurl')) { } else if (request.destination.includes('@') || request.destination.toLowerCase().startsWith('lnurl')) {
// Lightning address or LNURL // Lightning address or LNURL
endpoint = `${config.api.baseUrl}/api/v1/payments/lnurl` endpoint = `${config.api.baseUrl}/api/v1/payments/lnurl`
body = { body = {
lnurl: dest.includes('@') lnurl: request.destination.includes('@')
? `https://${dest.split('@')[1]}/.well-known/lnurlp/${dest.split('@')[0]}` ? `https://${request.destination.split('@')[1]}/.well-known/lnurlp/${request.destination.split('@')[0]}`
: dest, : request.destination,
amount: request.amount * 1000, // Convert to millisats amount: request.amount * 1000, // Convert to millisats
comment: request.comment || '' comment: request.comment || ''
} }

View file

@ -231,21 +231,41 @@ export class WalletWebSocketService extends BaseService {
this.handlePaymentNotification(data.payment) this.handlePaymentNotification(data.payment)
} }
// Handle wallet balance update. // Handle wallet balance update
// `wallet_balance` is authoritative and already POST-payment for BOTH
// directions: LNbits re-fetches the wallet AFTER the payment settles
// before emitting the notification (core services/payments.py
// `_send_payment_notification_in_background` → "fetch balance again" →
// services/notifications.py `send_ws_payment_notification` →
// `wallet.balance`). Use it as-is. The previous code subtracted the
// outgoing amount on top, double-deducting (100 → send 10 → showed 80,
// refresh corrected to 90).
if (data.wallet_balance !== undefined) { if (data.wallet_balance !== undefined) {
console.log('WalletWebSocketService: Updating balance to', data.wallet_balance, 'sats', { console.log('WalletWebSocketService: Processing balance update', {
newBalance: data.wallet_balance,
hasPayment: !!data.payment, hasPayment: !!data.payment,
paymentAmount: data.payment?.amount paymentAmount: data.payment?.amount
}) })
this.updateWalletBalance(data.wallet_balance)
let finalBalance = data.wallet_balance
// For outgoing payments, LNbits sends pre-payment balance, so we need to adjust
// For incoming payments, LNbits sends post-payment balance, so use as-is
if (data.payment && data.payment.amount < 0) {
// Outgoing payment - subtract the payment amount from the balance
const paymentSats = Math.abs(data.payment.amount) / 1000
finalBalance = data.wallet_balance - paymentSats
console.log('WalletWebSocketService: Adjusting balance for outgoing payment', {
originalBalance: data.wallet_balance,
paymentSats: paymentSats,
finalBalance: finalBalance
})
} else if (data.payment && data.payment.amount > 0) {
// Incoming payment - use balance as-is (already post-payment)
console.log('WalletWebSocketService: Using balance as-is for incoming payment', {
balance: data.wallet_balance
})
} else {
// No payment in message - use balance as-is
console.log('WalletWebSocketService: Using balance as-is (no payment)', {
balance: data.wallet_balance
})
}
console.log('WalletWebSocketService: Updating balance to', finalBalance, 'sats')
this.updateWalletBalance(finalBalance)
} }
} catch (error) { } catch (error) {

View file

@ -13,29 +13,16 @@ import PreferencesRow from '@/components/layout/PreferencesRow.vue'
const router = useRouter() const router = useRouter()
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
// The app registry. Today this is a static in-code catalog; the view below
// treats it as an opaque list, so the source can later move to a runtime
// feed (an LNbits /hub/apps response or a NIP-78 replaceable event) to
// support installing/removing apps per-instance without a rebuild — without
// touching the render path.
interface Module { interface Module {
label: string label: string
chakra: string chakra: string
icon: any icon: any
bgClass: string bgClass: string
glow: string glow: string
/** Env var holding this app's deployed URL. Absent/empty ⇒ not provisioned. */
envKey?: string envKey?: string
/** Maturity badge ('alpha' | 'beta' | …), shown under the label. */
status?: string status?: string
/** When true, the tile is ghosted out unless the user is logged in. */ /** When true, the tile is ghosted out unless the user is logged in. */
authRequired?: boolean authRequired?: boolean
/**
* Soft kill-switch for a *provisioned* app: false ⇒ rendered greyed-out
* and non-clickable ('inactive') even though it's deployed. Default (unset)
* is active. Seam for a future install/disable model; no app sets it yet.
*/
active?: boolean
/** Unread count for the corner badge. Wire to real data via #32. */ /** Unread count for the corner badge. Wire to real data via #32. */
unread?: number unread?: number
} }
@ -51,57 +38,17 @@ const modules: Module[] = [
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true }, { label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true },
{ label: 'Libra', chakra: 'Sahasrara', icon: Scale, bgClass: '', glow: 'rgba(160,80,220,0.5)', envKey: 'VITE_HUB_LIBRA_URL', status: 'beta', authRequired: true }, { label: 'Libra', chakra: 'Sahasrara', icon: Scale, bgClass: '', glow: 'rgba(160,80,220,0.5)', envKey: 'VITE_HUB_LIBRA_URL', status: 'beta', authRequired: true },
] ]
// Crown at top, root at bottom
const orderedModules = computed(() => [...modules].reverse())
const token = computed(() => localStorage.getItem('lnbits_access_token') || '') const token = computed(() => localStorage.getItem('lnbits_access_token') || '')
/** function hubLink(m: Module): string | null {
* Per-tile availability, resolved from deploy config (env URLs) + auth state. if (!m.envKey) return null
* Explicit states keep the render path declarative and make each future step // Auth-only modules (wallet, chat, libra, tasks) are ghosted when not logged in.
* a small change rather than another overloaded null check. if (m.authRequired && !isAuthenticated.value) return null
*/ const url = import.meta.env[m.envKey] as string | undefined
type Availability = if (!url) return null
| 'available' // provisioned + reachable → link
| 'auth-locked' // provisioned, needs login, logged out → greyed, prompts login
| 'inactive' // provisioned but switched off (active:false) → greyed, inert
| 'unavailable' // not provisioned on this instance → hidden entirely
/** This app's deployed URL, or undefined when not provisioned on this build. */
function appUrl(m: Module): string | undefined {
const url = m.envKey ? (import.meta.env[m.envKey] as string | undefined) : undefined
return url || undefined
}
function availabilityOf(m: Module): Availability {
if (!appUrl(m)) return 'unavailable'
if (m.active === false) return 'inactive'
if (m.authRequired && !isAuthenticated.value) return 'auth-locked'
return 'available'
}
interface Tile {
module: Module
availability: Availability
/** Final href (with auth token appended) — only set for 'available'. */
href?: string
}
// Crown at top, root at bottom. Unavailable (not-deployed) apps are dropped
// here, so the grid only ever renders provisioned tiles.
const tiles = computed<Tile[]>(() =>
[...modules]
.reverse()
.map((m) => {
const availability = availabilityOf(m)
return {
module: m,
availability,
href: availability === 'available' ? withToken(appUrl(m)!) : undefined,
}
})
.filter((t) => t.availability !== 'unavailable'),
)
function withToken(url: string): string {
if (isAuthenticated.value && token.value) { if (isAuthenticated.value && token.value) {
const sep = url.includes('?') ? '&' : '?' const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(token.value)}` return `${url}${sep}token=${encodeURIComponent(token.value)}`
@ -109,35 +56,22 @@ function withToken(url: string): string {
return url return url
} }
function tileTag(t: Tile): 'a' | 'button' | 'div' { function isAuthGated(m: Module): boolean {
if (t.availability === 'available') return 'a' return !!(m.authRequired && !isAuthenticated.value)
if (t.availability === 'auth-locked') return 'button'
return 'div' // inactive
} }
function tileClass(t: Tile): string { function onTileClick(m: Module, event: Event) {
switch (t.availability) { // Ghosted auth-required tiles aren't anchors; intercept and toast.
case 'available': if (isAuthGated(m)) {
return 'bg-card/60 hover:bg-card/40 border-white/10 hover:border-white/25 cursor-pointer'
case 'auth-locked':
return 'bg-card/70 hover:bg-card/55 border-white/5 opacity-60 cursor-pointer'
default: // inactive
return 'bg-card/70 border-white/5 opacity-60 cursor-not-allowed'
}
}
function onTileClick(t: Tile, event: Event) {
// Auth-locked tiles aren't anchors; intercept and prompt login.
if (t.availability === 'auth-locked') {
event.preventDefault() event.preventDefault()
toast.info(`${t.module.label} requires login`, { toast.info(`${m.label} requires login`, {
action: { action: {
label: 'Log in', label: 'Log in',
onClick: () => router.push('/login'), onClick: () => router.push('/login'),
}, },
}) })
} }
// 'inactive' tiles silently do nothing. // "Coming soon" tiles (no envKey, no authRequired) silently do nothing.
} }
</script> </script>
@ -168,27 +102,33 @@ function onTileClick(t: Tile, event: Event) {
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0"> <div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
<component <component
v-for="t in tiles" v-for="m in orderedModules"
:key="t.module.label" :key="m.label"
:is="tileTag(t)" :is="hubLink(m) ? 'a' : (isAuthGated(m) ? 'button' : 'div')"
:href="t.href" :href="hubLink(m) || undefined"
class="relative flex flex-col items-center justify-center gap-1 rounded-xl border backdrop-blur-sm transition-all p-2 min-h-0" class="relative flex flex-col items-center justify-center gap-1 rounded-xl border backdrop-blur-sm transition-all p-2 min-h-0"
:class="tileClass(t)" :class="[
@click="onTileClick(t, $event)" hubLink(m)
? 'bg-card/60 hover:bg-card/40 border-white/10 hover:border-white/25 cursor-pointer'
: isAuthGated(m)
? 'bg-card/70 hover:bg-card/55 border-white/5 opacity-60 cursor-pointer'
: 'bg-card/70 border-white/5 opacity-60 cursor-not-allowed',
]"
@click="onTileClick(m, $event)"
> >
<component :is="t.module.icon" class="h-7 w-7 text-foreground/90" :style="{ filter: `drop-shadow(0 0 8px ${t.module.glow})` }" /> <component :is="m.icon" class="h-7 w-7 text-foreground/90" :style="{ filter: `drop-shadow(0 0 8px ${m.glow})` }" />
<div class="text-center leading-tight"> <div class="text-center leading-tight">
<p class="text-sm font-semibold text-foreground drop-shadow">{{ t.module.label }}</p> <p class="text-sm font-semibold text-foreground drop-shadow">{{ m.label }}</p>
<p v-if="t.module.status" class="text-[9px] font-light text-muted-foreground">{{ t.module.status }}</p> <p v-if="m.status" class="text-[9px] font-light text-muted-foreground">{{ m.status }}</p>
</div> </div>
<!-- Notification badge — wired to data once #32 lands. Hidden when unread is falsy/0. --> <!-- Notification badge — wired to data once #32 lands. Hidden when unread is falsy/0. -->
<span <span
v-if="t.module.unread" v-if="m.unread"
class="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-semibold flex items-center justify-center shadow ring-1 ring-background/60" class="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-semibold flex items-center justify-center shadow ring-1 ring-background/60"
:aria-label="`${t.module.unread} unread`" :aria-label="`${m.unread} unread`"
> >
{{ t.module.unread > 99 ? '99+' : t.module.unread }} {{ m.unread > 99 ? '99+' : m.unread }}
</span> </span>
</component> </component>
</div> </div>

View file

@ -4,7 +4,7 @@
<!-- Logo and Title --> <!-- Logo and Title -->
<div class="text-center space-y-6"> <div class="text-center space-y-6">
<div class="flex justify-center"> <div class="flex justify-center">
<img src="@brand-app-logo" alt="Logo" class="h-24 w-24 sm:h-32 sm:w-32" /> <img src="@/assets/logo.png" alt="Logo" class="h-24 w-24 sm:h-32 sm:w-32" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<h1 class="text-3xl font-bold tracking-tight">Virtual Realm</h1> <h1 class="text-3xl font-bold tracking-tight">Virtual Realm</h1>

View file

@ -5,7 +5,7 @@
<!-- Welcome Section --> <!-- Welcome Section -->
<div class="text-center space-y-2 sm:space-y-4"> <div class="text-center space-y-2 sm:space-y-4">
<div class="flex justify-center"> <div class="flex justify-center">
<img src="@brand-app-logo" alt="Logo" class="h-34 w-34 sm:h-42 sm:w-42 lg:h-50 lg:w-50" /> <img src="@/assets/logo.png" alt="Logo" class="h-34 w-34 sm:h-42 sm:w-42 lg:h-50 lg:w-50" />
</div> </div>
<div class="space-y-1 sm:space-y-3"> <div class="space-y-1 sm:space-y-3">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight">Welcome to the Virtual Realm</h1> <h1 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight">Welcome to the Virtual Realm</h1>

View file

@ -1,10 +0,0 @@
import { describe, it, expect } from 'vitest'
// Smoke test — proves the runner, TS transform and `@` alias resolve so
// the suite has a known-good baseline. Real coverage lives beside the
// code it tests as `*.spec.ts`.
describe('vitest smoke', () => {
it('runs', () => {
expect(1 + 1).toBe(2)
})
})

29
src/vite-env.d.ts vendored
View file

@ -1,31 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" /> /// <reference types="vite-plugin-pwa/client" />
// Brand-kit alias for the active standalone's logo. Resolved at build
// time by vite-branding.ts (per-standalone override or global). The
// `?url` import returns the asset's URL string just like any other
// vite static asset.
declare module '@brand-app-logo?url' {
const src: string
export default src
}
// Optional brand banner (wide logo+wordmark lockup). Resolved at build
// time by vite-branding.ts; falls back to the logo when the active brand
// ships no banner, so the import always resolves. The component gates on
// `VITE_APP_BANNER` before rendering it.
declare module '@brand-app-banner?url' {
const src: string
export default src
}
interface ImportMetaEnv {
/** Brand name, set from brand.json in vite.<app>.config.ts. */
readonly VITE_APP_NAME?: string
/** '1' when the active brand ships a banner, '' otherwise. */
readonly VITE_APP_BANNER?: string
/** Brand default theme mode ('light'|'dark'|'system'), set from brand.json. */
readonly VITE_BRAND_THEME?: string
/** Brand default palette name, set from brand.json. */
readonly VITE_BRAND_PALETTE?: string
}

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