Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.

99 changed files with 1579 additions and 3950 deletions

1
.gitignore vendored
View file

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

View file

@ -712,63 +712,8 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
# Optional: Disable WebSocket if needed
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
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="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
<link rel="icon" href="/favicon.ico" />
<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>
<meta name="apple-mobile-web-app-title" content="Chat">
<meta name="description" content="End-to-end encrypted Nostr chat">

View file

@ -7,19 +7,15 @@ in this file follows from that single fact.
## Strictly-monotonic `created_at` per coord
**Canonical helper:** `src/lib/nostr/timestamp.ts`
`monotonicCreatedAt(lastCreatedAt, now?)` returns `max(now, last + 1)`.
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).
**Canonical:** `src/modules/events/composables/useRSVP.ts`
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
```ts
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
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
```

View file

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

View file

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

View file

@ -5,13 +5,10 @@
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"generate-pwa-assets": "node scripts/generate-pwa-assets.mjs",
"dev": "vite --host",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host",
"analyze": "vite build --mode analyze",
"test": "vitest run",
"test:watch": "vitest",
"dev:events": "vite --host --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",
@ -95,7 +92,6 @@
"@types/node": "^22.18.1",
"@types/qrcode": "^1.5.5",
"@types/rollup-plugin-visualizer": "^4.2.3",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"concurrently": "^8.2.2",
@ -109,7 +105,6 @@
"vite-plugin-image-optimizer": "^1.1.7",
"vite-plugin-inspect": "^0.8.3",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^4.1.9",
"vue-tsc": "^2.2.0",
"web-push": "^3.6.7",
"workbox-window": "^7.3.0"

346
pnpm-lock.yaml generated
View file

