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 # auto-generated build file for PWA
dev-dist/sw.js dev-dist/sw.js
public/icons/
aio-shadcn-vite.code-workspace aio-shadcn-vite.code-workspace
dev-dist dev-dist
.specstory/history .specstory/history

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

View file

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

View file

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

View file

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

61
flake.lock generated
View file

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

114
flake.nix
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

346
pnpm-lock.yaml generated
View file

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

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

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

After

Width:  |  Height:  |  Size: 227 B

View file

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

View file

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

View file

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

View file

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

View file

@ -221,16 +221,3 @@
background: transparent; background: transparent;
} }
} }
/*
* Disable enter/exit animations on reka-ui overlays (dialog, sheet,
* popover, dropdown, tooltip, ) app-wide. They animate via the
* data-state open/closed attribute; zeroing the duration keeps the final
* state but removes the motion (overlays appear/disappear instantly).
* Pulse/spin loaders and CSS transitions (e.g. hovers, the favourite
* heart pop) are unaffected.
*/
[data-state='open'],
[data-state='closed'] {
animation-duration: 0s !important;
}

View file

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

View file

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

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { Component } from 'vue' import type { Component } from 'vue'
import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
export interface BottomTab { export interface BottomTab {
/** Translated label shown under the icon. */ /** Translated label shown under the icon. */
@ -17,11 +18,6 @@ export interface BottomTab {
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and /** Render the entry ghosted (opacity-reduced). Used for coming-soon and
* for auth-required tabs when the user is logged out. */ * for auth-required tabs when the user is logged out. */
disabled?: boolean disabled?: boolean
/** Per-tab active-state override for entries whose active condition
* doesn't reduce to "current route starts with this.path" e.g. a
* "Hosting" tab that is active when a feed-filter ref is on. When
* set it wins over the App-level `isActive(path)` matcher. */
isActive?: () => boolean
} }
interface Props { interface Props {
@ -29,9 +25,13 @@ interface Props {
/** Active-tab matcher. Each app has its own nesting rules so we don't try /** Active-tab matcher. Each app has its own nesting rules so we don't try
* to derive a one-size-fits-all default consumer supplies the function. */ * to derive a one-size-fits-all default consumer supplies the function. */
isActive: (path: string) => boolean isActive: (path: string) => boolean
/** When true (Hub), the unauthenticated profile button still opens the
* sheet so logged-out users can change theme/lang. When false (standalones),
* unauth profile button routes straight to /login. */
loggedOutOpensSheet?: boolean
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: false })
const router = useRouter() const router = useRouter()
@ -42,11 +42,6 @@ function onTabClick(tab: BottomTab) {
} }
if (tab.path) router.push(tab.path) if (tab.path) router.push(tab.path)
} }
function isTabActive(tab: BottomTab): boolean {
if (tab.isActive) return tab.isActive()
return !!tab.path && props.isActive(tab.path)
}
</script> </script>
<template> <template>
@ -61,12 +56,12 @@ function isTabActive(tab: BottomTab): boolean {
:key="tab.name" :key="tab.name"
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors" class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="[ :class="[
isTabActive(tab) tab.path && props.isActive(tab.path)
? 'text-primary' ? 'text-primary'
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
tab.disabled ? 'opacity-50' : '', tab.disabled ? 'opacity-50' : '',
]" ]"
:aria-current="isTabActive(tab) ? 'page' : undefined" :aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
@click="onTabClick(tab)" @click="onTabClick(tab)"
> >
<component :is="tab.icon" class="w-5 h-5" /> <component :is="tab.icon" class="w-5 h-5" />
@ -78,6 +73,10 @@ function isTabActive(tab: BottomTab): boolean {
{{ tab.badge > 99 ? '99+' : tab.badge }} {{ tab.badge > 99 ? '99+' : tab.badge }}
</span> </span>
</button> </button>
<!-- Always-on Profile entry, appended on the right. Consumers don't
pass it; the shell owns it so it's identical across every app. -->
<ProfileSheetTrigger :logged-out-opens-sheet="props.loggedOutOpensSheet" />
</div> </div>
</nav> </nav>
</template> </template>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,6 @@ const messages: LocaleMessages = {
profileDescription: 'Your Nostr identity and display name.', profileDescription: 'Your Nostr identity and display name.',
profileLoggedOutDescription: 'Sign in or change your preferences.', profileLoggedOutDescription: 'Sign in or change your preferences.',
login: 'Log in', login: 'Log in',
menu: 'Menu',
backToHub: 'Back to hub', backToHub: 'Back to hub',
hub: 'Hub', hub: 'Hub',
theme: 'Theme', theme: 'Theme',
@ -69,10 +68,6 @@ const messages: LocaleMessages = {
hosting: 'Hosting', hosting: 'Hosting',
pastEvents: 'Past events', pastEvents: 'Past events',
past: 'Past', past: 'Past',
filters: 'Filters',
clearAll: 'Clear all',
filteringBy: 'Filtering by:',
removeCategory: 'Remove {category} filter',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -103,13 +98,15 @@ const messages: LocaleMessages = {
}, },
detail: { detail: {
getTicket: 'Get Ticket', getTicket: 'Get Ticket',
going: 'Going',
maybe: 'Maybe',
notGoing: 'Not Going',
contactOrganizer: 'Contact Organizer', contactOrganizer: 'Contact Organizer',
organizer: 'Organizer', organizer: 'Organizer',
location: 'Location', location: 'Location',
when: 'When', when: 'When',
tickets: 'Tickets', tickets: 'Tickets',
ticketsAvailable: '{count} tickets available', ticketsAvailable: '{count} tickets available',
ticketsRemainingOfTotal: '{count} of {total} tickets left',
ticketsOwned: 'You have {count} ticket | You have {count} tickets', ticketsOwned: 'You have {count} ticket | You have {count} tickets',
unlimitedTickets: 'Unlimited tickets', unlimitedTickets: 'Unlimited tickets',
buyTicket: 'Buy ticket', buyTicket: 'Buy ticket',
@ -130,7 +127,7 @@ const messages: LocaleMessages = {
registered: 'Registered', registered: 'Registered',
}, },
nav: { nav: {
feed: 'Home', feed: 'Feed',
calendar: 'Calendar', calendar: 'Calendar',
map: 'Map', map: 'Map',
favorites: 'Favorites', favorites: 'Favorites',
@ -202,8 +199,7 @@ const messages: LocaleMessages = {
income: { income: {
title: 'Add Income', title: 'Add Income',
description: 'Submit income for the organization', description: 'Submit income for the organization',
selectAccount: 'Select the revenue account.', selectAccount: 'Select the revenue account',
otherAccountHint: 'Use the "Other" account if you\'re not sure.',
submitIncome: 'Submit Income', submitIncome: 'Submit Income',
notAvailable: 'Income submission is not yet available. This feature is coming soon.', notAvailable: 'Income submission is not yet available. This feature is coming soon.',
}, },

View file

@ -22,7 +22,6 @@ const messages: LocaleMessages = {
profileDescription: 'Tu identidad Nostr y nombre de visualización.', profileDescription: 'Tu identidad Nostr y nombre de visualización.',
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.', profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
login: 'Iniciar sesión', login: 'Iniciar sesión',
menu: 'Menú',
backToHub: 'Volver al hub', backToHub: 'Volver al hub',
hub: 'Hub', hub: 'Hub',
theme: 'Tema', theme: 'Tema',
@ -69,10 +68,6 @@ const messages: LocaleMessages = {
hosting: 'Organizo', hosting: 'Organizo',
pastEvents: 'Eventos pasados', pastEvents: 'Eventos pasados',
past: 'Pasado', past: 'Pasado',
filters: 'Filtros',
clearAll: 'Limpiar todo',
filteringBy: 'Filtrando por:',
removeCategory: 'Quitar el filtro {category}',
}, },
categories: { categories: {
concert: 'Concierto', concert: 'Concierto',
@ -103,13 +98,15 @@ const messages: LocaleMessages = {
}, },
detail: { detail: {
getTicket: 'Obtener boleto', getTicket: 'Obtener boleto',
going: 'Voy',
maybe: 'Tal vez',
notGoing: 'No voy',
contactOrganizer: 'Contactar organizador', contactOrganizer: 'Contactar organizador',
organizer: 'Organizador', organizer: 'Organizador',
location: 'Ubicación', location: 'Ubicación',
when: 'Cuándo', when: 'Cuándo',
tickets: 'Boletos', tickets: 'Boletos',
ticketsAvailable: '{count} boletos disponibles', ticketsAvailable: '{count} boletos disponibles',
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos', ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
unlimitedTickets: 'Boletos ilimitados', unlimitedTickets: 'Boletos ilimitados',
buyTicket: 'Comprar boleto', buyTicket: 'Comprar boleto',
@ -202,8 +199,7 @@ const messages: LocaleMessages = {
income: { income: {
title: 'A\u00f1adir ingreso', title: 'A\u00f1adir ingreso',
description: 'Enviar un ingreso para la organizaci\u00f3n', description: 'Enviar un ingreso para la organizaci\u00f3n',
selectAccount: 'Seleccionar la cuenta de ingresos.', selectAccount: 'Seleccionar la cuenta de ingresos',
otherAccountHint: 'Use la cuenta "Other" si no está seguro.',
submitIncome: 'Enviar ingreso', submitIncome: 'Enviar ingreso',
notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.', notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.',
}, },

View file

@ -22,7 +22,6 @@ const messages: LocaleMessages = {
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.', profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.', profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
login: 'Se connecter', login: 'Se connecter',
menu: 'Menu',
backToHub: 'Retour au hub', backToHub: 'Retour au hub',
hub: 'Hub', hub: 'Hub',
theme: 'Thème', theme: 'Thème',
@ -69,10 +68,6 @@ const messages: LocaleMessages = {
hosting: 'J\'organise', hosting: 'J\'organise',
pastEvents: 'Événements passés', pastEvents: 'Événements passés',
past: 'Passé', past: 'Passé',
filters: 'Filtres',
clearAll: 'Tout effacer',
filteringBy: 'Filtré par :',
removeCategory: 'Retirer le filtre {category}',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -103,13 +98,15 @@ const messages: LocaleMessages = {
}, },
detail: { detail: {
getTicket: 'Obtenir un billet', getTicket: 'Obtenir un billet',
going: 'Présent',
maybe: 'Peut-être',
notGoing: 'Absent',
contactOrganizer: "Contacter l'organisateur", contactOrganizer: "Contacter l'organisateur",
organizer: 'Organisateur', organizer: 'Organisateur',
location: 'Lieu', location: 'Lieu',
when: 'Quand', when: 'Quand',
tickets: 'Billets', tickets: 'Billets',
ticketsAvailable: '{count} billets disponibles', ticketsAvailable: '{count} billets disponibles',
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets', ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
unlimitedTickets: 'Billets illimités', unlimitedTickets: 'Billets illimités',
buyTicket: 'Acheter un billet', buyTicket: 'Acheter un billet',
@ -130,7 +127,7 @@ const messages: LocaleMessages = {
registered: 'Enregistré', registered: 'Enregistré',
}, },
nav: { nav: {
feed: 'Accueil', feed: 'Fil',
calendar: 'Calendrier', calendar: 'Calendrier',
map: 'Carte', map: 'Carte',
favorites: 'Favoris', favorites: 'Favoris',
@ -202,8 +199,7 @@ const messages: LocaleMessages = {
income: { income: {
title: 'Ajouter un revenu', title: 'Ajouter un revenu',
description: 'Soumettre un revenu pour l\u2019organisation', description: 'Soumettre un revenu pour l\u2019organisation',
selectAccount: 'S\u00e9lectionner le compte de revenus.', selectAccount: 'S\u00e9lectionner le compte de revenus',
otherAccountHint: 'Utilisez le compte "Other" si vous n\'\u00eates pas s\u00fbr.',
submitIncome: 'Soumettre le revenu', submitIncome: 'Soumettre le revenu',
notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.', notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.',
}, },

View file

@ -21,7 +21,6 @@ export interface LocaleMessages {
profileDescription: string profileDescription: string
profileLoggedOutDescription: string profileLoggedOutDescription: string
login: string login: string
menu: string
backToHub: string backToHub: string
hub: string hub: string
theme: string theme: string
@ -70,21 +69,19 @@ export interface LocaleMessages {
hosting: string hosting: string
pastEvents: string pastEvents: string
past: string past: string
filters: string
clearAll: string
filteringBy: string
removeCategory: string
} }
categories: Record<string, string> categories: Record<string, string>
detail: { detail: {
getTicket: string getTicket: string
going: string
maybe: string
notGoing: string
contactOrganizer: string contactOrganizer: string
organizer: string organizer: string
location: string location: string
when: string when: string
tickets: string tickets: string
ticketsAvailable: string ticketsAvailable: string
ticketsRemainingOfTotal: string
ticketsOwned: string ticketsOwned: string
unlimitedTickets: string unlimitedTickets: string
buyTicket: string buyTicket: string
@ -179,7 +176,6 @@ export interface LocaleMessages {
title: string title: string
description: string description: string
selectAccount: string selectAccount: string
otherAccountHint: string
submitIncome: string submitIncome: string
notAvailable: string notAvailable: string
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -432,7 +432,7 @@ const handleOpenChange = (open: boolean) => {
<template> <template>
<Dialog :open="open" @update:open="handleOpenChange"> <Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="max-w-lg max-h-[90vh] p-0 overflow-x-hidden"> <DialogContent class="max-w-lg max-h-[90vh] p-0">
<DialogHeader class="px-6 pt-6 pb-2"> <DialogHeader class="px-6 pt-6 pb-2">
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<Calendar class="w-5 h-5" /> <Calendar class="w-5 h-5" />
@ -611,13 +611,9 @@ const handleOpenChange = (open: boolean) => {
fiat amounts convert at checkout using current rates. fiat amounts convert at checkout using current rates.
</p> </p>
</div> </div>
<!-- Pricing grid: 2 cols on narrow phones (Tickets/Price share <div class="grid grid-cols-3 gap-3">
a row, Currency takes its own), 3 cols once we have the
breathing room. Plain `grid-cols-3` was overflowing the
dialog on 360px-class viewports. -->
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets"> <FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem class="min-w-0"> <FormItem>
<FormLabel>Tickets</FormLabel> <FormLabel>Tickets</FormLabel>
<FormControl> <FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" /> <Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
@ -628,7 +624,7 @@ const handleOpenChange = (open: boolean) => {
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket"> <FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem class="min-w-0"> <FormItem>
<FormLabel>Price</FormLabel> <FormLabel>Price</FormLabel>
<FormControl> <FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" /> <Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
@ -639,11 +635,11 @@ const handleOpenChange = (open: boolean) => {
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="currency"> <FormField v-slot="{ componentField }" name="currency">
<FormItem class="col-span-2 sm:col-span-1 min-w-0"> <FormItem>
<FormLabel>Price currency</FormLabel> <FormLabel>Price currency</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField"> <Select v-bind="componentField">
<SelectTrigger class="w-full"> <SelectTrigger>
<SelectValue placeholder="sat" /> <SelectValue placeholder="sat" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -743,26 +739,12 @@ const handleOpenChange = (open: boolean) => {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<!-- Actions. Stack full-width on narrow viewports so a wide <!-- Actions -->
localized "Submit Event" label can't push the Cancel <div class="flex justify-end gap-3 pt-2">
button out of the dialog. Submit reads top-down on <Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
mobile (flex-col-reverse) to keep the primary action
under the thumb. -->
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
class="w-full sm:w-auto"
@click="handleOpenChange(false)"
:disabled="isLoading"
>
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" :disabled="isLoading || !isFormValid">
type="submit"
class="w-full sm:w-auto"
:disabled="isLoading || !isFormValid"
>
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{ {{
isLoading isLoading

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -70,11 +70,6 @@ function increaseQuantity() {
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value) const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
// Free events (price 0): no invoice is minted the backend issues the
// tickets already-paid. Drop the payment-method selector and price line
// and label the CTA "Get ticket" instead of "Proceed".
const isFree = computed(() => props.event.price_per_ticket <= 0)
async function copyInvoice() { async function copyInvoice() {
if (!paymentRequest.value) return if (!paymentRequest.value) return
try { try {
@ -264,10 +259,7 @@ onUnmounted(() => {
<DialogDescription> <DialogDescription>
<span v-if="quantity > 1"> <span v-if="quantity > 1">
{{ quantity }} tickets for <strong>{{ event.name }}</strong> · {{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }} {{ formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else-if="isFree">
Get a free ticket for <strong>{{ event.name }}</strong>
</span> </span>
<span v-else> <span v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }} Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
@ -383,9 +375,9 @@ onUnmounted(() => {
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} {{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span> </span>
<span class="text-sm font-medium">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span> <span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
</div> </div>
<PriceConversionPreview <PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency" v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
@ -404,7 +396,7 @@ onUnmounted(() => {
Lightning rather than collapsing into a single "Fiat" Lightning rather than collapsing into a single "Fiat"
catch-all. Hidden entirely for Lightning-only events to catch-all. Hidden entirely for Lightning-only events to
keep the dialog uncluttered. --> 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> <div class="text-sm font-medium">Payment method</div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails. Both methods charge the same amount via different rails.
@ -445,17 +437,13 @@ onUnmounted(() => {
class="w-full" class="w-full"
> >
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<template v-else-if="isFree">
<Ticket class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
</template>
<template v-else-if="selectedMethod?.rail === 'fiat'"> <template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" /> <CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout Continue to {{ selectedMethod.label }} checkout
</template> </template>
<template v-else> <template v-else>
<Zap class="w-4 h-4 mr-2" /> <Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }} {{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
</template> </template>
</Button> </Button>
</div> </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"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { History } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { TemporalFilter } from '../types/filters' import type { TemporalFilter } from '../types/filters'
const props = defineProps<{ const props = defineProps<{
modelValue: TemporalFilter modelValue: TemporalFilter
showPast: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: TemporalFilter] 'update:modelValue': [value: TemporalFilter]
'toggle-past': []
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@ -26,33 +23,16 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
</script> </script>
<template> <template>
<!-- pb-1 pr-1 keep the theme's offset drop-shadow (neobrut casts a hard 4px <div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
shadow down and to the right) from being clipped at the scroll box's
bottom/right edges (overflow-x-auto forces overflow-y to auto). -->
<div class="flex gap-2 overflow-x-auto pb-1 pr-1" style="-ms-overflow-style: none; scrollbar-width: none;">
<Button <Button
v-for="option in options" v-for="option in options"
:key="option.value" :key="option.value"
:variant="props.modelValue === option.value ? 'default' : 'outline'" :variant="props.modelValue === option.value ? 'default' : 'outline'"
size="sm" size="sm"
class="text-xs shrink-0" class="rounded-full text-xs shrink-0"
@click="emit('update:modelValue', option.value)" @click="emit('update:modelValue', option.value)"
> >
{{ t(option.labelKey) }} {{ t(option.labelKey) }}
</Button> </Button>
<!-- Past pill sits at the end of the same scrollable strip so it's
discoverable alongside the time-window pills, without claiming
a dropdown row of its own. Composes orthogonally with the
temporal pills: e.g. "This Week" + Past narrows to days
already past this week. -->
<Button
:variant="props.showPast ? 'default' : 'outline'"
size="sm"
class="text-xs shrink-0 gap-1.5"
@click="emit('toggle-past')"
>
<History class="w-3 h-3" />
{{ t('events.filters.past', 'Past') }}
</Button>
</div> </div>
</template> </template>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,19 +5,12 @@ import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from './useOwnedTickets'
export function useTicketPurchase() { export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
// Refresh the shared owned-tickets singleton after a purchase so the
// feed/calendar "My tickets" filter and EventCard owned badges update
// without a reload — purchase is exactly the "consumer that mutates
// the ticket set" useOwnedTickets's docs anticipate.
const { refresh: refreshOwnedTickets } = useOwnedTickets()
// Async operations // Async operations
const purchaseOperation = useAsyncOperation() const purchaseOperation = useAsyncOperation()
@ -94,38 +87,6 @@ export function useTicketPurchase() {
* have to take it as an argument from the UI. */ * have to take it as an argument from the UI. */
const currentEventId = ref<string | null>(null) const currentEventId = ref<string | null>(null)
/**
* Advance to the ticket-QR success state for the given row ids. Shared
* by the Lightning-poll path (payment confirmed) and the free-ticket
* path (issued + paid server-side, no invoice) so both render one
* scannable QR per attendee identically.
*/
async function finalizePurchasedTickets(ids: string[]) {
// Ticket row(s) now exist — refresh the shared owned-tickets state so
// the feed/calendar My-tickets filter and owned badges reflect the
// purchase immediately (no reload). Runs in parallel with QR gen.
void refreshOwnedTickets()
if (ids.length > 0) {
purchasedTicketIds.value = ids
purchasedTicketId.value = ids[0]
const qrMap: Record<string, string> = {}
for (const id of ids) {
const dataUrl = await generateTicketQRCode(id)
if (dataUrl) qrMap[id] = dataUrl
}
ticketQRCodes.value = qrMap
ticketQRCode.value = qrMap[ids[0]] ?? null
showTicketQR.value = true
}
toast.success(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
}
async function purchaseTicketForEvent( async function purchaseTicketForEvent(
eventId: string, eventId: string,
options: { quantity?: number } = {}, options: { quantity?: number } = {},
@ -157,23 +118,13 @@ export function useTicketPurchase() {
{ quantity: options.quantity }, { quantity: options.quantity },
) )
// Free tickets (price 0 / 100%-off promo): the backend already // Backend now returns either a Lightning invoice or a fiat
// issued + marked them paid, so there's no invoice to settle. Skip // checkout URL (post-events-v1.4.0). This composable only knows
// the QR / payment-poll and jump straight to the ticket-QR success // how to drive the Lightning path; fiat would need a separate
// state with the ids returned inline. // redirect-to-provider flow that lives in PurchaseTicketDialog
if (invoice.paid) { // (it has the user-visible payment-method selector). Reject the
paymentHash.value = invoice.paymentHash // fiat response here so callers get a clear error instead of a
await finalizePurchasedTickets(invoice.ticketIds ?? []) // silent broken QR.
return invoice
}
// Otherwise the backend returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows how
// to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog (it
// has the user-visible payment-method selector). Reject the fiat
// response here so callers get a clear error instead of a silent
// broken QR.
if (invoice.isFiat || !invoice.paymentRequest) { if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error( throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' + 'This event uses fiat checkout. Use the purchase dialog ' +
@ -229,13 +180,32 @@ export function useTicketPurchase() {
// Multi-ticket purchases come back with `ticketIds` (N rows // Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include // sharing one invoice). Single-ticket purchases include
// `ticketId` only. // `ticketId` only. Render one QR per row so each attendee
// has their own scannable code at the door.
const ids = result.ticketIds && result.ticketIds.length > 0 const ids = result.ticketIds && result.ticketIds.length > 0
? result.ticketIds ? result.ticketIds
: result.ticketId : result.ticketId
? [result.ticketId] ? [result.ticketId]
: [] : []
await finalizePurchasedTickets(ids)
if (ids.length > 0) {
purchasedTicketIds.value = ids
purchasedTicketId.value = ids[0]
const qrMap: Record<string, string> = {}
for (const id of ids) {
const dataUrl = await generateTicketQRCode(id)
if (dataUrl) qrMap[id] = dataUrl
}
ticketQRCodes.value = qrMap
ticketQRCode.value = qrMap[ids[0]] ?? null
showTicketQR.value = true
}
toast.success(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
} }
} catch (err) { } catch (err) {
console.error('Error checking payment status:', err) console.error('Error checking payment status:', err)

View file

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

View file

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

View file

@ -105,10 +105,6 @@ export class TicketApiService {
fiatPaymentRequest: data.fiat_payment_request ?? undefined, fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined, fiatProvider: data.fiat_provider ?? undefined,
isFiat: Boolean(data.is_fiat), isFiat: Boolean(data.is_fiat),
// Free tickets: backend returns paid=true with the row ids inline
// and no invoice to settle.
paid: Boolean(data.paid),
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
} }
} }

View file

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

View file

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

View file

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

View file

@ -81,12 +81,10 @@ export interface TicketPurchaseRequest {
} }
/** /**
* Server response from `POST /tickets/{event_id}`. One of three shapes: * Server response from `POST /tickets/{event_id}`. Either Lightning
* - Lightning: `paymentRequest` = bolt11 (`isFiat` false, `paid` false) * (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
* - Fiat: `fiatPaymentRequest` = a URL the buyer follows with `fiatProvider` * the buyer follows to complete payment with `fiatProvider`).
* - Free: `paid` true with no `paymentRequest` tickets already issued + * `isFiat` is the discriminator.
* paid server-side (price 0 / 100%-off promo); `ticketIds` carries the
* scannable rows so the client skips the payment step entirely.
*/ */
export interface TicketPurchaseInvoice { export interface TicketPurchaseInvoice {
paymentHash: string paymentHash: string
@ -94,10 +92,6 @@ export interface TicketPurchaseInvoice {
fiatPaymentRequest?: string fiatPaymentRequest?: string
fiatProvider?: string fiatProvider?: string
isFiat: boolean isFiat: boolean
/** Free tickets: already issued + paid, no invoice to settle. */
paid?: boolean
/** Row ids returned inline for the free path (no poll needed). */
ticketIds?: string[]
} }
export interface TicketPaymentStatus { export interface TicketPaymentStatus {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -84,12 +84,8 @@
<!-- Step 1: Account Selection --> <!-- Step 1: Account Selection -->
<div v-if="currentStep === 1"> <div v-if="currentStep === 1">
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 mb-4 text-sm"> <p class="text-sm text-muted-foreground mb-4">
<span class="text-muted-foreground">Select the account for this expense.</span> Select the account for this expense
<span class="inline-flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-4 w-4 shrink-0" />
Use the "Other" account if you're not sure.
</span>
</p> </p>
<AccountSelector <AccountSelector
v-model="selectedAccount" v-model="selectedAccount"
@ -107,17 +103,18 @@
<FormLabel>Description *</FormLabel> <FormLabel>Description *</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="A detailed description of the purchase/invoice/bill, and what it was used for (event/project/etc)..." placeholder="e.g., Biocoop, Ferme des Croquantes, Foix Market, etc"
v-bind="componentField" v-bind="componentField"
rows="3" rows="3"
/> />
</FormControl> </FormControl>
<FormDescription>
Describe what this expense was for
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Amount + Currency -->
<div class="grid grid-cols-2 gap-4">
<!-- Amount --> <!-- Amount -->
<FormField v-slot="{ componentField }" name="amount"> <FormField v-slot="{ componentField }" name="amount">
<FormItem> <FormItem>
@ -131,6 +128,9 @@
step="0.01" step="0.01"
/> />
</FormControl> </FormControl>
<FormDescription>
Amount in selected currency
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@ -141,7 +141,7 @@
<FormLabel>Currency *</FormLabel> <FormLabel>Currency *</FormLabel>
<Select v-bind="componentField"> <Select v-bind="componentField">
<FormControl> <FormControl>
<SelectTrigger class="w-full"> <SelectTrigger>
<SelectValue placeholder="Select currency" /> <SelectValue placeholder="Select currency" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
@ -155,21 +155,26 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>
Currency for this expense
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
</div>
<!-- Reference (optional) --> <!-- Reference (optional) -->
<FormField v-slot="{ componentField }" name="reference"> <FormField v-slot="{ componentField }" name="reference">
<FormItem> <FormItem>
<FormLabel>Reference (optional)</FormLabel> <FormLabel>Reference</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="e.g., Invoice #123, Receipt #456" placeholder="e.g., Invoice #123, Receipt #456"
v-bind="componentField" v-bind="componentField"
/> />
</FormControl> </FormControl>
<FormDescription>
Optional reference number or note
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@ -274,7 +279,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { DollarSign, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock, AlertTriangle } from 'lucide-vue-next' import { DollarSign, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

@ -5,16 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' 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 * 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) * VITE_BASE_PATH=/events/ app.ariege.io/events/ (shared auth)
* (default: /) bouge.ariege.io (standalone subdomain) * (default: /) bouge.ariege.io (standalone subdomain)
* *
* Brand name resolves from brand.json under $BRAND_DIR (see * Set VITE_APP_NAME to brand the standalone (PWA name, HTML title, console
* vite-branding.ts and aiolabs/webapp#95). Surfaced into Vite's HTML * logs). cfaun overrides to "Bouge" via NixOS. Defaults to "Events".
* env-var substitution as VITE_APP_NAME for templated titles.
*/ */
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 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 }) => ({ export default defineConfig(({ mode }) => ({
base: process.env.VITE_BASE_PATH || '/', base: process.env.VITE_BASE_PATH || '/',
// Per-app dep cache so concurrent dev servers don't race on .vite/deps // Per-app dep cache so concurrent dev servers don't race on .vite/deps
@ -73,7 +58,6 @@ export default defineConfig(({ mode }) => ({
strictPort: true, strictPort: true,
}, },
plugins: [ plugins: [
brandAssetsPlugin(),
eventsHtmlPlugin(), eventsHtmlPlugin(),
vue(), vue(),
tailwindcss(), tailwindcss(),
@ -91,19 +75,20 @@ export default defineConfig(({ mode }) => ({
], ],
}, },
includeAssets: [ includeAssets: [
'icons/favicon.ico', 'favicon.ico',
'icons/apple-touch-icon.png', 'apple-touch-icon.png',
'icons/icon-192.png', 'mask-icon.svg',
'icons/icon-512.png', 'icon-192.png',
'icons/icon-maskable-192.png', 'icon-512.png',
'icons/icon-maskable-512.png', 'icon-maskable-192.png',
'icon-maskable-512.png',
], ],
manifest: { manifest: {
name: APP_NAME, name: APP_NAME,
short_name: brand.shortName ?? APP_NAME, short_name: APP_NAME,
description: 'Discover events near you', description: 'Discover events near you',
theme_color: brand.themeColor ?? '#1f2937', theme_color: '#1f2937',
background_color: brand.backgroundColor ?? '#ffffff', background_color: '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait-primary', orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/', start_url: process.env.VITE_BASE_PATH || '/',
@ -111,10 +96,10 @@ export default defineConfig(({ mode }) => ({
id: 'aiolabs-events', id: 'aiolabs-events',
categories: ['social', 'entertainment', 'lifestyle'], categories: ['social', 'entertainment', 'lifestyle'],
icons: [ icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, { src: '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-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
], ],
}, },
}), }),
@ -132,16 +117,9 @@ export default defineConfig(({ mode }) => ({
}), }),
], ],
resolve: { resolve: {
// Array form so we can mix the per-standalone logo regex (needs to alias: {
// match `@brand-app-logo?url` query suffix) with the bare string '@': fileURLToPath(new URL('./src', import.meta.url)),
// 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)) },
],
}, },
build: { build: {
outDir: 'dist-events', outDir: 'dist-events',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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