@ -150,9 +150,6 @@ importers:
'@types/rollup-plugin-visualizer':
specifier: ^4.2.3
version: 4.2.4
'@vite-pwa/assets-generator':
specifier: ^1.0.2
version: 1.0.2
'@vitejs/plugin-vue':
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))
@ -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))
vite-plugin-pwa:
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)
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))
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)
vue-tsc:
specifier: ^2.2.0
version: 2.2.12(typescript@5.6.3)
@ -717,9 +711,6 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
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':
resolution: {integrity: sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg==}
engines: {node: '>= 16.4.0'}
@ -1292,9 +1283,6 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@quansync/fs@1.0.0':
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
'@rollup/plugin-babel@6.1.0':
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
engines: {node: '>=14.0.0'}
@ -1498,9 +1486,6 @@ packages:
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
@ -1641,12 +1626,6 @@ packages:
'@types/cacheable-request@6.0.3':
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':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -1718,11 +1697,6 @@ packages:
peerDependencies:
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':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -1730,35 +1704,6 @@ packages:
vite: ^5.0.0 || ^6.0.0
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':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
@ -2070,10 +2015,6 @@ packages:
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@ -2180,10 +2121,6 @@ packages:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
cacache@16.1.3:
resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -2215,10 +2152,6 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@ -2334,10 +2267,6 @@ packages:
engines: {node: ^14.13.0 || >=16.0.0}
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:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -2437,14 +2366,6 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
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:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@ -2662,9 +2583,6 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@ -2688,10 +2606,6 @@ packages:
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
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:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
@ -2976,9 +2890,6 @@ packages:
humanize-ms@1.2.1:
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:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -3652,10 +3563,6 @@ packages:
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
engines: {node: '>= 0.4'}
obug@2.1.3:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@ -3786,9 +3693,6 @@ packages:
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pe-library@1.0.1:
resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==}
engines: {node: '>=14', npm: '>=7'}
@ -3896,9 +3800,6 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -4119,9 +4020,6 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
sharp-ico@0.1.5:
resolution: {integrity: sha512-a3jODQl82NPp1d5OYb0wY+oFaPk7AvyxipIowCHk7pBsZCWgbe0yAkU2OOXdoH0ENyANhyOQbs9xkAiRHcF02Q==}
sharp@0.33.5:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@ -4162,9 +4060,6 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@ -4245,12 +4140,6 @@ packages:
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
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:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@ -4418,21 +4307,10 @@ packages:
tiny-each-async@2.0.3:
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:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
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:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
@ -4444,9 +4322,6 @@ packages:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
engines: {node: '>=14.14'}
to-data-view@1.1.0:
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -4522,12 +4397,6 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
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:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -4667,47 +4536,6 @@ packages:
yaml:
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:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@ -4828,11 +4656,6 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@ -5633,8 +5456,6 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@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)':
dependencies:
'@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': {}
'@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)':
dependencies:
'@babel/core': 7.29.0
@ -6552,8 +6369,6 @@ snapshots:
'@sindresorhus/is@4.6.0': {}
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.21':
dependencies:
tslib: 2.8.1
@ -6670,13 +6485,6 @@ snapshots:
'@types/node': 22.19.19
'@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.9': {}
@ -6751,61 +6559,11 @@ snapshots:
transitivePeerDependencies:
- 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))':
dependencies:
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)
'@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':
dependencies:
'@volar/source-map': 2.4.15
@ -7167,8 +6925,6 @@ snapshots:
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
assertion-error@2.0.1: {}
async-function@1.0.0: {}
async@3.2.6: {}
@ -7274,8 +7030,6 @@ snapshots:
dependencies:
run-applescript: 7.1.0
cac@6.7.14: {}
cacache@16.1.3:
dependencies:
'@npmcli/fs': 2.1.2
@ -7332,8 +7086,6 @@ snapshots:
caniuse-lite@1.0.30001793: {}
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@ -7441,8 +7193,6 @@ snapshots:
tree-kill: 1.2.2
yargs: 17.7.2
consola@3.4.2: {}
convert-source-map@2.0.0: {}
copy-anything@4.0.5:
@ -7537,17 +7287,6 @@ snapshots:
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:
dependencies:
mimic-response: 3.1.0
@ -7870,10 +7609,6 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
esutils@2.0.3: {}
eta@3.5.0: {}
@ -7894,8 +7629,6 @@ snapshots:
signal-exit: 3.0.7
strip-eof: 1.0.0
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
external-editor@3.1.0:
@ -8240,8 +7973,6 @@ snapshots:
dependencies:
ms: 2.1.3
ico-endec@0.1.6: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@ -8837,8 +8568,6 @@ snapshots:
has-symbols: 1.1.0
object-keys: 1.1.1
obug@2.1.3: {}
ohash@2.0.11: {}
once@1.4.0:
@ -8957,8 +8686,6 @@ snapshots:
pathe@1.1.2: {}
pathe@2.0.3: {}
pe-library@1.0.1: {}
pend@1.2.0: {}
@ -9042,8 +8769,6 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
quansync@1.0.0: {}
queue-microtask@1.2.3: {}
quick-lru@5.1.1: {}
@ -9333,12 +9058,6 @@ snapshots:
es-errors: 1.3.0
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:
dependencies:
color: 4.2.3
@ -9407,8 +9126,6 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@ -9485,10 +9202,6 @@ snapshots:
dependencies:
minipass: 3.3.6
stackback@0.0.2: {}
std-env@4.1.0: {}
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@ -9657,17 +9370,11 @@ snapshots:
tiny-each-async@2.0.3:
optional: true
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
tmp-promise@3.0.3:
dependencies:
tmp: 0.2.5
@ -9680,8 +9387,6 @@ snapshots:
tmp@0.2.5:
optional: true
to-data-view@1.1.0: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@ -9757,19 +9462,6 @@ snapshots:
has-symbols: 1.1.0
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: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}
@ -9851,7 +9543,7 @@ snapshots:
- rollup
- 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:
debug: 4.4.3
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)
workbox-build: 7.4.1
workbox-window: 7.4.1
optionalDependencies:
'@vite-pwa/assets-generator': 1.0.2
transitivePeerDependencies:
- supports-color
@ -9879,33 +9569,6 @@ snapshots:
lightningcss: 1.32.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: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.6.3)):
@ -10068,11 +9731,6 @@ snapshots:
dependencies:
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:
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="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
<link rel="icon" href="/favicon.ico" />
<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>
<meta name="apple-mobile-web-app-title" content="Restaurant">
<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 -->
<div v-if="currentStep === 1">
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 mb-4 text-sm">
<span class="text-muted-foreground">{{ t('libra.income.selectAccount') }}</span>
<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 class="text-sm text-muted-foreground mb-4">
{{ t('libra.income.selectAccount') }}
</p>
<AccountSelector
v-model="selectedRevenueAccount"
@ -100,58 +96,60 @@
<FormLabel>Description *</FormLabel>
<FormControl>
<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"
rows="3"
/>
</FormControl>
<FormDescription>Describe the source of this income</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div class="grid grid-cols-2 gap-4">
<FormField v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0.00"
v-bind="componentField"
min="0.01"
step="0.01"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0.00"
v-bind="componentField"
min="0.01"
step="0.01"
/>
</FormControl>
<FormDescription>Amount in selected currency</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger class="w-full">
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Currency for this income</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="reference">
<FormItem>
<FormLabel>Reference (optional)</FormLabel>
<FormLabel>Reference</FormLabel>
<FormControl>
<Input placeholder="e.g., Invoice #123, Receipt #456" v-bind="componentField" />
</FormControl>
<FormDescription>Optional reference number or note</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@ -203,6 +201,7 @@ import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@ -222,7 +221,7 @@ import {
DialogHeader,
DialogTitle,
} 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 { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'

View file

@ -221,16 +221,3 @@
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 { useTheme } from '@/components/theme-provider'
import BottomNav, { type BottomTab } from './BottomNav.vue'
import StandaloneMenu, { type SidebarNavItem } from './StandaloneMenu.vue'
import HubPill from './HubPill.vue'
interface Props {
/** App-specific tabs displayed before the constant Profile entry. */
tabs: BottomTab[]
/** Active-tab matcher. Forwarded to BottomNav. */
isActive: (path: string) => boolean
/** Hide the top-right standalone menu only true when this shell is
* rendering the hub itself. Standalones leave this false (default). */
/** Hide the top-right HubPill only true when this shell is rendering
* the hub itself. Standalones leave this false (default). */
hideHub?: boolean
/** App-specific nav items rendered at the top of the standalone menu. */
sidebarNav?: SidebarNavItem[]
/** Forwarded to BottomNav. Hub passes true so logged-out users can still
* reach prefs from the sheet. Standalones leave it false. */
loggedOutOpensSheet?: boolean
}
const props = withDefaults(defineProps<Props>(), {
hideHub: false,
sidebarNav: () => [],
loggedOutOpensSheet: false,
})
const route = useRoute()
@ -44,13 +45,11 @@ const isLoginPage = computed(() => route.path === '/login')
v-if="!isLoginPage"
:tabs="props.tabs"
:is-active="props.isActive"
:logged-out-opens-sheet="props.loggedOutOpensSheet"
/>
</div>
<StandaloneMenu
v-if="!props.hideHub && !isLoginPage"
:items="props.sidebarNav"
/>
<HubPill v-if="!props.hideHub && !isLoginPage" />
<Toaster />
<!-- 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">
<router-link to="/" class="flex items-center gap-2">
<img
src="@brand-app-logo"
src="@/assets/logo.png"
alt="Logo"
class="h-8 w-8"
/>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import type { Component } from 'vue'
import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
export interface BottomTab {
/** Translated label shown under the icon. */
@ -17,11 +18,6 @@ export interface BottomTab {
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
* for auth-required tabs when the user is logged out. */
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 {
@ -29,9 +25,13 @@ interface Props {
/** 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. */
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 })
const router = useRouter()
@ -42,11 +42,6 @@ function onTabClick(tab: BottomTab) {
}
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>
<template>
@ -61,12 +56,12 @@ function isTabActive(tab: BottomTab): boolean {
:key="tab.name"
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="[
isTabActive(tab)
tab.path && props.isActive(tab.path)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
tab.disabled ? 'opacity-50' : '',
]"
:aria-current="isTabActive(tab) ? 'page' : undefined"
:aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
@click="onTabClick(tab)"
>
<component :is="tab.icon" class="w-5 h-5" />
@ -78,6 +73,10 @@ function isTabActive(tab: BottomTab): boolean {
{{ tab.badge > 99 ? '99+' : tab.badge }}
</span>
</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>
</nav>
</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">
<SheetTitle class="flex items-center gap-2">
<img
src="@brand-app-logo"
src="@/assets/logo.png"
alt="Logo"
class="h-8 w-8"
/>

View file

@ -1,277 +1,86 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, Copy, LogIn, LogOut, Pencil, Zap } from 'lucide-vue-next'
import { nip19 } from 'nostr-tools'
import { Home, LogIn } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
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 { useAuth } from '@/composables/useAuthService'
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
import { toastService } from '@/core/services/ToastService'
import PreferencesRow from './PreferencesRow.vue'
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const { isAuthenticated, user, logout } = useAuth()
const { isAuthenticated, user } = useAuth()
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
const lightningDomain = 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 npubPreview = computed(() => {
const pubkey = user.value?.pubkey
if (!pubkey) return ''
try {
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
return `${pubkey.slice(0, 8)}${pubkey.slice(-8)}`
})
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() {
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>
<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>
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
<SheetDescription v-if="isAuthenticated">
{{ t('common.nav.profileDescription') }}
</SheetDescription>
<SheetDescription v-else>
{{ t('common.nav.profileLoggedOutDescription') }}
</SheetDescription>
</SheetHeader>
<SheetHeader>
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
<SheetDescription v-if="isAuthenticated">
{{ t('common.nav.profileDescription') }}
</SheetDescription>
<SheetDescription v-else>
{{ t('common.nav.profileLoggedOutDescription') }}
</SheetDescription>
</SheetHeader>
<!-- Identity card (logged in) summary with an inline edit (pencil)
button that opens the profile form. -->
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4">
<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 ?? ''" />
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
<p v-if="displayName && user?.username" class="text-xs text-muted-foreground truncate">
@{{ user.username }}
</p>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 self-start text-muted-foreground"
:aria-label="t('common.nav.editProfile', 'Edit profile')"
@click="editProfileOpen = true"
>
<Pencil class="h-4 w-4" />
</Button>
</div>
<!-- Identifier rows: full-width value with a corner-offset "legend"
badge straddling the top border (fieldset-legend pattern). The
value gets the entire row so long bech32 / username@domain
strings have room to render. -->
<div class="space-y-3 pt-1">
<!-- Lightning Address this is also the NIP-05 in this stack,
but the @username above already signals the NIP-05. -->
<button
v-if="lightningAddress"
type="button"
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
:aria-label="t('common.nav.copyLightning', 'Copy Lightning address')"
@click="copyToClipboard(lightningAddress, 'lightning')"
>
<span class="absolute -top-2 left-2 inline-flex items-center gap-1 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
<Zap class="w-3 h-3 text-yellow-500 fill-yellow-500" />
{{ t('common.nav.lightning', 'Lightning') }}
</span>
<span class="block truncate pr-6 text-xs font-mono">{{ lightningAddress }}</span>
<component
:is="copiedField === 'lightning' ? Check : Copy"
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
/>
</button>
<!-- npub copy the full bech32 even though we display a preview. -->
<button
v-if="npub"
type="button"
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
:aria-label="t('common.nav.copyNpub', 'Copy npub')"
@click="copyToClipboard(npub, 'npub')"
>
<span class="absolute -top-2 left-2 inline-flex items-center rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
{{ t('common.nav.npub', 'npub') }}
</span>
<span class="block truncate pr-6 text-xs font-mono">{{ npubPreview }}</span>
<component
:is="copiedField === 'npub' ? Check : Copy"
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
/>
</button>
</div>
</div>
<!-- App-specific nav items (rendered by callers like StandaloneMenu) -->
<slot name="app-nav" />
<!-- Cross-app links + global preferences (always visible, auth or not) -->
<div class="mt-4">
<PreferencesRow layout="list" />
</div>
</div>
<!-- Pinned footer: stays stuck to the bottom of the sheet (shrink-0);
"Back to hub" sits directly above the log-in/out bar. -->
<div class="shrink-0 pt-1">
<!-- "Back to hub" shows the HUB's brand-kit logo (the brand's
primary/global logo via @brand-hub-logo) NOT the per-standalone
@brand-app-logo, which resolves to this standalone's own logo. -->
<a
:href="hubRootUrl"
class="flex items-center justify-center gap-3 px-3 py-1.5 hover:bg-accent rounded-md transition-colors"
:aria-label="t('common.nav.backToHub')"
>
<div class="flex items-center gap-3">
<img src="@brand-hub-logo" :alt="t('common.nav.backToHub')" class="w-8 h-8 shrink-0" />
<span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
</div>
</a>
<!-- Logged-out: prominent log-in CTA -->
<div v-if="!isAuthenticated">
<Separator class="mb-2" />
<Button class="w-full" @click="goLogin">
<LogIn class="mr-2 h-4 w-4" />
{{ t('common.nav.login') }}
</Button>
</div>
<!-- Logged-in: log-out button stays visible without opening the edit popup. -->
<div v-else>
<Separator class="mb-2" />
<AlertDialog>
<AlertDialogTrigger as-child>
<Button variant="destructive" class="w-full">
<LogOut class="mr-2 h-4 w-4" />
{{ t('common.nav.logOut', 'Log out') }}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Log out of {{ user?.username || 'your account' }}?
</AlertDialogTitle>
<AlertDialogDescription>
{{ t('common.nav.logOutConfirmDescription', "You'll need to sign in again to access your wallet, post in the forum, place orders, or use any feature that needs your account.") }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('common.nav.cancel', 'Cancel') }}</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@click="onLogout"
>
{{ t('common.nav.logOut', 'Log out') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<!-- Identity card (logged in) -->
<div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
<Avatar class="h-12 w-12">
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p>
</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 />
</DialogContent>
</Dialog>
<!-- Cross-app links + global preferences (always visible, auth or not) -->
<div class="mt-4">
<a
:href="hubRootUrl"
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')"
>
<div class="flex items-center gap-3">
<Home class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
</div>
</a>
<PreferencesRow layout="list" />
</div>
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings -->
<div v-if="!isAuthenticated" class="mt-6">
<Separator class="mb-4" />
<Button class="w-full" @click="goLogin">
<LogIn class="mr-2 h-4 w-4" />
{{ t('common.nav.login') }}
</Button>
</div>
<!-- Logged-in: full profile management form -->
<div v-else class="mt-6">
<Separator class="mb-4" />
<ProfileSettings />
</div>
</template>

View file

@ -50,7 +50,7 @@ const open = ref(false)
</template>
</button>
</SheetTrigger>
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto overflow-x-hidden">
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
<ProfileSheetContent />
</SheetContent>
</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',
]
// The palette styled by the bare `:root` in index.css (no `data-theme`
// 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 DEFAULT_PALETTE: Palette = 'catppuccin'
const useTheme = () => {
const theme = ref<Theme>(DEFAULT_THEME)
const theme = ref<Theme>('dark')
const systemTheme = ref<'dark' | 'light'>('light')
const palette = ref<Palette>(DEFAULT_PALETTE)
@ -63,7 +45,7 @@ const useTheme = () => {
}
const applyPalette = () => {
if (palette.value === BASE_PALETTE) {
if (palette.value === DEFAULT_PALETTE) {
document.documentElement.removeAttribute('data-theme')
} else {
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 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: {
size: {

View file

@ -3,7 +3,7 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
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 type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useAuth } from '@/composables/useAuthService'
@ -23,72 +23,37 @@ const eventsStore = useEventsStore()
const { isAdmin, autoApprove } = useApprovalState()
// Used to merge own LNbits drafts into the events feed right after
// the user creates or edits an event otherwise the new draft only
// surfaces on the next EventsPage subscribe cycle. `onlyHosting`
// is the feed filter that backs the Hosting bottom-nav tab tapping
// 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/')
}
// surfaces on the next EventsPage subscribe cycle.
const { loadOwnEvents } = useEvents()
// 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[]>(() => [
{ name: t('events.nav.feed'), icon: Search, path: '/events' },
{ name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' },
{
name: t('events.nav.feed'),
icon: Home,
name: t('events.createNew'),
icon: Plus,
onClick: () => {
// Tapping Home clears the hosting filter so the feed always
// returns to the unfiltered view, regardless of where the
// user just came from.
if (onlyHosting.value) toggleHosting()
if (route.path !== '/events') router.push('/events')
if (!isAuthenticated.value) {
toast.info('Log in to create an event', {
action: {
label: 'Log in',
onClick: () => router.push('/login'),
},
})
return
}
// Defensively clear any lingering edit selection so the Create
// tap always opens in Create mode regardless of a prior Edit.
eventsStore.editingEvent = null
eventsStore.showCreateDialog = true
},
isActive: () => inFeedRoute() && !onlyHosting.value,
disabled: !isAuthenticated.value,
},
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
{
name: t('events.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'),
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 {
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)
}

View file

@ -22,7 +22,6 @@ const messages: LocaleMessages = {
profileDescription: 'Your Nostr identity and display name.',
profileLoggedOutDescription: 'Sign in or change your preferences.',
login: 'Log in',
menu: 'Menu',
backToHub: 'Back to hub',
hub: 'Hub',
theme: 'Theme',
@ -69,10 +68,6 @@ const messages: LocaleMessages = {
hosting: 'Hosting',
pastEvents: 'Past events',
past: 'Past',
filters: 'Filters',
clearAll: 'Clear all',
filteringBy: 'Filtering by:',
removeCategory: 'Remove {category} filter',
},
categories: {
concert: 'Concert',
@ -103,13 +98,15 @@ const messages: LocaleMessages = {
},
detail: {
getTicket: 'Get Ticket',
going: 'Going',
maybe: 'Maybe',
notGoing: 'Not Going',
contactOrganizer: 'Contact Organizer',
organizer: 'Organizer',
location: 'Location',
when: 'When',
tickets: 'Tickets',
ticketsAvailable: '{count} tickets available',
ticketsRemainingOfTotal: '{count} of {total} tickets left',
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
unlimitedTickets: 'Unlimited tickets',
buyTicket: 'Buy ticket',
@ -130,7 +127,7 @@ const messages: LocaleMessages = {
registered: 'Registered',
},
nav: {
feed: 'Home',
feed: 'Feed',
calendar: 'Calendar',
map: 'Map',
favorites: 'Favorites',
@ -202,8 +199,7 @@ const messages: LocaleMessages = {
income: {
title: 'Add Income',
description: 'Submit income for the organization',
selectAccount: 'Select the revenue account.',
otherAccountHint: 'Use the "Other" account if you\'re not sure.',
selectAccount: 'Select the revenue account',
submitIncome: 'Submit Income',
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.',
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
login: 'Iniciar sesión',
menu: 'Menú',
backToHub: 'Volver al hub',
hub: 'Hub',
theme: 'Tema',
@ -69,10 +68,6 @@ const messages: LocaleMessages = {
hosting: 'Organizo',
pastEvents: 'Eventos pasados',
past: 'Pasado',
filters: 'Filtros',
clearAll: 'Limpiar todo',
filteringBy: 'Filtrando por:',
removeCategory: 'Quitar el filtro {category}',
},
categories: {
concert: 'Concierto',
@ -103,13 +98,15 @@ const messages: LocaleMessages = {
},
detail: {
getTicket: 'Obtener boleto',
going: 'Voy',
maybe: 'Tal vez',
notGoing: 'No voy',
contactOrganizer: 'Contactar organizador',
organizer: 'Organizador',
location: 'Ubicación',
when: 'Cuándo',
tickets: 'Boletos',
ticketsAvailable: '{count} boletos disponibles',
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
unlimitedTickets: 'Boletos ilimitados',
buyTicket: 'Comprar boleto',
@ -202,8 +199,7 @@ const messages: LocaleMessages = {
income: {
title: 'A\u00f1adir ingreso',
description: 'Enviar un ingreso para la organizaci\u00f3n',
selectAccount: 'Seleccionar la cuenta de ingresos.',
otherAccountHint: 'Use la cuenta "Other" si no está seguro.',
selectAccount: 'Seleccionar la cuenta de ingresos',
submitIncome: 'Enviar ingreso',
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.',
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
login: 'Se connecter',
menu: 'Menu',
backToHub: 'Retour au hub',
hub: 'Hub',
theme: 'Thème',
@ -69,10 +68,6 @@ const messages: LocaleMessages = {
hosting: 'J\'organise',
pastEvents: 'Événements passés',
past: 'Passé',
filters: 'Filtres',
clearAll: 'Tout effacer',
filteringBy: 'Filtré par :',
removeCategory: 'Retirer le filtre {category}',
},
categories: {
concert: 'Concert',
@ -103,13 +98,15 @@ const messages: LocaleMessages = {
},
detail: {
getTicket: 'Obtenir un billet',
going: 'Présent',
maybe: 'Peut-être',
notGoing: 'Absent',
contactOrganizer: "Contacter l'organisateur",
organizer: 'Organisateur',
location: 'Lieu',
when: 'Quand',
tickets: 'Billets',
ticketsAvailable: '{count} billets disponibles',
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
unlimitedTickets: 'Billets illimités',
buyTicket: 'Acheter un billet',
@ -130,7 +127,7 @@ const messages: LocaleMessages = {
registered: 'Enregistré',
},
nav: {
feed: 'Accueil',
feed: 'Fil',
calendar: 'Calendrier',
map: 'Carte',
favorites: 'Favoris',
@ -202,8 +199,7 @@ const messages: LocaleMessages = {
income: {
title: 'Ajouter un revenu',
description: 'Soumettre un revenu pour l\u2019organisation',
selectAccount: 'S\u00e9lectionner le compte de revenus.',
otherAccountHint: 'Utilisez le compte "Other" si vous n\'\u00eates pas s\u00fbr.',
selectAccount: 'S\u00e9lectionner le compte de revenus',
submitIncome: 'Soumettre le revenu',
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
profileLoggedOutDescription: string
login: string
menu: string
backToHub: string
hub: string
theme: string
@ -70,21 +69,19 @@ export interface LocaleMessages {
hosting: string
pastEvents: string
past: string
filters: string
clearAll: string
filteringBy: string
removeCategory: string
}
categories: Record<string, string>
detail: {
getTicket: string
going: string
maybe: string
notGoing: string
contactOrganizer: string
organizer: string
location: string
when: string
tickets: string
ticketsAvailable: string
ticketsRemainingOfTotal: string
ticketsOwned: string
unlimitedTickets: string
buyTicket: string
@ -179,7 +176,6 @@ export interface LocaleMessages {
title: string
description: string
selectAccount: string
otherAccountHint: string
submitIncome: 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>
<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">
<!-- Profile Picture -->
<FormField name="picture">
@ -8,38 +17,33 @@
<FormDescription>
Upload a profile picture. This will be published to your Nostr profile.
</FormDescription>
<!-- Stack preview + upload on narrow viewports so the upload
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">
<div class="flex items-center gap-4">
<!-- Current picture preview -->
<div v-if="currentPictureUrl" class="relative shrink-0">
<div v-if="currentPictureUrl" class="relative">
<img
:src="currentPictureUrl"
alt="Profile picture"
class="h-20 w-20 rounded-full object-cover border-2 border-border"
/>
</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" />
</div>
<!-- Upload component. Avatars are small; tighten the
default compress knobs so a 4K phone photo lands as
a ~200 KB 512px WebP. -->
<div class="flex-1 min-w-0 w-full">
<ImageUpload
v-model="uploadedPicture"
:multiple="false"
:max-files="1"
:max-size-mb="5"
:disabled="isUpdating"
:allow-camera="true"
:compress="{ maxWidthOrHeight: 512, maxSizeMB: 0.2 }"
placeholder="Upload picture"
accept="image/*"
/>
</div>
<ImageUpload
v-model="uploadedPicture"
:multiple="false"
:max-files="1"
:max-size-mb="5"
:disabled="isUpdating"
:allow-camera="true"
:compress="{ maxWidthOrHeight: 512, maxSizeMB: 0.2 }"
placeholder="Upload picture"
accept="image/*"
/>
</div>
<FormMessage />
</FormItem>
@ -87,6 +91,26 @@
</FormItem>
</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 -->
<div v-if="updateError" class="text-sm text-destructive">
{{ updateError }}
@ -111,6 +135,34 @@
Your profile is broadcast to Nostr automatically when you save changes.
</p>
</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>
</template>
@ -121,7 +173,8 @@ import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
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 {
FormControl,
FormDescription,
@ -131,13 +184,27 @@ import {
FormMessage,
} from '@/components/ui/form'
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 { useRouter } from 'vue-router'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '../services/ImageUploadService'
import { useToast } from '@/core/composables/useToast'
// Services
const { user, updateProfile } = useAuth()
const { user, updateProfile, logout } = useAuth()
const router = useRouter()
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const toast = useToast()
@ -157,14 +224,14 @@ const lightningDomain = computed(() =>
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
)
// Live preview of the user's NIP-05 / Lightning address shown in the
// username field's helper text so the consequence of a future rename is
// visible inline.
// Computed previews
const nip05Preview = computed(() => {
const username = form.values.username || currentUsername.value || 'username'
return `${username}@${lightningDomain.value}`
})
const lightningAddress = computed(() => nip05Preview.value)
// Form schema
const profileFormSchema = toTypedSchema(z.object({
username: z.string()
@ -260,4 +327,17 @@ const updateUserProfile = async (formData: any) => {
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>

View file

@ -1,12 +1,11 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Heart } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { useBookmarks } from '../composables/useBookmarks'
import { useEventLikes } from '../composables/useEventLikes'
import { NIP52_KINDS } from '../types/nip52'
const props = defineProps<{
@ -16,56 +15,13 @@ const props = defineProps<{
}>()
const router = useRouter()
const { isAuthenticated, user } = useAuth()
const { isAuthenticated } = useAuth()
const { isBookmarked, toggleBookmark } = useBookmarks()
const { track, likeCount, setSelf } = useEventLikes()
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))
// Live count of how many people have favorited (liked) this event.
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() {
function handleToggle() {
if (!isAuthenticated.value) {
toast.info('Log in to save favorites', {
action: {
@ -75,26 +31,18 @@ async function handleToggle() {
})
return
}
const ok = await toggleBookmark(eventKind.value, props.pubkey, props.dTag)
if (!ok) {
toast.error("Couldn't save favorite — please try again")
}
toggleBookmark(eventKind.value, props.pubkey, props.dTag)
}
</script>
<template>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1 px-2"
size="icon"
class="h-8 w-8"
: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"
>
<Heart
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>
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
</Button>
</template>

View file

@ -432,7 +432,7 @@ const handleOpenChange = (open: boolean) => {
<template>
<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">
<DialogTitle class="flex items-center gap-2">
<Calendar class="w-5 h-5" />
@ -611,13 +611,9 @@ const handleOpenChange = (open: boolean) => {
fiat amounts convert at checkout using current rates.
</p>
</div>
<!-- Pricing grid: 2 cols on narrow phones (Tickets/Price share
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">
<div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem class="min-w-0">
<FormItem>
<FormLabel>Tickets</FormLabel>
<FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
@ -628,7 +624,7 @@ const handleOpenChange = (open: boolean) => {
</FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem class="min-w-0">
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
@ -639,11 +635,11 @@ const handleOpenChange = (open: boolean) => {
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem class="col-span-2 sm:col-span-1 min-w-0">
<FormItem>
<FormLabel>Price currency</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger class="w-full">
<SelectTrigger>
<SelectValue placeholder="sat" />
</SelectTrigger>
<SelectContent>
@ -743,26 +739,12 @@ const handleOpenChange = (open: boolean) => {
</CollapsibleContent>
</Collapsible>
<!-- Actions. Stack full-width on narrow viewports so a wide
localized "Submit Event" label can't push the Cancel
button out of the dialog. Submit reads top-down on
mobile (flex-col-reverse) to keep the primary action
under the thumb. -->
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
class="w-full sm:w-auto"
@click="handleOpenChange(false)"
:disabled="isLoading"
>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
Cancel
</Button>
<Button
type="submit"
class="w-full sm:w-auto"
:disabled="isLoading || !isFormValid"
>
<Button type="submit" :disabled="isLoading || !isFormValid">
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{
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<{
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<{
@ -72,21 +68,13 @@ function getDotCount(date: Date): number {
return Math.min(getEventsForDay(date).length, 3)
}
// Default the selection to today so the calendar opens on today's events
// rather than an empty panel (currentMonth already starts on this month).
const selectedDay = ref<Date | null>(new Date())
const selectedDay = ref<Date | null>(null)
const selectedDayEvents = computed(() => {
if (!selectedDay.value) return []
return getEventsForDay(selectedDay.value)
})
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)) {
selectedDay.value = null
} else {
@ -107,7 +95,7 @@ function nextMonth() {
</script>
<template>
<div class="space-y-2">
<div class="space-y-4">
<!-- Month navigation -->
<div class="flex items-center justify-between">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
@ -135,7 +123,7 @@ function nextMonth() {
<button
v-for="date in calendarDays"
: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="{
'text-muted-foreground/40': !isSameMonth(date, currentMonth),
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
@ -157,9 +145,8 @@ function nextMonth() {
</button>
</div>
<!-- Selected day events (hidden in picker mode the popup just
picks a day and closes). -->
<div v-if="selectedDay && !pickerMode" class="border-t pt-4 space-y-2">
<!-- Selected day events -->
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
<h3 class="text-sm font-medium text-muted-foreground">
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
<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 { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
import BookmarkButton from './BookmarkButton.vue'
import OrganizerCard from './OrganizerCard.vue'
import { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import type { Event } from '../types/event'
const props = defineProps<{
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<{
@ -57,58 +52,42 @@ const priceDisplay = computed(() => {
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 a = props.event
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
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>
<template>
<Card
class="relative 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)"
>
<!-- Wash-out wrapper. The pending/rejected status badge below sits
OUTSIDE this wrapper so it stays in full color and reads
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">
<!-- Image / Placeholder -->
<div class="relative aspect-[16/9] overflow-hidden">
<img
v-if="event.image"
:src="event.image"
:alt="event.title"
class="w-full h-full object-cover"
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 -->
<Badge
@ -138,13 +117,27 @@ const isNonApproved = computed(
{{ priceDisplay }}
</Badge>
<!-- Past badge shown when the event has already ended. The
pending/rejected status badge that used to share this slot
is now an absolute overlay on Card root, above the wash-out,
so we still suppress Past when isNonApproved (the status
badge is more actionable in that case). -->
<!-- Pending/rejected overlay for the creator's own non-approved
drafts. Only present when the event originated from a
local LNbits event (Nostr-sourced events have no
lnbitsStatus). -->
<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"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
@ -153,71 +146,27 @@ const isNonApproved = computed(
</Badge>
</div>
<CardContent
:class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'"
>
<!-- 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. -->
<CardContent class="p-4 flex-1 flex flex-col gap-2">
<!-- Title + Bookmark -->
<div class="flex items-start gap-1">
<h3
:class="[
'font-semibold text-foreground leading-tight flex-1',
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
]"
>
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
{{ event.title }}
</h3>
<BookmarkButton
v-if="!compact"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
<!-- Summary (hidden in compact mode) -->
<!-- Summary -->
<p
v-if="event.summary && !compact"
v-if="event.summary"
class="text-sm text-muted-foreground line-clamp-2"
>
{{ event.summary }}
</p>
<div :class="compact ? 'space-y-1 text-xs' : '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
/>
<div class="mt-auto space-y-1.5 pt-2">
<!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" />
@ -245,7 +194,7 @@ const isNonApproved = computed(
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<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 v-else class="text-destructive font-medium">
{{ t('events.detail.soldOut') }}
@ -267,22 +216,5 @@ const isNonApproved = computed(
</div>
</div>
</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>
</template>

View file

@ -7,10 +7,6 @@ import type { Event } from '../types/event'
defineProps<{
events: Event[]
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<{
@ -43,24 +39,20 @@ const { t } = useI18n()
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" />
<h3 class="text-lg font-medium text-foreground">
<h3 class="text-lg font-medium text-foreground mb-1">
{{ t('events.noEvents') }}
</h3>
<p class="text-sm text-muted-foreground">
{{ t('events.search.noResults') }}
</p>
</div>
<!-- Event grid compact mode collapses to a single column of
tight rows; default mode is the responsive card grid. The
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'"
>
<!-- Event grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<EventCard
v-for="event in events"
:key="event.nostrEventId"
:event="event"
:compact="compact"
@click="emit('select', event)"
/>
</div>

View file

@ -7,13 +7,8 @@ import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
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'
/** Event enriched with its resolved organizer display name for search. */
type SearchableEvent = Event & { organizerName: string }
const props = defineProps<{
events: Event[]
}>()
@ -27,13 +22,12 @@ const { dateLocale } = useDateLocale()
const isOpen = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
const searchOptions: FuzzySearchOptions<SearchableEvent> = {
const searchOptions: FuzzySearchOptions<Event> = {
fuseOptions: {
keys: [
{ name: 'title', weight: 0.5 },
{ name: 'summary', weight: 0.2 },
{ name: 'description', weight: 0.15 },
{ name: 'organizerName', weight: 0.1 },
{ name: 'location', weight: 0.1 },
{ name: 'tags', weight: 0.05 },
],
@ -45,20 +39,7 @@ const searchOptions: FuzzySearchOptions<SearchableEvent> = {
resultLimit: 8,
}
// Organizer display names aren't stored on the event (they're fetched
// 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 eventsRef = computed(() => props.events)
const {
searchQuery,
@ -66,7 +47,7 @@ const {
isSearching,
clearSearch,
setSearchQuery,
} = useFuzzySearch(searchCorpus, searchOptions)
} = useFuzzySearch(eventsRef, searchOptions)
const showResults = 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) => {
if (open) {
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 {
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 { useOrganizerProfile } from '../composables/useOrganizerProfile'
const props = withDefaults(
defineProps<{
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 props = defineProps<{
pubkey: string
}>()
const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
</script>
<template>
<!-- Compact: tiny avatar + "By <name>" on a single line -->
<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">
<div class="flex items-center gap-3">
<Avatar class="h-10 w-10">
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
<AvatarFallback class="bg-primary/10">
@ -42,14 +20,14 @@ const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
</Avatar>
<div class="min-w-0">
<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>
</p>
<p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate">
{{ profile.nip05 }}
</p>
<p v-else class="text-xs text-muted-foreground font-mono truncate">
{{ pubkey.slice(0, 16) }}
{{ pubkey.slice(0, 16) }}...
</p>
</div>
</div>

View file

@ -70,11 +70,6 @@ function increaseQuantity() {
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)
async function copyInvoice() {
if (!paymentRequest.value) return
try {
@ -264,10 +259,7 @@ onUnmounted(() => {
<DialogDescription>
<span v-if="quantity > 1">
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else-if="isFree">
Get a free ticket for <strong>{{ event.name }}</strong>
{{ formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
@ -383,9 +375,9 @@ onUnmounted(() => {
</div>
<div class="flex justify-between">
<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 class="text-sm font-medium">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span>
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
</div>
<PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
@ -404,7 +396,7 @@ onUnmounted(() => {
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 && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Payment method</div>
<p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails.
@ -445,17 +437,13 @@ onUnmounted(() => {
class="w-full"
>
<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'">
<CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout
</template>
<template v-else>
<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>
</Button>
</div>

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">
import { useI18n } from 'vue-i18n'
import { History } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import type { TemporalFilter } from '../types/filters'
const props = defineProps<{
modelValue: TemporalFilter
showPast: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: TemporalFilter]
'toggle-past': []
}>()
const { t } = useI18n()
@ -26,33 +23,16 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
</script>
<template>
<!-- pb-1 pr-1 keep the theme's offset drop-shadow (neobrut casts a hard 4px
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;">
<div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
<Button
v-for="option in options"
:key="option.value"
:variant="props.modelValue === option.value ? 'default' : 'outline'"
size="sm"
class="text-xs shrink-0"
class="rounded-full text-xs shrink-0"
@click="emit('update:modelValue', option.value)"
>
{{ t(option.labelKey) }}
</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>
</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 { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing'
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
/**
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
@ -22,16 +21,12 @@ interface BookmarkState {
bookmarkedCoords: Set<string>
/** The latest bookmark event we've seen */
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
const state = ref<BookmarkState>({
bookmarkedCoords: new Set(),
lastEventId: null,
lastCreatedAt: null,
})
const isLoaded = ref(false)
@ -70,7 +65,7 @@ export function useBookmarks() {
}],
onEvent: (event: NostrEvent) => {
// 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>()
for (const tag of event.tags) {
@ -81,8 +76,8 @@ export function useBookmarks() {
state.value = {
bookmarkedCoords: coords,
lastEventId: event.id,
lastCreatedAt: event.created_at,
}
;(state.value as any).lastCreatedAt = event.created_at
isLoaded.value = true
},
onEose: () => {
@ -93,20 +88,9 @@ export function useBookmarks() {
/**
* 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(
eventKind: number,
pubkey: string,
dTag: string,
): Promise<boolean> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return false
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
const coord = `${eventKind}:${pubkey}:${dTag}`
const newCoords = new Set(state.value.bookmarkedCoords)
@ -117,29 +101,12 @@ export function useBookmarks() {
newCoords.add(coord)
}
// Optimistic flip — preserve the prior state so we can roll back if
// 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).
// Build and publish updated bookmark list
const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
const template: EventTemplate = {
kind: BOOKMARK_KIND,
created_at: monotonicCreatedAt(prevState.lastCreatedAt),
created_at: Math.floor(Date.now() / 1000),
content: '',
tags,
}
@ -149,28 +116,19 @@ export function useBookmarks() {
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useBookmarks] signEventViaLnbits failed:', err)
rollback()
return false
return
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) {
rollback()
return false
}
if (!relayHub) return
const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) {
state.value = {
bookmarkedCoords: newCoords,
lastEventId: signedEvent.id,
lastCreatedAt: template.created_at,
}
return true
}
rollback()
return false
}
onMounted(() => {

View file

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

View file

@ -1,32 +1,42 @@
import { ref, computed } from 'vue'
import {
startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays,
startOfMonth, endOfMonth, addDays, isSameDay,
} from 'date-fns'
import type { Event } from '../types/event'
import type { EventCategory } from '../types/category'
import type { TemporalFilter, EventFilters } 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.
*/
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>(() => ({
temporal: temporal.value,
@ -39,10 +49,10 @@ export function useEventFilters() {
function applyFilters(events: Event[]): Event[] {
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) {
// 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 dayEnd = endOfDay(selectedDate.value)
result = result.filter(a => {
@ -50,9 +60,8 @@ export function useEventFilters() {
return a.startDate <= dayEnd && eventEnd >= dayStart
})
} else {
// Temporal filter (preset pills).
// Temporal filter
result = applyTemporalFilter(result, temporal.value)
// Past/upcoming split — the chip narrows to one side of "now",
// mirroring the "My tickets" / "Hosting" mental model. Default
// (showPast=false) is upcoming-only; toggling on flips to
@ -84,16 +93,16 @@ export function useEventFilters() {
function setTemporal(value: TemporalFilter) {
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) {
selectedDate.value = date
temporal.value = 'all' // a specific day overrides the temporal pill
}
function clearSelectedDate() {
selectedDate.value = undefined
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
selectedDate.value = undefined // toggle off
} else {
selectedDate.value = date
temporal.value = 'all' // clear temporal pill when picking a specific date
}
}
function toggleCategory(category: EventCategory) {
@ -154,7 +163,6 @@ export function useEventFilters() {
applyFilters,
setTemporal,
selectDate,
clearSelectedDate,
toggleCategory,
clearCategories,
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 type { ProfileService, UserProfile } from '@/modules/base/nostr/ProfileService'
import type { Event as NostrEvent } from 'nostr-tools'
export interface OrganizerProfile {
pubkey: string
@ -14,92 +14,134 @@ export interface OrganizerProfile {
website?: string
}
function fromUserProfile(p: UserProfile): OrganizerProfile {
return {
pubkey: p.pubkey,
name: p.name,
displayName: p.display_name,
about: p.about,
picture: p.picture,
nip05: p.nip05,
}
}
// Global cache of fetched profiles
const profileCache = ref<Map<string, OrganizerProfile>>(new Map())
/**
* Composable for fetching and displaying organizer profiles (NIP-01 kind 0).
*
* 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.
* Uses its own relay subscription to avoid depending on the nostr-feed module.
*/
export function useOrganizerProfile(pubkey: string) {
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
const isLoading = ref(false)
const profile = ref<OrganizerProfile | null>(profileCache.value.get(pubkey) ?? null)
const isLoading = ref(!profile.value)
let unsubscribe: (() => void) | null = null
// Reactive read from the shared ProfileService cache. Updates the
// moment a kind-0 lands for this pubkey, regardless of which module
// triggered the fetch.
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 {
function load() {
if (profileCache.value.has(pubkey)) {
profile.value = profileCache.value.get(pubkey)!
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 {
profile,
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).
*
* 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() {
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
function fetchProfiles(pubkeys: string[]) {
if (!profileService || pubkeys.length === 0) return
void profileService.fetchProfiles(pubkeys)
const uncached = pubkeys.filter(pk => !profileCache.value.has(pk))
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 {
const p = profileService?.profiles.get(pubkey)
return p ? fromUserProfile(p) : undefined
return profileCache.value.get(pubkey)
}
function getDisplayName(pubkey: string): string {
const p = profileService?.profiles.get(pubkey)
return p?.display_name ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
const p = profileCache.value.get(pubkey)
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
}
return {
profiles: profileCache,
fetchProfiles,
getProfile,
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,19 +5,12 @@ import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from './useOwnedTickets'
export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth()
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
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
const purchaseOperation = useAsyncOperation()
@ -94,38 +87,6 @@ export function useTicketPurchase() {
* have to take it as an argument from the UI. */
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(
eventId: string,
options: { quantity?: number } = {},
@ -157,23 +118,13 @@ export function useTicketPurchase() {
{ quantity: options.quantity },
)
// Free tickets (price 0 / 100%-off promo): the backend already
// issued + marked them paid, so there's no invoice to settle. Skip
// the QR / payment-poll and jump straight to the ticket-QR success
// state with the ids returned inline.
if (invoice.paid) {
paymentHash.value = invoice.paymentHash
await finalizePurchasedTickets(invoice.ticketIds ?? [])
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.
// Backend now 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) {
throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' +
@ -229,13 +180,32 @@ export function useTicketPurchase() {
// Multi-ticket purchases come back with `ticketIds` (N rows
// 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
? result.ticketIds
: 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) {
console.error('Error checking payment status:', err)

View file

@ -188,38 +188,6 @@ export function useTicketScanner(eventId: Ref<string>) {
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() {
scanned.value = []
lastScan.value = null
@ -242,6 +210,5 @@ export function useTicketScanner(eventId: Ref<string>) {
onDecode,
resume,
clearScanned,
registerManually,
}
}

View file

@ -33,6 +33,15 @@ export const eventsModule = createModulePlugin({
requiresAuth: false,
},
},
{
path: '/events/calendar',
name: 'events-calendar',
component: () => import('./views/EventsCalendarPage.vue'),
meta: {
title: 'Calendar',
requiresAuth: false,
},
},
{
path: '/events/map',
name: 'events-map',

View file

@ -105,10 +105,6 @@ export class TicketApiService {
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined,
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,
}
}

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 { 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.
* 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', () => {
// State — keyed by addressable coordinate, NOT bare d-tag, so two
// authors using the same d-tag are stored independently.
// State
const eventsMap = ref<Map<string, Event>>(new Map())
const isLoading = ref(false)
const lastUpdated = ref<Date | null>(null)
@ -81,58 +43,14 @@ export const useEventsStore = defineStore('events', () => {
/**
* Add or update an event in the store.
*
* 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.
*
* Draftpublished 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.
* Deduplicates by id (d-tag). Newer events replace older ones.
*/
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
// 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.
// Only update if this is a newer version
if (!existing || event.createdAt >= existing.createdAt) {
eventsMap.value.set(key, event)
eventsMap.value.set(event.id, event)
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
* d-tag matches (normally one our calendar events are single-publisher).
* Remove an event from the store.
*/
function removeEvent(id: string) {
for (const [key, event] of eventsMap.value) {
if (event.id === id) eventsMap.value.delete(key)
}
eventsMap.value.delete(id)
}
/**
@ -165,28 +80,10 @@ export const useEventsStore = defineStore('events', () => {
}
/**
* Get a single event by its full addressable coordinate (kind:pubkey: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.
* Get a single event by its id (d-tag).
*/
function getEventById(id: string): Event | undefined {
let match: Event | undefined
for (const event of eventsMap.value.values()) {
if (event.id !== id) continue
if (!match || event.createdAt >= match.createdAt) match = event
}
return match
return eventsMap.value.get(id)
}
return {
@ -207,7 +104,6 @@ export const useEventsStore = defineStore('events', () => {
upsertEvents,
removeEvent,
clearAll,
getByCoordinate,
getEventById,
}
})

View file

@ -78,8 +78,6 @@ export interface EventTicketInfo {
available?: number
/** Running paid count. */
sold: number
/** Total capacity (available + sold). Undefined means unlimited. */
total?: number
/** Whether the organizer enabled fiat checkout. */
allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */
@ -93,9 +91,6 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
currency: ticket.currency,
available: ticket.available,
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,
fiatCurrency: ticket.fiatCurrency,
}

View file

@ -81,12 +81,10 @@ export interface TicketPurchaseRequest {
}
/**
* Server response from `POST /tickets/{event_id}`. One of three shapes:
* - Lightning: `paymentRequest` = bolt11 (`isFiat` false, `paid` false)
* - Fiat: `fiatPaymentRequest` = a URL the buyer follows with `fiatProvider`
* - Free: `paid` true with no `paymentRequest` tickets already issued +
* paid server-side (price 0 / 100%-off promo); `ticketIds` carries the
* scannable rows so the client skips the payment step entirely.
* Server response from `POST /tickets/{event_id}`. Either Lightning
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
* the buyer follows to complete payment with `fiatProvider`).
* `isFiat` is the discriminator.
*/
export interface TicketPurchaseInvoice {
paymentHash: string
@ -94,10 +92,6 @@ export interface TicketPurchaseInvoice {
fiatPaymentRequest?: string
fiatProvider?: string
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 {

View file

@ -12,8 +12,10 @@ import {
} from 'lucide-vue-next'
import { useEventDetail } from '../composables/useEventDetail'
import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService'
import { useEventsStore } from '../stores/events'
import { useOwnedTickets } from '../composables/useOwnedTickets'
@ -168,14 +170,41 @@ function goToMyTickets() {
<template>
<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
prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. -->
<div class="flex items-center mb-4">
<!-- Top bar -->
<div class="flex items-center justify-between mb-4">
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" />
Back
</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>
<!-- Loading -->
@ -204,115 +233,104 @@ function goToMyTickets() {
/>
</div>
<!-- Title + bookmark + captions -->
<!-- Title + Category -->
<div>
<div class="flex flex-wrap items-start gap-2 mb-2">
<!-- "Yours" leads the row in the highlighted variant so the
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">
<div class="flex items-start gap-2 mb-2">
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
{{ categoryLabel }}
</Badge>
<Badge
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="shrink-0 capitalize"
class="shrink-0 mt-1 capitalize"
>
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</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">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div>
</div>
<div class="flex items-start justify-between gap-3">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ event.title }}
</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>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ event.title }}
</h1>
<p v-if="event.summary" class="text-muted-foreground mt-2">
{{ event.summary }}
</p>
<!-- When + Where captions -->
<div class="mt-3 space-y-1 text-sm text-muted-foreground">
<div class="flex items-start gap-1.5">
<Calendar class="w-4 h-4 shrink-0 mt-0.5" />
<span>
{{ dateDisplay }}
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span>
</span>
</div>
<div v-if="event.location" class="flex items-start gap-1.5">
<MapPin class="w-4 h-4 shrink-0 mt-0.5" />
<span>{{ event.location }}</span>
</div>
</div>
</div>
<Separator />
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ event.description }}</p>
<!-- Info section -->
<div class="grid gap-4 sm:grid-cols-2">
<!-- When -->
<div class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<Calendar class="w-4 h-4" />
{{ t('events.detail.when') }}
</div>
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
<p v-if="event.timezone" class="text-xs text-muted-foreground/70">
{{ event.timezone }}
</p>
</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>
<!-- Host's primary CTA is to scan tickets at the door. Lives
OUTSIDE the ticketInfo gate so it appears even when the
event was published without AIO ticket tags a host always
gets to scan attempts. Stays available for past events too
so the host can still verify attendance after the fact. -->
<Button
v-if="ownedLnbitsEvent"
class="w-full gap-1.5"
size="lg"
@click="openScannerPage"
>
<ScanLine class="w-4 h-4" />
{{ t('events.detail.scanTickets', 'Scan tickets') }}
</Button>
<!-- RSVP -->
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
(31922 for date-based, 31923 for time-based). Without this prop the
button would default to time-based for every event, leaving RSVPs
on date-based events pointing at a non-existent event coord. -->
<RSVPButton
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/>
<!-- Tickets gated on the event carrying ticketInfo (set
by the calendarEvent converter from the AIO custom
tickets_* tags on the published event). Skipped for the
host entirely they have the Scan CTA above and don't
need a Buy CTA for their own event. -->
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
tickets_* tags on the published event). Sections render
bottom-up: availability count, then existing owned
tickets (when count > 0) above a Purchase CTA (when
capacity remains). -->
<div v-if="event.ticketInfo" class="space-y-3">
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Ticket class="w-4 h-4 shrink-0" />
<span v-if="event.ticketInfo.available === undefined">
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</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
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">
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</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" />
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
</Button>
@ -325,11 +343,10 @@ function goToMyTickets() {
<History class="w-4 h-4 shrink-0" />
{{ t('events.detail.pastEvent', 'This event has already happened') }}
</div>
<div v-else-if="canBuyTicket" class="space-y-1">
<div v-else-if="canBuyTicket">
<Button
class="w-full gap-1.5"
size="lg"
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
@ -342,14 +359,6 @@ function goToMyTickets() {
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
</span>
</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>
<p
v-else-if="ownedPaidCount === 0"
@ -366,13 +375,6 @@ function goToMyTickets() {
@update:is-open="showPurchaseDialog = $event"
/>
<!-- External references -->
<div v-if="event.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->
</div>
<Separator />
<!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
@ -380,6 +382,18 @@ function goToMyTickets() {
</p>
<OrganizerCard :pubkey="event.organizer.pubkey" />
</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>
</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 -->
<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
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" />
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
<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>
</div>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import {
@ -8,85 +8,44 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { SlidersHorizontal, CalendarDays, Plus, X } 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 { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
import { useEvents } from '../composables/useEvents'
import { useEventsStore } from '../stores/events'
import { useAuth } from '@/composables/useAuthService'
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.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 { useDateLocale } from '../composables/useDateLocale'
import type { Event } from '../types/event'
const router = useRouter()
const { t } = useI18n()
const { dateLocale } = useDateLocale()
const eventsStore = useEventsStore()
const {
events,
allEvents,
isLoading,
error,
temporal,
selectedCategories,
selectedDate,
hasActiveFilters,
showPast,
selectedDate,
onlyOwnedTickets,
onlyHosting,
setTemporal,
showPast,
selectDate,
clearSelectedDate,
setTemporal,
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters,
subscribe,
} = useEvents()
const { isAuthenticated } = useAuth()
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(() => {
subscribe()
@ -95,123 +54,85 @@ onMounted(() => {
function handleSelectEvent(event: Event) {
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>
<template>
<!-- pt-2.5 (not py-4) so the header logo/banner sits centered on the
same horizontal axis as the fixed top-right profile icon. -->
<div class="container mx-auto pt-2.5 pb-4 px-4">
<!-- Page header. A brand may ship a wide banner (logo + wordmark in
one image) that replaces the logo + name pair; otherwise we show
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>
<div class="container mx-auto py-6 px-4">
<!-- Page header -->
<div class="mb-4">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ t('events.title') }}
</h1>
</div>
<!-- Search with dropdown overlay -->
<div class="mb-3">
<div class="mb-4">
<EventSearchOverlay
:events="events"
@select="handleSelectEvent"
/>
</div>
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
column; only the temporal pills scroll horizontally. The
Filters icon (with a count badge when categories are active)
opens a collapsible that hosts category chips below. Past is
a pill at the end of the temporal strip and doesn't live in
the dropdown anymore. Hidden in the Hosting view the
operator's roster doesn't need them. -->
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
<div class="flex items-start gap-3">
<div class="shrink-0 flex flex-col items-center gap-0.5">
<CollapsibleTrigger as-child>
<Button
variant="ghost"
size="icon"
class="rounded-full h-8 w-8 relative"
:class="{ 'bg-accent text-accent-foreground': filtersOpen || filterCount > 0 }"
:aria-label="t('events.filters.filters', 'Filters')"
:aria-expanded="filtersOpen"
>
<SlidersHorizontal class="w-4 h-4" />
<span
v-if="filterCount > 0"
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"
>
{{ filterCount }}
</span>
</Button>
</CollapsibleTrigger>
<Button
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. -->
<!-- Date picker strip (p'a semana style) -->
<div class="mb-4">
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
</div>
<!-- Temporal filter pills -->
<div class="mb-4">
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div>
<!-- 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
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"
:variant="onlyOwnedTickets ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleOwnedTickets"
>
<CalendarDays class="h-4 w-4" />
<Ticket class="w-3.5 h-3.5" />
{{ t('events.filters.myTickets', 'My tickets') }}
</Button>
</div>
<CollapsibleContent class="mt-3">
<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" />
Categories
<span v-if="selectedCategories.length > 0" class="text-xs bg-primary text-primary-foreground rounded-full px-1.5">
{{ selectedCategories.length }}
</span>
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': filtersOpen }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="mt-2">
<CategoryFilterBar
:selected="selectedCategories"
@toggle="toggleCategory"
@ -220,61 +141,24 @@ onBeforeRouteLeave(() => {
</CollapsibleContent>
</Collapsible>
<!-- Active day-filter chip removing it clears ONLY the date
selection (categories have their own clear in the filter
dropdown). Shown when a day is picked from the calendar popup. -->
<div v-if="selectedDate" class="mb-3 flex">
<Button
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" />
<!-- Active filters indicator -->
<div v-if="hasActiveFilters" class="flex items-center gap-2 mb-4">
<span class="text-xs text-muted-foreground">Filters active</span>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="resetFilters">
Clear all
</Button>
</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 -->
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
{{ error }}
</div>
<!-- Event feed. The Hosting view renders compact rows so the
operator can scan their roster without the visual weight of
hero images they already recognize. -->
<!-- Event feed -->
<EventList
:events="events"
:is-loading="isLoading"
:compact="onlyHosting"
@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>
</template>

View file

@ -1,118 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, onBeforeRouteLeave } from 'vue-router'
import { onMounted, ref, watch } from 'vue'
import { useUserTickets } from '../composables/useUserTickets'
import { useEvents } from '../composables/useEvents'
import { useEventsStore } from '../stores/events'
import { useAuth } from '@/composables/useAuthService'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { format, startOfDay, endOfDay } from 'date-fns'
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-vue-next'
import EventCalendarPopup from '../components/EventCalendarPopup.vue'
import { useDateLocale } from '../composables/useDateLocale'
import type { Event } from '../types/event'
import { format } from 'date-fns'
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
const { isAuthenticated, userDisplay } = useAuth()
const { dateLocale } = useDateLocale()
const {
tickets,
paidTickets,
pendingTickets,
registeredTickets,
groupedTickets,
isLoading,
error,
refresh
} = 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 currentTicketIndex = ref<Record<string, number>>({})
@ -201,10 +110,6 @@ watch(groupedTickets, async (newGroups) => {
}, { immediate: true })
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) {
await refresh()
}
@ -251,83 +156,23 @@ onMounted(async () => {
</div>
<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">
<TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="all">All ({{ visibleCounts.all }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ visibleCounts.paid }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ visibleCounts.pending }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ visibleCounts.registered }})</TabsTrigger>
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger>
</TabsList>
<!-- All Tickets Tab -->
<TabsContent value="all">
<ScrollArea class="h-[600px] w-full pr-4">
<div v-if="visibleGroups.length === 0" class="text-center py-8 text-muted-foreground">
{{ selectedDay ? 'No tickets on this day' : (showPast ? 'No past tickets' : 'No upcoming tickets') }}
</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">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between gap-2">
<CardTitle class="text-foreground min-w-0 flex-1">
<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>
<Badge variant="outline" class="shrink-0">
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<Badge variant="outline">
{{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }}
</Badge>
</div>
@ -413,19 +258,11 @@ onMounted(async () => {
<!-- Paid, Pending, Registered tabs follow the same pattern but filter -->
<TabsContent value="paid">
<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">
<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>
<CardTitle class="text-foreground min-w-0">
<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>
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<CardDescription>{{ group.paidCount }} paid ticket{{ group.paidCount !== 1 ? 's' : '' }}</CardDescription>
</CardHeader>
<CardContent>
@ -438,19 +275,11 @@ onMounted(async () => {
<TabsContent value="pending">
<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">
<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>
<CardTitle class="text-foreground min-w-0">
<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>
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<CardDescription>{{ group.pendingCount }} pending ticket{{ group.pendingCount !== 1 ? 's' : '' }}</CardDescription>
</CardHeader>
<CardContent>
@ -463,19 +292,11 @@ onMounted(async () => {
<TabsContent value="registered">
<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">
<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>
<CardTitle class="text-foreground min-w-0">
<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>
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
<CardDescription>{{ group.registeredCount }} registered ticket{{ group.registeredCount !== 1 ? 's' : '' }}</CardDescription>
</CardHeader>
<CardContent>
@ -487,15 +308,5 @@ onMounted(async () => {
</TabsContent>
</Tabs>
</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>
</template>

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import {
ArrowLeft,
CheckCircle2,
@ -10,20 +9,15 @@ import {
Ticket,
ScanLine,
RefreshCw,
UserCheck,
Search,
} from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner'
import type { EventTicket } from '../composables/useTicketScanner'
import { useEventDetail } from '../composables/useEventDetail'
import { useFuzzySearch } from '@/composables/useFuzzySearch'
const route = useRoute()
const router = useRouter()
@ -41,14 +35,8 @@ const {
refreshStats,
onDecode,
resume,
registerManually,
} = 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 activeTab = ref<'scanner' | 'list'>('scanner')
@ -76,55 +64,11 @@ const remainingCount = computed(() => {
return Math.max(0, soldCount.value - registeredCount.value)
})
// Full ticket roster, sorted so unregistered (actionable) rows lead
// and registered rows follow most-recent-first. Powers the Tickets
// tab where the host can manually register attendees who can prove
// 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,
// Registered tickets only what the "Scanned" tab shows.
const registeredTickets = computed(
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
)
// 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) {
// Don't pause the scanner useQRScanner's `maxScansPerSecond: 5`
// already throttles, and useTicketScanner.onDecode dedups the same
@ -190,7 +134,7 @@ function fmtTime(iso: string) {
<p class="text-2xl font-bold text-foreground">
{{ remainingCount ?? '—' }}
</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>
@ -212,21 +156,13 @@ function fmtTime(iso: string) {
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4">
<!-- Icon + label wrapped in a real flex container so they
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" />
Scanner
</span>
<TabsTrigger value="scanner" class="gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
</TabsTrigger>
<TabsTrigger value="list">
<span class="inline-flex items-center justify-center gap-1.5">
<Ticket class="w-4 h-4" />
Tickets ({{ totalTicketsCount }})
</span>
<TabsTrigger value="list" class="gap-1.5">
<Ticket class="w-4 h-4" />
Scanned ({{ registeredCount }})
</TabsTrigger>
</TabsList>
@ -254,83 +190,39 @@ function fmtTime(iso: string) {
<TabsContent value="list" class="mt-0">
<div class="space-y-3">
<h2 class="text-sm font-medium text-foreground">
{{ registeredCount }} / {{ totalTicketsCount }} registered
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
· {{ unregisteredCount }} to go
</span>
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
</h2>
<!-- Fuzzy filter on holder name + ticket id (Fuse.js via
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]">
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
<ul class="space-y-1.5 pr-3">
<li
v-for="ticket in searchedTickets"
:key="ticket.id"
v-for="record in registeredTickets"
:key="record.id"
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="flex items-center gap-2">
<Badge
v-if="ticket.registered && ticket.registeredAt"
v-if="record.registeredAt"
variant="secondary"
class="text-[10px] font-mono px-1.5"
>
{{ fmtTime(ticket.registeredAt) }}
{{ fmtTime(record.registeredAt) }}
</Badge>
<span v-if="ticket.name" class="font-medium text-foreground">
{{ ticket.name }}
<span v-if="record.name" class="font-medium text-foreground">
{{ record.name }}
</span>
</div>
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
{{ ticket.id }}
{{ record.id }}
</p>
</div>
<CheckCircle2
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>
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
</li>
</ul>
</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">
No tickets match {{ searchQuery }}.
No tickets scanned yet.
</p>
</div>
</TabsContent>

View file

@ -84,12 +84,8 @@
<!-- Step 1: Account Selection -->
<div v-if="currentStep === 1">
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 mb-4 text-sm">
<span class="text-muted-foreground">Select the account for this expense.</span>
<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 class="text-sm text-muted-foreground mb-4">
Select the account for this expense
</p>
<AccountSelector
v-model="selectedAccount"
@ -107,69 +103,78 @@
<FormLabel>Description *</FormLabel>
<FormControl>
<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"
rows="3"
/>
</FormControl>
<FormDescription>
Describe what this expense was for
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Amount + Currency -->
<div class="grid grid-cols-2 gap-4">
<!-- Amount -->
<FormField v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0.00"
v-bind="componentField"
min="0.01"
step="0.01"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Amount -->
<FormField v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0.00"
v-bind="componentField"
min="0.01"
step="0.01"
/>
</FormControl>
<FormDescription>
Amount in selected currency
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Currency -->
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger class="w-full">
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="currency in availableCurrencies"
:key="currency"
:value="currency"
>
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Currency -->
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="currency in availableCurrencies"
:key="currency"
:value="currency"
>
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Currency for this expense
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Reference (optional) -->
<FormField v-slot="{ componentField }" name="reference">
<FormItem>
<FormLabel>Reference (optional)</FormLabel>
<FormLabel>Reference</FormLabel>
<FormControl>
<Input
placeholder="e.g., Invoice #123, Receipt #456"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Optional reference number or note
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@ -274,7 +279,7 @@ import {
DialogHeader,
DialogTitle,
} 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 { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'

View file

@ -240,28 +240,21 @@ export default class WalletService extends BaseService {
let endpoint = ''
let body: any = {}
// Determine payment type and prepare request.
// QR-scanned invoices are uppercase bech32, so detection must be
// case-insensitive. Match BOLT11 by its HRP (lnbc / lntb / lntbs /
// 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
// Determine payment type and prepare request
if (request.destination.startsWith('ln')) {
// Lightning invoice
endpoint = `${config.api.baseUrl}/api/v1/payments`
body = {
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
endpoint = `${config.api.baseUrl}/api/v1/payments/lnurl`
body = {
lnurl: dest.includes('@')
? `https://${dest.split('@')[1]}/.well-known/lnurlp/${dest.split('@')[0]}`
: dest,
lnurl: request.destination.includes('@')
? `https://${request.destination.split('@')[1]}/.well-known/lnurlp/${request.destination.split('@')[0]}`
: request.destination,
amount: request.amount * 1000, // Convert to millisats
comment: request.comment || ''
}

View file

@ -231,21 +231,41 @@ export class WalletWebSocketService extends BaseService {
this.handlePaymentNotification(data.payment)
}
// 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).
// Handle wallet balance update
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,
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) {

View file

@ -13,29 +13,16 @@ import PreferencesRow from '@/components/layout/PreferencesRow.vue'
const router = useRouter()
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 {
label: string
chakra: string
icon: any
bgClass: string
glow: string
/** Env var holding this app's deployed URL. Absent/empty ⇒ not provisioned. */
envKey?: string
/** Maturity badge ('alpha' | 'beta' | …), shown under the label. */
status?: string
/** When true, the tile is ghosted out unless the user is logged in. */
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?: 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: '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') || '')
/**
* Per-tile availability, resolved from deploy config (env URLs) + auth state.
* Explicit states keep the render path declarative and make each future step
* a small change rather than another overloaded null check.
*/
type Availability =
| '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 {
function hubLink(m: Module): string | null {
if (!m.envKey) return null
// Auth-only modules (wallet, chat, libra, tasks) are ghosted when not logged in.
if (m.authRequired && !isAuthenticated.value) return null
const url = import.meta.env[m.envKey] as string | undefined
if (!url) return null
if (isAuthenticated.value && token.value) {
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(token.value)}`
@ -109,35 +56,22 @@ function withToken(url: string): string {
return url
}
function tileTag(t: Tile): 'a' | 'button' | 'div' {
if (t.availability === 'available') return 'a'
if (t.availability === 'auth-locked') return 'button'
return 'div' // inactive
function isAuthGated(m: Module): boolean {
return !!(m.authRequired && !isAuthenticated.value)
}
function tileClass(t: Tile): string {
switch (t.availability) {
case 'available':
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') {
function onTileClick(m: Module, event: Event) {
// Ghosted auth-required tiles aren't anchors; intercept and toast.
if (isAuthGated(m)) {
event.preventDefault()
toast.info(`${t.module.label} requires login`, {
toast.info(`${m.label} requires login`, {
action: {
label: 'Log in',
onClick: () => router.push('/login'),
},
})
}
// 'inactive' tiles silently do nothing.
// "Coming soon" tiles (no envKey, no authRequired) silently do nothing.
}
</script>
@ -168,27 +102,33 @@ function onTileClick(t: Tile, event: Event) {
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
<component
v-for="t in tiles"
:key="t.module.label"
:is="tileTag(t)"
:href="t.href"
v-for="m in orderedModules"
:key="m.label"
:is="hubLink(m) ? 'a' : (isAuthGated(m) ? 'button' : 'div')"
: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="tileClass(t)"
@click="onTileClick(t, $event)"
:class="[
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">
<p class="text-sm font-semibold text-foreground drop-shadow">{{ t.module.label }}</p>
<p v-if="t.module.status" class="text-[9px] font-light text-muted-foreground">{{ t.module.status }}</p>
<p class="text-sm font-semibold text-foreground drop-shadow">{{ m.label }}</p>
<p v-if="m.status" class="text-[9px] font-light text-muted-foreground">{{ m.status }}</p>
</div>
<!-- Notification badge wired to data once #32 lands. Hidden when unread is falsy/0. -->
<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"
: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>
</component>
</div>

View file

@ -4,7 +4,7 @@
<!-- Logo and Title -->
<div class="text-center space-y-6">
<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 class="space-y-2">
<h1 class="text-3xl font-bold tracking-tight">Virtual Realm</h1>

View file

@ -5,7 +5,7 @@
<!-- Welcome Section -->
<div class="text-center space-y-2 sm:space-y-4">
<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 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>

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

View file

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

View file

@ -1,208 +0,0 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import type { Plugin } from 'vite'
/**
* Absolute path to the active brand kit. Deployers point this at their
* own `branding/<name>/` directory (see branding/README.md).
*
* Defaults to the committed aiolabs default brand. Used by vite configs
* for the `@brand` import alias and by pwa-assets.config.ts.
*/
export const BRAND_DIR = resolve(process.env.BRAND_DIR ?? './branding/default')
/** Fields parsed from brand.json. All but `name` are optional. */
export interface Brand {
/** Brand label — drives PWA manifest name. */
name: string
/** PWA install/home-screen short label. Defaults to `name`. */
shortName?: string
/**
* Optional PWA chrome theme color (status bar / title bar when installed).
* When unset, each standalone's vite config keeps its hardcoded accent.
*/
themeColor?: string
/**
* Optional PWA splash background. When unset, each standalone's vite
* config keeps its hardcoded value.
*/
backgroundColor?: string
/**
* Optional default in-app theme mode (light / dark / system). Sets the
* INITIAL value the theme-provider uses when the user has no saved
* preference; a user's later choice still wins and persists. Unset
* the app's built-in default ('dark'). Distinct from `themeColor`,
* which is PWA chrome only.
*/
theme?: 'light' | 'dark' | 'system'
/**
* Optional default in-app color palette (e.g. 'darkmatter'). Same
* initial-default semantics as `theme`. Must be one of the palettes in
* src/components/theme-provider (PALETTES). Unset 'catppuccin'.
*/
palette?: string
}
export const brand: Brand = JSON.parse(
readFileSync(resolve(BRAND_DIR, 'brand.json'), 'utf-8'),
)
// Surface the brand's in-app theme defaults to the client as VITE_*
// env vars (read by the theme-provider). Set here at module load — every
// vite.<app>.config.ts imports this file — so the default applies
// app-wide (hub + all standalones) without per-config wiring. Always
// assigned (empty when unset) so a prior brand's value can't leak into a
// later build in the same process.
process.env.VITE_BRAND_THEME = brand.theme ?? ''
process.env.VITE_BRAND_PALETTE = brand.palette ?? ''
/**
* Spread into a vite config's `resolve.alias` map. Lets components
* import deployer-provided assets via `@brand/<file>` instead of
* hardcoding `@/assets/logo.png`.
*/
export const brandAlias = {
'@brand': BRAND_DIR,
} as const
/**
* Resolution order for the in-app logo of a given standalone. Mirrors
* what pwa-assets.config.ts does for PWA icons: per-standalone override
* first (SVG then PNG), then the brand's primary logo (SVG then PNG).
*
* Returned path is absolute so vite alias can map directly to it.
*/
export function resolveAppLogo(app?: string): string {
const candidates: string[] = []
if (app) {
candidates.push(
join(BRAND_DIR, 'icons', app, 'logo.svg'),
join(BRAND_DIR, 'icons', app, 'logo.png'),
)
}
candidates.push(
join(BRAND_DIR, 'logo.svg'),
join(BRAND_DIR, 'logo.png'),
)
const found = candidates.find((p) => existsSync(p))
if (!found) {
throw new Error(
`No brand logo found for app="${app ?? ''}". Tried:\n ${candidates.join('\n ')}\n` +
`See branding/README.md for the brand kit contract.`,
)
}
return found
}
/**
* Standalone-aware brand-logo alias entry. Append to a vite config's
* `resolve.alias` array alongside the rest of the alias map. The
* regex matches `@brand-app-logo` with or without a `?url` query so
* `import logoUrl from '@brand-app-logo?url'` resolves to the active
* standalone's logo file (per-app override or global), with no
* fallback chain in the component itself.
*
* Note: when used with the object form of resolve.alias, a bare
* `@brand` entry would shadow this combine the two as an array
* (see vite.events.config.ts).
*/
export function brandAppLogoAliasEntry(app?: string) {
const resolved = resolveAppLogo(app)
return {
find: /^@brand-app-logo(\?.*)?$/,
replacement: `${resolved}$1`,
} as const
}
/**
* Hub-logo alias entry. Resolves `@brand-hub-logo` to the brand's
* primary/global logo (the hub's logo), independent of which standalone
* is building. Unlike {@link brandAppLogoAliasEntry}, this never takes an
* `app` argument the "Back to hub" link in every standalone must point
* at the HUB's logo, not the current standalone's own logo. Wire it into
* every vite.<app>.config.ts that builds ProfileSheetContent.vue.
*/
export function brandHubLogoAliasEntry() {
const resolved = resolveAppLogo()
return {
find: /^@brand-hub-logo(\?.*)?$/,
replacement: `${resolved}$1`,
} as const
}
/**
* Optional brand banner a wide lockup (logo + wordmark in one image)
* that replaces the logo + app-name pair in a standalone's header.
*
* Resolution mirrors {@link resolveAppLogo} (per-standalone override
* first, then the brand's primary banner), but a banner is OPTIONAL:
* returns `null` when none is found instead of throwing. Brands that
* don't ship a banner keep the default logo + name rendering.
*/
export function resolveAppBanner(app?: string): string | null {
const candidates: string[] = []
if (app) {
candidates.push(
join(BRAND_DIR, 'icons', app, 'banner.svg'),
join(BRAND_DIR, 'icons', app, 'banner.png'),
)
}
candidates.push(
join(BRAND_DIR, 'banner.svg'),
join(BRAND_DIR, 'banner.png'),
)
return candidates.find((p) => existsSync(p)) ?? null
}
/**
* Standalone-aware brand-banner alias entry, the banner sibling of
* {@link brandAppLogoAliasEntry}. Always registers the
* `@brand-app-banner` alias so the static `import '@brand-app-banner?url'`
* in the component resolves cleanly when the active brand has no
* banner it falls back to the resolved logo, which the component never
* renders (it gates on the `VITE_APP_BANNER` flag instead).
*/
export function brandAppBannerAliasEntry(app?: string) {
const resolved = resolveAppBanner(app) ?? resolveAppLogo(app)
return {
find: /^@brand-app-banner(\?.*)?$/,
replacement: `${resolved}$1`,
} as const
}
/**
* PWA manifest name for a standalone. Combines the brand name with the
* app's own label, or returns the bare brand when no label is given.
*
* Example: `brandManifestName('Wallet')` "AIO Wallet" / "Cfaun Wallet".
* Example: `brandManifestName()` "AIO" / "Sortir".
*/
export function brandManifestName(appLabel?: string): string {
return appLabel ? `${brand.name} ${appLabel}` : brand.name
}
/**
* Vite plugin: regenerate PWA icons under public/icons/ once per build
* / dev-server start, so vite.<app>.config.ts's includeAssets +
* manifest.icons always have something to include. Source resolution
* lives in pwa-assets.config.ts.
*/
export function brandAssetsPlugin(): Plugin {
let generated = false
return {
name: 'brand-assets-generator',
buildStart() {
if (generated) return
const { status } = spawnSync(
'node',
[resolve('scripts/generate-pwa-assets.mjs')],
{ stdio: 'inherit' },
)
if (status !== 0) {
throw new Error('pwa-assets-generator failed; see output above')
}
generated = true
},
}
}

View file

@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function chatHtmlPlugin(): Plugin {
return {
@ -46,7 +45,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
chatHtmlPlugin(),
vue(),
tailwindcss(),
@ -61,19 +59,20 @@ export default defineConfig(({ mode }) => ({
],
},
includeAssets: [
'icons/favicon.ico',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-maskable-192.png',
'icons/icon-maskable-512.png',
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Chat — Encrypted',
short_name: 'Chat',
description: 'End-to-end encrypted Nostr chat',
theme_color: brand.themeColor ?? '#16a34a',
background_color: brand.backgroundColor ?? '#ffffff',
theme_color: '#16a34a',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
@ -82,10 +81,10 @@ export default defineConfig(({ mode }) => ({
categories: ['social', 'communication'],
lang: 'en',
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
@ -103,16 +102,11 @@ export default defineConfig(({ mode }) => ({
}),
],
resolve: {
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
// with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [
brandAppLogoAliasEntry('chat'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
alias: {
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/chat-app/app.config.ts', import.meta.url)) },
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
'@/app.config': fileURLToPath(new URL('./src/chat-app/app.config.ts', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-chat',

View file

@ -5,7 +5,6 @@ import { defineConfig } from 'vite'
import Inspect from 'vite-plugin-inspect'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
// https://vite.dev/config/
//
@ -13,11 +12,6 @@ import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubL
// the entire origin and blocked Chrome from offering installs for the
// path-mounted standalones at /libra/, /market/, etc. The hub is a
// launcher page; users install the standalones they actually use.
// Brand name flows into index.html's `%VITE_APP_NAME% Hub` title via
// Vite's HTML env-var substitution.
process.env.VITE_APP_NAME = brand.name
export default defineConfig(({ mode }) => ({
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
cacheDir: 'node_modules/.vite-hub',
@ -26,7 +20,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
vue(),
tailwindcss(),
Inspect(),
@ -49,14 +42,9 @@ export default defineConfig(({ mode }) => ({
})
],
resolve: {
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
// with optional `?url` query) doesn't get shadowed by the bare alias.
alias: [
brandAppLogoAliasEntry(),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
]
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
rollupOptions: {

View file

@ -5,16 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import {
brand,
brandAlias,
brandAppBannerAliasEntry,
brandAppLogoAliasEntry,
brandAssetsPlugin,
brandHubLogoAliasEntry,
brandManifestName,
resolveAppBanner,
} from './vite-branding'
/**
* Plugin to rewrite dev server requests to events.html
@ -51,19 +41,14 @@ function eventsHtmlPlugin(): Plugin {
* VITE_BASE_PATH=/events/ app.ariege.io/events/ (shared auth)
* (default: /) bouge.ariege.io (standalone subdomain)
*
* Brand name resolves from brand.json under $BRAND_DIR (see
* vite-branding.ts and aiolabs/webapp#95). Surfaced into Vite's HTML
* env-var substitution as VITE_APP_NAME for templated titles.
* Set VITE_APP_NAME to brand the standalone (PWA name, HTML title, console
* logs). cfaun overrides to "Bouge" via NixOS. Defaults to "Events".
*/
const APP_NAME = brandManifestName()
const APP_NAME = process.env.VITE_APP_NAME || 'Events'
// Surface the resolved value back into env so Vite's HTML %VITE_APP_NAME%
// substitution picks up the fallback when nothing was explicitly set.
process.env.VITE_APP_NAME = APP_NAME
// When the active brand ships a banner (wide logo+wordmark lockup), the
// events header renders it in place of the logo + name pair. Surfaced as
// a '1'/'' flag the component reads; the actual file comes through the
// @brand-app-banner alias below. See branding/README.md.
process.env.VITE_APP_BANNER = resolveAppBanner('events') ? '1' : ''
export default defineConfig(({ mode }) => ({
base: process.env.VITE_BASE_PATH || '/',
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
@ -73,7 +58,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
eventsHtmlPlugin(),
vue(),
tailwindcss(),
@ -91,19 +75,20 @@ export default defineConfig(({ mode }) => ({
],
},
includeAssets: [
'icons/favicon.ico',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-maskable-192.png',
'icons/icon-maskable-512.png',
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: APP_NAME,
short_name: brand.shortName ?? APP_NAME,
short_name: APP_NAME,
description: 'Discover events near you',
theme_color: brand.themeColor ?? '#1f2937',
background_color: brand.backgroundColor ?? '#ffffff',
theme_color: '#1f2937',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
@ -111,10 +96,10 @@ export default defineConfig(({ mode }) => ({
id: 'aiolabs-events',
categories: ['social', 'entertainment', 'lifestyle'],
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
@ -132,16 +117,9 @@ export default defineConfig(({ mode }) => ({
}),
],
resolve: {
// Array form so we can mix the per-standalone logo regex (needs to
// match `@brand-app-logo?url` query suffix) with the bare string
// aliases without one shadowing the other.
alias: [
brandAppLogoAliasEntry('events'),
brandAppBannerAliasEntry('events'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-events',

View file

@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function forumHtmlPlugin(): Plugin {
return {
@ -46,7 +45,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
forumHtmlPlugin(),
vue(),
tailwindcss(),
@ -61,19 +59,20 @@ export default defineConfig(({ mode }) => ({
],
},
includeAssets: [
'icons/favicon.ico',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-maskable-192.png',
'icons/icon-maskable-512.png',
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Forum — Discussions',
short_name: 'Forum',
description: 'Decentralized link aggregator and discussion forum on Nostr',
theme_color: brand.themeColor ?? '#2563eb',
background_color: brand.backgroundColor ?? '#ffffff',
theme_color: '#2563eb',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
@ -82,10 +81,10 @@ export default defineConfig(({ mode }) => ({
categories: ['social', 'news'],
lang: 'en',
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
@ -103,16 +102,11 @@ export default defineConfig(({ mode }) => ({
}),
],
resolve: {
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
// with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [
brandAppLogoAliasEntry('forum'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
alias: {
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)) },
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
'@/app.config': fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-forum',

View file

@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
/**
* Plugin to rewrite dev server requests to libra.html
@ -51,7 +50,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
libraHtmlPlugin(),
vue(),
tailwindcss(),
@ -68,19 +66,20 @@ export default defineConfig(({ mode }) => ({
],
},
includeAssets: [
'icons/favicon.ico',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-maskable-192.png',
'icons/icon-maskable-512.png',
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Libra — Team Accounting',
short_name: 'Libra',
description: 'Team accounting and expense management',
theme_color: brand.themeColor ?? '#1f2937',
background_color: brand.backgroundColor ?? '#ffffff',
theme_color: '#1f2937',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
@ -89,10 +88,10 @@ export default defineConfig(({ mode }) => ({
categories: ['finance', 'business', 'productivity'],
lang: 'en',
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
@ -110,20 +109,15 @@ export default defineConfig(({ mode }) => ({
}),
],
resolve: {
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
// with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [
brandAppLogoAliasEntry('libra'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
alias: {
// ORDER MATTERS — @rollup/plugin-alias is first-match-wins.
// The more specific @/app.config remap must precede the @ prefix
// alias, otherwise '@/app.config' matches '@' first and resolves
// to ./src/app.config (the hub config). ExpensesAPI etc. import
// from @/app.config and need the per-app config.
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/accounting-app/app.config.ts', import.meta.url)) },
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
'@/app.config': fileURLToPath(new URL('./src/accounting-app/app.config.ts', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-libra',

View file

@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function marketHtmlPlugin(): Plugin {
return {
@ -46,7 +45,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
marketHtmlPlugin(),
vue(),
tailwindcss(),
@ -61,19 +59,20 @@ export default defineConfig(({ mode }) => ({
],
},
includeAssets: [
'icons/favicon.ico',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-maskable-192.png',
'icons/icon-maskable-512.png',
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Market — Nostr',
short_name: 'Market',
description: 'Decentralized marketplace on Nostr with Lightning payments',
theme_color: brand.themeColor ?? '#dc2626',
background_color: brand.backgroundColor ?? '#ffffff',
theme_color: '#dc2626',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
@ -82,10 +81,10 @@ export default defineConfig(({ mode }) => ({
categories: ['shopping', 'business'],
lang: 'en',
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
@ -103,16 +102,11 @@ export default defineConfig(({ mode }) => ({
}),
],
resolve: {
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
// with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [
brandAppLogoAliasEntry('market'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
alias: {
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)) },
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
'@/app.config': fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-market',

View file

@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function restaurantHtmlPlugin(): Plugin {
return {
@ -51,7 +50,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
restaurantHtmlPlugin(),
vue(),
tailwindcss(),
@ -66,12 +64,13 @@ export default defineConfig(({ mode }) => ({
],
},
includeAssets: [
'icons/favicon.ico',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-maskable-192.png',
'icons/icon-maskable-512.png',
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Restaurant — Order',
@ -79,8 +78,8 @@ export default defineConfig(({ mode }) => ({
description: 'Order from your local Nostr-native restaurant with Lightning payments',
// Green to differentiate from market red. PDF tile is purple
// (see ~/dev/shared/extensions/restaurant/static/image/restaurant.png).
theme_color: brand.themeColor ?? '#16a34a',
background_color: brand.backgroundColor ?? '#ffffff',
theme_color: '#16a34a',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
@ -89,10 +88,10 @@ export default defineConfig(({ mode }) => ({
categories: ['food', 'shopping'],
lang: 'en',
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
@ -110,16 +109,11 @@ export default defineConfig(({ mode }) => ({
}),
],
resolve: {
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
// with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [
brandAppLogoAliasEntry('restaurant'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
alias: {
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/restaurant-app/app.config.ts', import.meta.url)) },
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
'@/app.config': fileURLToPath(new URL('./src/restaurant-app/app.config.ts', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-restaurant',

View file

@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
function tasksHtmlPlugin(): Plugin {
return {
@ -46,7 +45,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
tasksHtmlPlugin(),
vue(),
tailwindcss(),
@ -61,19 +59,20 @@ export default defineConfig(({ mode }) => ({
],
},
includeAssets: [
'icons/favicon.ico',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-maskable-192.png',
'icons/icon-maskable-512.png',
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Tasks — Work Orders',
short_name: 'Tasks',
description: 'Decentralized task management on Nostr',
theme_color: brand.themeColor ?? '#4338ca',
background_color: brand.backgroundColor ?? '#ffffff',
theme_color: '#4338ca',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
@ -82,10 +81,10 @@ export default defineConfig(({ mode }) => ({
categories: ['productivity', 'business'],
lang: 'en',
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
@ -103,16 +102,11 @@ export default defineConfig(({ mode }) => ({
}),
],
resolve: {
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
// with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [
brandAppLogoAliasEntry('tasks'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
alias: {
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/tasks-app/app.config.ts', import.meta.url)) },
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
'@/app.config': fileURLToPath(new URL('./src/tasks-app/app.config.ts', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-tasks',

View file

@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
/**
* Plugin to rewrite dev server requests to wallet.html
@ -50,7 +49,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
walletHtmlPlugin(),
vue(),
tailwindcss(),
@ -67,19 +65,20 @@ export default defineConfig(({ mode }) => ({
],
},
includeAssets: [
'icons/favicon.ico',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-maskable-192.png',
'icons/icon-maskable-512.png',
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Wallet — Lightning',
short_name: 'Wallet',
description: 'Lightning Network wallet — send, receive, and manage sats',
theme_color: brand.themeColor ?? '#eab308',
background_color: brand.backgroundColor ?? '#ffffff',
theme_color: '#eab308',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
@ -88,10 +87,10 @@ export default defineConfig(({ mode }) => ({
categories: ['finance'],
lang: 'en',
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
@ -109,16 +108,11 @@ export default defineConfig(({ mode }) => ({
}),
],
resolve: {
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
// with optional `?url` query) doesn't get shadowed by the bare aliases.
alias: [
brandAppLogoAliasEntry('wallet'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
alias: {
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)) },
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
'@/app.config': fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-wallet',

View file

@ -1,23 +0,0 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
// Minimal test runner config. Unit tests live next to the code they
// cover as `*.spec.ts`. The default `node` environment is enough for
// the pure logic + Pinia/Vue-reactivity tests we run today (no DOM);
// switch a given file to jsdom via a per-file `// @vitest-environment`
// pragma if a component test ever needs it.
//
// Only the bare `@` → src alias is mirrored from vite.config.ts. The
// brand-kit aliases (@brand-*) are build-time asset shims that unit
// tests don't touch, so they're deliberately omitted to keep this lean.
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.spec.ts'],
},
resolve: {
alias: [
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
},
})

View file

@ -6,8 +6,9 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Wallet — Lightning</title>
<meta name="apple-mobile-web-app-title" content="Wallet">
<meta name="description" content="Lightning Network wallet — send, receive, and manage sats">