Compare commits
No commits in common. "dev" and "main" have entirely different histories.
1
.gitignore
vendored
|
|
@ -29,7 +29,6 @@ dist.tar.gz
|
|||
|
||||
# auto-generated build file for PWA
|
||||
dev-dist/sw.js
|
||||
public/icons/
|
||||
aio-shadcn-vite.code-workspace
|
||||
dev-dist
|
||||
.specstory/history
|
||||
|
|
|
|||
55
CLAUDE.md
|
|
@ -712,63 +712,8 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
|
|||
|
||||
# Optional: Disable WebSocket if needed
|
||||
VITE_WEBSOCKET_ENABLED=true
|
||||
|
||||
# Brand kit override (defaults to ./branding/default)
|
||||
BRAND_DIR=branding/cfaun
|
||||
|
||||
# Per-standalone brand override (set by build pipeline, not directly)
|
||||
BRAND_APP=events
|
||||
```
|
||||
|
||||
## Brand kit (white-label PWA branding)
|
||||
|
||||
The webapp ships a brand kit architecture so the hub + every standalone
|
||||
(events, wallet, chat, market, …) can be rebranded per deployment without
|
||||
forking the codebase. See `branding/README.md` for the deployer contract.
|
||||
|
||||
**Single source of truth:** `branding/<dep>/` holds `logo.{svg,png}` +
|
||||
`brand.json`. `vite-branding.ts` reads brand.json and exposes a `@brand`
|
||||
import alias. `pwa-assets.config.ts` + `@vite-pwa/assets-generator` derive
|
||||
the full PWA icon set from the single logo source.
|
||||
|
||||
**brand.json schema:** `{ name, shortName?, themeColor?, backgroundColor? }`
|
||||
— `name` drives the manifest. `themeColor`/`backgroundColor` are optional
|
||||
chrome overrides; when unset, each standalone's per-app accent applies.
|
||||
|
||||
**In-app logo:** components reference `@brand/logo.png`. Active consumers:
|
||||
`Login.vue`, `LoginDemo.vue`, `AppSidebar.vue`, `MobileDrawer.vue`. The
|
||||
Vite alias resolves to the active brand dir at build time.
|
||||
|
||||
**Generated icons:** `public/icons/` is gitignored. `brandAssetsPlugin()`
|
||||
(registered first in every `vite.*.config.ts`'s plugins[]) runs the
|
||||
generator once per build/dev start via `buildStart`. Outputs match the
|
||||
existing filename convention (`icon-192.png`, `icon-maskable-512.png`,
|
||||
…) so HTML `<link>` hrefs and VitePWA `manifest.icons` reference
|
||||
`/icons/<name>` consistently across all 9 configs.
|
||||
|
||||
**Per-standalone override:** `branding/<dep>/icons/<app>/logo.{svg,png}`
|
||||
is checked before the brand's primary logo. The standalone build sets
|
||||
`BRAND_APP`; deployers just put files in the right place.
|
||||
|
||||
**Switching brands:**
|
||||
```bash
|
||||
BRAND_DIR=branding/cfaun pnpm build:events
|
||||
```
|
||||
|
||||
**Adding a new in-app logo consumer:** use `<img src="@brand/logo.png">`
|
||||
instead of `@/assets/logo.png`. The latter still works for non-brand
|
||||
assets (`@/assets/bitcoin.svg`, etc.) — it's only the logo that moved.
|
||||
|
||||
**NixOS deployment:** `flake.nix` exposes
|
||||
`lib.mkWebapp { pkgs, brandDir ? ./branding/default, app ? "main" }`.
|
||||
Server-deploy hosts call it from their `services/webapp.nix`:
|
||||
`inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "events"; }`.
|
||||
Builder pins `pkgs.pnpm_10` regardless of consumer's nixpkgs (keeps
|
||||
pnpmDeps hash stable downstream), uses `autoPatchelfHook` to handle
|
||||
prebuilt sharp binaries, and sets `CI=true` to bypass pnpm's
|
||||
interactive modules-purge prompt. Per-host migration tracked in
|
||||
aiolabs/server-deploy#8.
|
||||
|
||||
## Payment Rails Pattern
|
||||
|
||||
Shared primitives for modules that mix Lightning + fiat (and, future,
|
||||
|
|
|
|||
|
|
@ -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"; }'
|
||||
```
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"name": "AIO",
|
||||
"shortName": "AIO"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 456 KiB |
|
|
@ -6,8 +6,9 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Chat — Encrypted</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Chat">
|
||||
<meta name="description" content="End-to-end encrypted Nostr chat">
|
||||
|
|
|
|||
|
|
@ -7,19 +7,15 @@ in this file follows from that single fact.
|
|||
|
||||
## Strictly-monotonic `created_at` per coord
|
||||
|
||||
**Canonical helper:** `src/lib/nostr/timestamp.ts` —
|
||||
`monotonicCreatedAt(lastCreatedAt, now?)` returns `max(now, last + 1)`.
|
||||
Use it for **every** replaceable-event publish; track the last
|
||||
`created_at` per coord (a `Map<coord, number>` when one composable
|
||||
publishes many coords like `useRSVP.ts`, or a single field when there's
|
||||
one coord per user like `useBookmarks.ts`' kind-10003 list).
|
||||
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
|
||||
|
||||
```ts
|
||||
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
|
||||
|
||||
const lastPublishAt = new Map<string, number>()
|
||||
|
||||
const createdAt = monotonicCreatedAt(lastPublishAt.get(coord))
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const previous = lastPublishAt.get(coord) ?? 0
|
||||
const createdAt = Math.max(now, previous + 1)
|
||||
…
|
||||
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
|
||||
```
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>%VITE_APP_NAME%</title>
|
||||
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||
<meta name="description" content="Discover events near you">
|
||||
|
|
|
|||
61
flake.lock
generated
|
|
@ -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
|
|
@ -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;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -6,8 +6,9 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Forum — Discussions</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Forum">
|
||||
<meta name="description" content="Decentralized link aggregator and discussion forum on Nostr">
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@
|
|||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<!-- <meta name="theme-color" content="#ffffff"> -->
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>%VITE_APP_NAME% Hub</title>
|
||||
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Libra — Accounting</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Libra">
|
||||
<meta name="description" content="Team accounting and expense management">
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Market — Nostr</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Market">
|
||||
<meta name="description" content="Decentralized marketplace on Nostr with Lightning payments">
|
||||
|
|
|
|||
|
|
@ -5,13 +5,10 @@
|
|||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"generate-pwa-assets": "node scripts/generate-pwa-assets.mjs",
|
||||
"dev": "vite --host",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview --host",
|
||||
"analyze": "vite build --mode analyze",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"dev:events": "vite --host --config vite.events.config.ts",
|
||||
"build:events": "vue-tsc -b && vite build --config vite.events.config.ts",
|
||||
"preview:events": "vite preview --host --config vite.events.config.ts",
|
||||
|
|
@ -95,7 +92,6 @@
|
|||
"@types/node": "^22.18.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/rollup-plugin-visualizer": "^4.2.3",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"concurrently": "^8.2.2",
|
||||
|
|
@ -109,7 +105,6 @@
|
|||
"vite-plugin-image-optimizer": "^1.1.7",
|
||||
"vite-plugin-inspect": "^0.8.3",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^4.1.9",
|
||||
"vue-tsc": "^2.2.0",
|
||||
"web-push": "^3.6.7",
|
||||
"workbox-window": "^7.3.0"
|
||||
|
|
|
|||
346
pnpm-lock.yaml
generated
|
|
@ -150,9 +150,6 @@ importers:
|
|||
'@types/rollup-plugin-visualizer':
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.4
|
||||
'@vite-pwa/assets-generator':
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))
|
||||
|
|
@ -191,10 +188,7 @@ importers:
|
|||
version: 0.8.9(rollup@4.60.4)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
vite-plugin-pwa:
|
||||
specifier: ^0.21.1
|
||||
version: 0.21.2(@vite-pwa/assets-generator@1.0.2)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1)
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@22.19.19)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
version: 0.21.2(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1)
|
||||
vue-tsc:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.12(typescript@5.6.3)
|
||||
|
|
@ -717,9 +711,6 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@canvas/image-data@1.1.0':
|
||||
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
|
||||
|
||||
'@electron-forge/cli@7.11.2':
|
||||
resolution: {integrity: sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg==}
|
||||
engines: {node: '>= 16.4.0'}
|
||||
|
|
@ -1292,9 +1283,6 @@ packages:
|
|||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@quansync/fs@1.0.0':
|
||||
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
|
||||
|
||||
'@rollup/plugin-babel@6.1.0':
|
||||
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
@ -1498,9 +1486,6 @@ packages:
|
|||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||
|
||||
|
|
@ -1641,12 +1626,6 @@ packages:
|
|||
'@types/cacheable-request@6.0.3':
|
||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
|
|
@ -1718,11 +1697,6 @@ packages:
|
|||
peerDependencies:
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vite-pwa/assets-generator@1.0.2':
|
||||
resolution: {integrity: sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==}
|
||||
engines: {node: '>=16.14.0'}
|
||||
hasBin: true
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4':
|
||||
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
|
@ -1730,35 +1704,6 @@ packages:
|
|||
vite: ^5.0.0 || ^6.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
|
||||
|
||||
'@vitest/mocker@4.1.9':
|
||||
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
|
||||
|
||||
'@vitest/spy@4.1.9':
|
||||
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
||||
|
||||
'@volar/language-core@2.4.15':
|
||||
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
||||
|
||||
|
|
@ -2070,10 +2015,6 @@ packages:
|
|||
asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
async-function@1.0.0:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -2180,10 +2121,6 @@ packages:
|
|||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
cacache@16.1.3:
|
||||
resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
|
|
@ -2215,10 +2152,6 @@ packages:
|
|||
caniuse-lite@1.0.30001793:
|
||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -2334,10 +2267,6 @@ packages:
|
|||
engines: {node: ^14.13.0 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
||||
consola@3.4.2:
|
||||
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
|
|
@ -2437,14 +2366,6 @@ packages:
|
|||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decode-bmp@0.2.1:
|
||||
resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
decode-ico@0.4.1:
|
||||
resolution: {integrity: sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -2662,9 +2583,6 @@ packages:
|
|||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
esutils@2.0.3:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2688,10 +2606,6 @@ packages:
|
|||
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
exponential-backoff@3.1.3:
|
||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||
|
||||
|
|
@ -2976,9 +2890,6 @@ packages:
|
|||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||
|
||||
ico-endec@0.1.6:
|
||||
resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -3652,10 +3563,6 @@ packages:
|
|||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
obug@2.1.3:
|
||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
|
|
@ -3786,9 +3693,6 @@ packages:
|
|||
pathe@1.1.2:
|
||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pe-library@1.0.1:
|
||||
resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==}
|
||||
engines: {node: '>=14', npm: '>=7'}
|
||||
|
|
@ -3896,9 +3800,6 @@ packages:
|
|||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
quansync@1.0.0:
|
||||
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
|
|
@ -4119,9 +4020,6 @@ packages:
|
|||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
sharp-ico@0.1.5:
|
||||
resolution: {integrity: sha512-a3jODQl82NPp1d5OYb0wY+oFaPk7AvyxipIowCHk7pBsZCWgbe0yAkU2OOXdoH0ENyANhyOQbs9xkAiRHcF02Q==}
|
||||
|
||||
sharp@0.33.5:
|
||||
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
|
@ -4162,9 +4060,6 @@ packages:
|
|||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
signal-exit@3.0.7:
|
||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||
|
||||
|
|
@ -4245,12 +4140,6 @@ packages:
|
|||
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -4418,21 +4307,10 @@ packages:
|
|||
tiny-each-async@2.0.3:
|
||||
resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.2.4:
|
||||
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tmp-promise@3.0.3:
|
||||
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
||||
|
||||
|
|
@ -4444,9 +4322,6 @@ packages:
|
|||
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
to-data-view@1.1.0:
|
||||
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
|
@ -4522,12 +4397,6 @@ packages:
|
|||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
unconfig-core@7.5.0:
|
||||
resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
|
||||
|
||||
unconfig@7.5.0:
|
||||
resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
|
|
@ -4667,47 +4536,6 @@ packages:
|
|||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.9:
|
||||
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.9
|
||||
'@vitest/browser-preview': 4.1.9
|
||||
'@vitest/browser-webdriverio': 4.1.9
|
||||
'@vitest/coverage-istanbul': 4.1.9
|
||||
'@vitest/coverage-v8': 4.1.9
|
||||
'@vitest/ui': 4.1.9
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
|
|
@ -4828,11 +4656,6 @@ packages:
|
|||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
word-wrap@1.2.5:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -5633,8 +5456,6 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@canvas/image-data@1.1.0': {}
|
||||
|
||||
'@electron-forge/cli@7.11.2(encoding@0.1.13)(lightningcss@1.32.0)':
|
||||
dependencies:
|
||||
'@electron-forge/core': 7.11.2(encoding@0.1.13)(lightningcss@1.32.0)
|
||||
|
|
@ -6413,10 +6234,6 @@ snapshots:
|
|||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@quansync/fs@1.0.0':
|
||||
dependencies:
|
||||
quansync: 1.0.0
|
||||
|
||||
'@rollup/plugin-babel@6.1.0(@babel/core@7.29.0)(rollup@4.60.4)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
|
@ -6552,8 +6369,6 @@ snapshots:
|
|||
|
||||
'@sindresorhus/is@4.6.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -6670,13 +6485,6 @@ snapshots:
|
|||
'@types/node': 22.19.19
|
||||
'@types/responselike': 1.0.3
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
|
@ -6751,61 +6559,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vite-pwa/assets-generator@1.0.2':
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
colorette: 2.0.20
|
||||
consola: 3.4.2
|
||||
sharp: 0.33.5
|
||||
sharp-ico: 0.1.5
|
||||
unconfig: 7.5.0
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))':
|
||||
dependencies:
|
||||
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
vue: 3.5.34(typescript@5.6.3)
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.9(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.9
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.9': {}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@volar/language-core@2.4.15':
|
||||
dependencies:
|
||||
'@volar/source-map': 2.4.15
|
||||
|
|
@ -7167,8 +6925,6 @@ snapshots:
|
|||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
async@3.2.6: {}
|
||||
|
|
@ -7274,8 +7030,6 @@ snapshots:
|
|||
dependencies:
|
||||
run-applescript: 7.1.0
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
cacache@16.1.3:
|
||||
dependencies:
|
||||
'@npmcli/fs': 2.1.2
|
||||
|
|
@ -7332,8 +7086,6 @@ snapshots:
|
|||
|
||||
caniuse-lite@1.0.30001793: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
|
@ -7441,8 +7193,6 @@ snapshots:
|
|||
tree-kill: 1.2.2
|
||||
yargs: 17.7.2
|
||||
|
||||
consola@3.4.2: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
|
|
@ -7537,17 +7287,6 @@ snapshots:
|
|||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decode-bmp@0.2.1:
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
to-data-view: 1.1.0
|
||||
|
||||
decode-ico@0.4.1:
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
decode-bmp: 0.2.1
|
||||
to-data-view: 1.1.0
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
|
|
@ -7870,10 +7609,6 @@ snapshots:
|
|||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eta@3.5.0: {}
|
||||
|
|
@ -7894,8 +7629,6 @@ snapshots:
|
|||
signal-exit: 3.0.7
|
||||
strip-eof: 1.0.0
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
|
||||
external-editor@3.1.0:
|
||||
|
|
@ -8240,8 +7973,6 @@ snapshots:
|
|||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
ico-endec@0.1.6: {}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
|
@ -8837,8 +8568,6 @@ snapshots:
|
|||
has-symbols: 1.1.0
|
||||
object-keys: 1.1.1
|
||||
|
||||
obug@2.1.3: {}
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
once@1.4.0:
|
||||
|
|
@ -8957,8 +8686,6 @@ snapshots:
|
|||
|
||||
pathe@1.1.2: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pe-library@1.0.1: {}
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
|
@ -9042,8 +8769,6 @@ snapshots:
|
|||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
quansync@1.0.0: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-lru@5.1.1: {}
|
||||
|
|
@ -9333,12 +9058,6 @@ snapshots:
|
|||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
sharp-ico@0.1.5:
|
||||
dependencies:
|
||||
decode-ico: 0.4.1
|
||||
ico-endec: 0.1.6
|
||||
sharp: 0.33.5
|
||||
|
||||
sharp@0.33.5:
|
||||
dependencies:
|
||||
color: 4.2.3
|
||||
|
|
@ -9407,8 +9126,6 @@ snapshots:
|
|||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@3.0.7: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
|
@ -9485,10 +9202,6 @@ snapshots:
|
|||
dependencies:
|
||||
minipass: 3.3.6
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
|
@ -9657,17 +9370,11 @@ snapshots:
|
|||
tiny-each-async@2.0.3:
|
||||
optional: true
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.2.4: {}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tmp-promise@3.0.3:
|
||||
dependencies:
|
||||
tmp: 0.2.5
|
||||
|
|
@ -9680,8 +9387,6 @@ snapshots:
|
|||
tmp@0.2.5:
|
||||
optional: true
|
||||
|
||||
to-data-view@1.1.0: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
|
@ -9757,19 +9462,6 @@ snapshots:
|
|||
has-symbols: 1.1.0
|
||||
which-boxed-primitive: 1.1.1
|
||||
|
||||
unconfig-core@7.5.0:
|
||||
dependencies:
|
||||
'@quansync/fs': 1.0.0
|
||||
quansync: 1.0.0
|
||||
|
||||
unconfig@7.5.0:
|
||||
dependencies:
|
||||
'@quansync/fs': 1.0.0
|
||||
defu: 6.1.7
|
||||
jiti: 2.7.0
|
||||
quansync: 1.0.0
|
||||
unconfig-core: 7.5.0
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||
|
|
@ -9851,7 +9543,7 @@ snapshots:
|
|||
- rollup
|
||||
- supports-color
|
||||
|
||||
vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@1.0.2)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1):
|
||||
vite-plugin-pwa@0.21.2(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
pretty-bytes: 6.1.1
|
||||
|
|
@ -9859,8 +9551,6 @@ snapshots:
|
|||
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
workbox-build: 7.4.1
|
||||
workbox-window: 7.4.1
|
||||
optionalDependencies:
|
||||
'@vite-pwa/assets-generator': 1.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -9879,33 +9569,6 @@ snapshots:
|
|||
lightningcss: 1.32.0
|
||||
terser: 5.48.0
|
||||
|
||||
vitest@4.1.9(@types/node@22.19.19)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.3
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.16
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.19
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.6.3)):
|
||||
|
|
@ -10068,11 +9731,6 @@ snapshots:
|
|||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
word-wrap@1.2.5:
|
||||
optional: true
|
||||
|
||||
|
|
|
|||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
public/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
3
public/mask-icon.svg
Normal 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 |
|
|
@ -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],
|
||||
})
|
||||
|
|
@ -6,8 +6,9 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Restaurant — Order</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Restaurant">
|
||||
<meta name="description" content="Order from your local Nostr-native restaurant with Lightning payments">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -78,12 +78,8 @@
|
|||
|
||||
<!-- Step 1: Revenue Account Selection -->
|
||||
<div v-if="currentStep === 1">
|
||||
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 mb-4 text-sm">
|
||||
<span class="text-muted-foreground">{{ t('libra.income.selectAccount') }}</span>
|
||||
<span class="inline-flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle class="h-4 w-4 shrink-0" />
|
||||
{{ t('libra.income.otherAccountHint') }}
|
||||
</span>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
{{ t('libra.income.selectAccount') }}
|
||||
</p>
|
||||
<AccountSelector
|
||||
v-model="selectedRevenueAccount"
|
||||
|
|
@ -100,16 +96,16 @@
|
|||
<FormLabel>Description *</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="A detailed description of the income source, and what it was for (event/project/etc)..."
|
||||
placeholder="e.g., Workshop fee, Donation, Service revenue"
|
||||
v-bind="componentField"
|
||||
rows="3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Describe the source of this income</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormField v-slot="{ componentField }" name="amount">
|
||||
<FormItem>
|
||||
<FormLabel>Amount *</FormLabel>
|
||||
|
|
@ -122,6 +118,7 @@
|
|||
step="0.01"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Amount in selected currency</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
|
@ -131,7 +128,7 @@
|
|||
<FormLabel>Currency *</FormLabel>
|
||||
<Select v-bind="componentField">
|
||||
<FormControl>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
|
@ -141,17 +138,18 @@
|
|||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Currency for this income</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="reference">
|
||||
<FormItem>
|
||||
<FormLabel>Reference (optional)</FormLabel>
|
||||
<FormLabel>Reference</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., Invoice #123, Receipt #456" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>Optional reference number or note</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
|
@ -203,6 +201,7 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
|
|
@ -222,7 +221,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { TrendingUp, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock, AlertTriangle } from 'lucide-vue-next'
|
||||
import { TrendingUp, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock } from 'lucide-vue-next'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
|
|
|||
|
|
@ -221,16 +221,3 @@
|
|||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Disable enter/exit animations on reka-ui overlays (dialog, sheet,
|
||||
* popover, dropdown, tooltip, …) app-wide. They animate via the
|
||||
* data-state open/closed attribute; zeroing the duration keeps the final
|
||||
* state but removes the motion (overlays appear/disappear instantly).
|
||||
* Pulse/spin loaders and CSS transitions (e.g. hovers, the favourite
|
||||
* heart pop) are unaffected.
|
||||
*/
|
||||
[data-state='open'],
|
||||
[data-state='closed'] {
|
||||
animation-duration: 0s !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,23 +4,24 @@ import { useRoute } from 'vue-router'
|
|||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import BottomNav, { type BottomTab } from './BottomNav.vue'
|
||||
import StandaloneMenu, { type SidebarNavItem } from './StandaloneMenu.vue'
|
||||
import HubPill from './HubPill.vue'
|
||||
|
||||
interface Props {
|
||||
/** App-specific tabs displayed before the constant Profile entry. */
|
||||
tabs: BottomTab[]
|
||||
/** Active-tab matcher. Forwarded to BottomNav. */
|
||||
isActive: (path: string) => boolean
|
||||
/** Hide the top-right standalone menu — only true when this shell is
|
||||
* rendering the hub itself. Standalones leave this false (default). */
|
||||
/** Hide the top-right HubPill — only true when this shell is rendering
|
||||
* the hub itself. Standalones leave this false (default). */
|
||||
hideHub?: boolean
|
||||
/** App-specific nav items rendered at the top of the standalone menu. */
|
||||
sidebarNav?: SidebarNavItem[]
|
||||
/** Forwarded to BottomNav. Hub passes true so logged-out users can still
|
||||
* reach prefs from the sheet. Standalones leave it false. */
|
||||
loggedOutOpensSheet?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hideHub: false,
|
||||
sidebarNav: () => [],
|
||||
loggedOutOpensSheet: false,
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
|
@ -44,13 +45,11 @@ const isLoginPage = computed(() => route.path === '/login')
|
|||
v-if="!isLoginPage"
|
||||
:tabs="props.tabs"
|
||||
:is-active="props.isActive"
|
||||
:logged-out-opens-sheet="props.loggedOutOpensSheet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StandaloneMenu
|
||||
v-if="!props.hideHub && !isLoginPage"
|
||||
:items="props.sidebarNav"
|
||||
/>
|
||||
<HubPill v-if="!props.hideHub && !isLoginPage" />
|
||||
<Toaster />
|
||||
|
||||
<!-- Default slot for shell-level overlays (dialogs, sheets, etc.) that
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const isActive = (href: string) => {
|
|||
<div class="flex h-16 shrink-0 items-center">
|
||||
<router-link to="/" class="flex items-center gap-2">
|
||||
<img
|
||||
src="@brand-app-logo"
|
||||
src="@/assets/logo.png"
|
||||
alt="Logo"
|
||||
class="h-8 w-8"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Component } from 'vue'
|
||||
import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
|
||||
|
||||
export interface BottomTab {
|
||||
/** Translated label shown under the icon. */
|
||||
|
|
@ -17,11 +18,6 @@ export interface BottomTab {
|
|||
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
|
||||
* for auth-required tabs when the user is logged out. */
|
||||
disabled?: boolean
|
||||
/** Per-tab active-state override for entries whose active condition
|
||||
* doesn't reduce to "current route starts with this.path" — e.g. a
|
||||
* "Hosting" tab that is active when a feed-filter ref is on. When
|
||||
* set it wins over the App-level `isActive(path)` matcher. */
|
||||
isActive?: () => boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -29,9 +25,13 @@ interface Props {
|
|||
/** Active-tab matcher. Each app has its own nesting rules so we don't try
|
||||
* to derive a one-size-fits-all default — consumer supplies the function. */
|
||||
isActive: (path: string) => boolean
|
||||
/** When true (Hub), the unauthenticated profile button still opens the
|
||||
* sheet so logged-out users can change theme/lang. When false (standalones),
|
||||
* unauth profile button routes straight to /login. */
|
||||
loggedOutOpensSheet?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: false })
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
|
@ -42,11 +42,6 @@ function onTabClick(tab: BottomTab) {
|
|||
}
|
||||
if (tab.path) router.push(tab.path)
|
||||
}
|
||||
|
||||
function isTabActive(tab: BottomTab): boolean {
|
||||
if (tab.isActive) return tab.isActive()
|
||||
return !!tab.path && props.isActive(tab.path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -61,12 +56,12 @@ function isTabActive(tab: BottomTab): boolean {
|
|||
:key="tab.name"
|
||||
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="[
|
||||
isTabActive(tab)
|
||||
tab.path && props.isActive(tab.path)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
tab.disabled ? 'opacity-50' : '',
|
||||
]"
|
||||
:aria-current="isTabActive(tab) ? 'page' : undefined"
|
||||
:aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
|
||||
@click="onTabClick(tab)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
|
|
@ -78,6 +73,10 @@ function isTabActive(tab: BottomTab): boolean {
|
|||
{{ tab.badge > 99 ? '99+' : tab.badge }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Always-on Profile entry, appended on the right. Consumers don't
|
||||
pass it; the shell owns it so it's identical across every app. -->
|
||||
<ProfileSheetTrigger :logged-out-opens-sheet="props.loggedOutOpensSheet" />
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
|
|
|||
24
src/components/layout/HubPill.vue
Normal 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>
|
||||
|
|
@ -77,7 +77,7 @@ const navigateTo = (href: string) => {
|
|||
<SheetHeader class="px-6 py-4 border-b border-border">
|
||||
<SheetTitle class="flex items-center gap-2">
|
||||
<img
|
||||
src="@brand-app-logo"
|
||||
src="@/assets/logo.png"
|
||||
alt="Logo"
|
||||
class="h-8 w-8"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,116 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, Copy, LogIn, LogOut, Pencil, Zap } from 'lucide-vue-next'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Home, LogIn } from 'lucide-vue-next'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
||||
import { toastService } from '@/core/services/ToastService'
|
||||
import PreferencesRow from './PreferencesRow.vue'
|
||||
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, user, logout } = useAuth()
|
||||
const { isAuthenticated, user } = useAuth()
|
||||
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
||||
|
||||
const lightningDomain = computed(
|
||||
() => import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname,
|
||||
)
|
||||
|
||||
// Lightning Address and NIP-05 share the same identifier in this app —
|
||||
// both are `username@domain`. The `@username` row above the identity
|
||||
// card already signals NIP-05, so this row is labeled just "Lightning".
|
||||
const lightningAddress = computed(() => {
|
||||
const username = user.value?.username
|
||||
if (!username) return ''
|
||||
return `${username}@${lightningDomain.value}`
|
||||
})
|
||||
|
||||
const npub = computed(() => {
|
||||
const npubPreview = computed(() => {
|
||||
const pubkey = user.value?.pubkey
|
||||
if (!pubkey) return ''
|
||||
try {
|
||||
return nip19.npubEncode(pubkey)
|
||||
} catch {
|
||||
return pubkey
|
||||
}
|
||||
})
|
||||
|
||||
const npubPreview = computed(() => {
|
||||
const value = npub.value
|
||||
if (!value) return ''
|
||||
return value.length > 24 ? `${value.slice(0, 12)}…${value.slice(-8)}` : value
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`
|
||||
})
|
||||
|
||||
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
|
||||
|
||||
const copiedField = ref<string | null>(null)
|
||||
async function copyToClipboard(text: string, field: string) {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copiedField.value = field
|
||||
toastService.success(t('common.nav.copied', 'Copied to clipboard'))
|
||||
setTimeout(() => {
|
||||
if (copiedField.value === field) copiedField.value = null
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err)
|
||||
toastService.error(t('common.nav.copyFailed', 'Failed to copy'))
|
||||
}
|
||||
}
|
||||
|
||||
const editProfileOpen = ref(false)
|
||||
|
||||
function goLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await logout()
|
||||
toastService.success(t('common.nav.loggedOut', 'Logged out'))
|
||||
router.push('/login')
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to log out'
|
||||
toastService.error(`${t('common.nav.logoutFailed', 'Logout failed')}: ${msg}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Fill the sheet exactly (h-full) so the footer stays stuck to the
|
||||
bottom while only the region above it scrolls. The sheet host already
|
||||
has overflow-y-auto, but with an exact-fit child it never triggers —
|
||||
the inner flex-1 region owns the scroll instead. -->
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Scrollable region: everything above the pinned footer. min-h-0 lets
|
||||
this flex child shrink below its content height so it can scroll. -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
||||
<SheetDescription v-if="isAuthenticated">
|
||||
|
|
@ -121,157 +41,46 @@ async function onLogout() {
|
|||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<!-- Identity card (logged in) — summary with an inline edit (pencil)
|
||||
button that opens the profile form. -->
|
||||
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar class="h-12 w-12 shrink-0">
|
||||
<!-- Identity card (logged in) -->
|
||||
<div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
|
||||
<Avatar class="h-12 w-12">
|
||||
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
||||
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
|
||||
<p v-if="displayName && user?.username" class="text-xs text-muted-foreground truncate">
|
||||
@{{ user.username }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 self-start text-muted-foreground"
|
||||
:aria-label="t('common.nav.editProfile', 'Edit profile')"
|
||||
@click="editProfileOpen = true"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Identifier rows: full-width value with a corner-offset "legend"
|
||||
badge straddling the top border (fieldset-legend pattern). The
|
||||
value gets the entire row so long bech32 / username@domain
|
||||
strings have room to render. -->
|
||||
<div class="space-y-3 pt-1">
|
||||
<!-- Lightning Address — this is also the NIP-05 in this stack,
|
||||
but the @username above already signals the NIP-05. -->
|
||||
<button
|
||||
v-if="lightningAddress"
|
||||
type="button"
|
||||
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
|
||||
:aria-label="t('common.nav.copyLightning', 'Copy Lightning address')"
|
||||
@click="copyToClipboard(lightningAddress, 'lightning')"
|
||||
>
|
||||
<span class="absolute -top-2 left-2 inline-flex items-center gap-1 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
|
||||
<Zap class="w-3 h-3 text-yellow-500 fill-yellow-500" />
|
||||
{{ t('common.nav.lightning', 'Lightning') }}
|
||||
</span>
|
||||
<span class="block truncate pr-6 text-xs font-mono">{{ lightningAddress }}</span>
|
||||
<component
|
||||
:is="copiedField === 'lightning' ? Check : Copy"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- npub — copy the full bech32 even though we display a preview. -->
|
||||
<button
|
||||
v-if="npub"
|
||||
type="button"
|
||||
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
|
||||
:aria-label="t('common.nav.copyNpub', 'Copy npub')"
|
||||
@click="copyToClipboard(npub, 'npub')"
|
||||
>
|
||||
<span class="absolute -top-2 left-2 inline-flex items-center rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
|
||||
{{ t('common.nav.npub', 'npub') }}
|
||||
</span>
|
||||
<span class="block truncate pr-6 text-xs font-mono">{{ npubPreview }}</span>
|
||||
<component
|
||||
:is="copiedField === 'npub' ? Check : Copy"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App-specific nav items (rendered by callers like StandaloneMenu) -->
|
||||
<slot name="app-nav" />
|
||||
|
||||
<!-- Cross-app links + global preferences (always visible, auth or not) -->
|
||||
<div class="mt-4">
|
||||
<PreferencesRow layout="list" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pinned footer: stays stuck to the bottom of the sheet (shrink-0);
|
||||
"Back to hub" sits directly above the log-in/out bar. -->
|
||||
<div class="shrink-0 pt-1">
|
||||
<!-- "Back to hub" shows the HUB's brand-kit logo (the brand's
|
||||
primary/global logo via @brand-hub-logo) — NOT the per-standalone
|
||||
@brand-app-logo, which resolves to this standalone's own logo. -->
|
||||
<a
|
||||
:href="hubRootUrl"
|
||||
class="flex items-center justify-center gap-3 px-3 py-1.5 hover:bg-accent rounded-md transition-colors"
|
||||
class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors"
|
||||
:aria-label="t('common.nav.backToHub')"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Logged-out: prominent log-in CTA -->
|
||||
<div v-if="!isAuthenticated">
|
||||
<Separator class="mb-2" />
|
||||
<PreferencesRow layout="list" />
|
||||
</div>
|
||||
|
||||
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings -->
|
||||
<div v-if="!isAuthenticated" class="mt-6">
|
||||
<Separator class="mb-4" />
|
||||
<Button class="w-full" @click="goLogin">
|
||||
<LogIn class="mr-2 h-4 w-4" />
|
||||
{{ t('common.nav.login') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Logged-in: log-out button stays visible without opening the edit popup. -->
|
||||
<div v-else>
|
||||
<Separator class="mb-2" />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" class="w-full">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
{{ t('common.nav.logOut', 'Log out') }}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Log out of {{ user?.username || 'your account' }}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ t('common.nav.logOutConfirmDescription', "You'll need to sign in again to access your wallet, post in the forum, place orders, or use any feature that needs your account.") }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{{ t('common.nav.cancel', 'Cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
@click="onLogout"
|
||||
>
|
||||
{{ t('common.nav.logOut', 'Log out') }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Logged-in: full profile management form -->
|
||||
<div v-else class="mt-6">
|
||||
<Separator class="mb-4" />
|
||||
<ProfileSettings />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const open = ref(false)
|
|||
</template>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto overflow-x-hidden">
|
||||
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
|
||||
<ProfileSheetContent />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -21,28 +21,10 @@ export const PALETTES: Palette[] = [
|
|||
'starrynight',
|
||||
]
|
||||
|
||||
// The palette styled by the bare `:root` in index.css (no `data-theme`
|
||||
// attribute). This is a fixed invariant of the CSS, NOT the configurable
|
||||
// default — applyPalette() removes the attribute only for this one.
|
||||
const BASE_PALETTE: Palette = 'catppuccin'
|
||||
|
||||
// Brand-configurable initial defaults, surfaced from brand.json via
|
||||
// vite-branding.ts (VITE_BRAND_THEME / VITE_BRAND_PALETTE). These set the
|
||||
// first-load value when the user has no saved preference; a stored choice
|
||||
// always overrides (see onMounted). Invalid/unset values fall back to the
|
||||
// app's built-ins ('dark' + catppuccin).
|
||||
const BRAND_THEME = (import.meta.env.VITE_BRAND_THEME as string) || ''
|
||||
const BRAND_PALETTE = (import.meta.env.VITE_BRAND_PALETTE as string) || ''
|
||||
const DEFAULT_THEME: Theme =
|
||||
BRAND_THEME === 'dark' || BRAND_THEME === 'light' || BRAND_THEME === 'system'
|
||||
? BRAND_THEME
|
||||
: 'dark'
|
||||
const DEFAULT_PALETTE: Palette = (PALETTES as string[]).includes(BRAND_PALETTE)
|
||||
? (BRAND_PALETTE as Palette)
|
||||
: BASE_PALETTE
|
||||
const DEFAULT_PALETTE: Palette = 'catppuccin'
|
||||
|
||||
const useTheme = () => {
|
||||
const theme = ref<Theme>(DEFAULT_THEME)
|
||||
const theme = ref<Theme>('dark')
|
||||
const systemTheme = ref<'dark' | 'light'>('light')
|
||||
const palette = ref<Palette>(DEFAULT_PALETTE)
|
||||
|
||||
|
|
@ -63,7 +45,7 @@ const useTheme = () => {
|
|||
}
|
||||
|
||||
const applyPalette = () => {
|
||||
if (palette.value === BASE_PALETTE) {
|
||||
if (palette.value === DEFAULT_PALETTE) {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', palette.value)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export { default as AvatarFallback } from './AvatarFallback.vue'
|
|||
export { default as AvatarImage } from './AvatarImage.vue'
|
||||
|
||||
export const avatarVariant = cva(
|
||||
'inline-flex items-center justify-center font-normal text-secondary-foreground select-none shrink-0 bg-secondary overflow-hidden',
|
||||
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { computed } from 'vue'
|
|||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Home, Map, Heart, Ticket, Megaphone } from 'lucide-vue-next'
|
||||
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
|
|
@ -23,72 +23,37 @@ const eventsStore = useEventsStore()
|
|||
const { isAdmin, autoApprove } = useApprovalState()
|
||||
// Used to merge own LNbits drafts into the events feed right after
|
||||
// the user creates or edits an event — otherwise the new draft only
|
||||
// surfaces on the next EventsPage subscribe cycle. `onlyHosting`
|
||||
// is the feed filter that backs the Hosting bottom-nav tab — tapping
|
||||
// it toggles the filter on; Home tab toggles it off.
|
||||
const { loadOwnEvents, onlyHosting, toggleHosting } = useEvents()
|
||||
|
||||
// True for /events and its sub-routes (incl. detail pages) but
|
||||
// not for the routes owned by other tabs (map/favorites). Used by
|
||||
// both Home and Hosting active-state predicates so the highlight
|
||||
// only shifts based on the onlyHosting flag while you're in the feed.
|
||||
function inFeedRoute(): boolean {
|
||||
if (route.path.startsWith('/events/map')) return false
|
||||
if (route.path.startsWith('/events/favorites')) return false
|
||||
return route.path === '/events' || route.path.startsWith('/events/')
|
||||
}
|
||||
// surfaces on the next EventsPage subscribe cycle.
|
||||
const { loadOwnEvents } = useEvents()
|
||||
|
||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||
// Create lives in the bottom nav: when logged out, tapping it shows an
|
||||
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
|
||||
// opening the dialog. Per-app placement deliberation tracked at #53.
|
||||
const tabs = computed<BottomTab[]>(() => [
|
||||
{ name: t('events.nav.feed'), icon: Search, path: '/events' },
|
||||
{ name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' },
|
||||
{
|
||||
name: t('events.nav.feed'),
|
||||
icon: Home,
|
||||
name: t('events.createNew'),
|
||||
icon: Plus,
|
||||
onClick: () => {
|
||||
// Tapping Home clears the hosting filter so the feed always
|
||||
// returns to the unfiltered view, regardless of where the
|
||||
// user just came from.
|
||||
if (onlyHosting.value) toggleHosting()
|
||||
if (route.path !== '/events') router.push('/events')
|
||||
if (!isAuthenticated.value) {
|
||||
toast.info('Log in to create an event', {
|
||||
action: {
|
||||
label: 'Log in',
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
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.filters.myTickets'),
|
||||
icon: Ticket,
|
||||
path: '/my-tickets',
|
||||
onClick: () => {
|
||||
if (!isAuthenticated.value) {
|
||||
toast.info(t('events.detail.loginToBuyTickets'), {
|
||||
action: {
|
||||
label: t('events.detail.logIn'),
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
router.push('/my-tickets')
|
||||
},
|
||||
disabled: !isAuthenticated.value,
|
||||
},
|
||||
{
|
||||
name: t('events.filters.hosting'),
|
||||
icon: Megaphone,
|
||||
onClick: () => {
|
||||
if (!isAuthenticated.value) {
|
||||
toast.info(t('events.hosting.loginPrompt', 'Log in to manage your hosted events'), {
|
||||
action: {
|
||||
label: t('events.favorites.logIn'),
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!onlyHosting.value) toggleHosting()
|
||||
if (route.path !== '/events') router.push('/events')
|
||||
},
|
||||
isActive: () => inFeedRoute() && onlyHosting.value,
|
||||
disabled: !isAuthenticated.value,
|
||||
},
|
||||
{
|
||||
name: t('events.nav.favorites'),
|
||||
icon: Heart,
|
||||
|
|
@ -112,8 +77,18 @@ const tabs = computed<BottomTab[]>(() => [
|
|||
},
|
||||
])
|
||||
|
||||
// Path-based fallback for tabs that don't carry their own `isActive`.
|
||||
// Feed tab is active for the bare /events route AND all sub-paths that
|
||||
// aren't owned by another tab (e.g. /events/<id> detail pages).
|
||||
function isActive(path: string): boolean {
|
||||
if (path === '/events') {
|
||||
return (
|
||||
route.path === '/events' ||
|
||||
(route.path.startsWith('/events/') &&
|
||||
!route.path.startsWith('/events/calendar') &&
|
||||
!route.path.startsWith('/events/map') &&
|
||||
!route.path.startsWith('/events/favorites'))
|
||||
)
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ const messages: LocaleMessages = {
|
|||
profileDescription: 'Your Nostr identity and display name.',
|
||||
profileLoggedOutDescription: 'Sign in or change your preferences.',
|
||||
login: 'Log in',
|
||||
menu: 'Menu',
|
||||
backToHub: 'Back to hub',
|
||||
hub: 'Hub',
|
||||
theme: 'Theme',
|
||||
|
|
@ -69,10 +68,6 @@ const messages: LocaleMessages = {
|
|||
hosting: 'Hosting',
|
||||
pastEvents: 'Past events',
|
||||
past: 'Past',
|
||||
filters: 'Filters',
|
||||
clearAll: 'Clear all',
|
||||
filteringBy: 'Filtering by:',
|
||||
removeCategory: 'Remove {category} filter',
|
||||
},
|
||||
categories: {
|
||||
concert: 'Concert',
|
||||
|
|
@ -103,13 +98,15 @@ const messages: LocaleMessages = {
|
|||
},
|
||||
detail: {
|
||||
getTicket: 'Get Ticket',
|
||||
going: 'Going',
|
||||
maybe: 'Maybe',
|
||||
notGoing: 'Not Going',
|
||||
contactOrganizer: 'Contact Organizer',
|
||||
organizer: 'Organizer',
|
||||
location: 'Location',
|
||||
when: 'When',
|
||||
tickets: 'Tickets',
|
||||
ticketsAvailable: '{count} tickets available',
|
||||
ticketsRemainingOfTotal: '{count} of {total} tickets left',
|
||||
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
||||
unlimitedTickets: 'Unlimited tickets',
|
||||
buyTicket: 'Buy ticket',
|
||||
|
|
@ -130,7 +127,7 @@ const messages: LocaleMessages = {
|
|||
registered: 'Registered',
|
||||
},
|
||||
nav: {
|
||||
feed: 'Home',
|
||||
feed: 'Feed',
|
||||
calendar: 'Calendar',
|
||||
map: 'Map',
|
||||
favorites: 'Favorites',
|
||||
|
|
@ -202,8 +199,7 @@ const messages: LocaleMessages = {
|
|||
income: {
|
||||
title: 'Add Income',
|
||||
description: 'Submit income for the organization',
|
||||
selectAccount: 'Select the revenue account.',
|
||||
otherAccountHint: 'Use the "Other" account if you\'re not sure.',
|
||||
selectAccount: 'Select the revenue account',
|
||||
submitIncome: 'Submit Income',
|
||||
notAvailable: 'Income submission is not yet available. This feature is coming soon.',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ const messages: LocaleMessages = {
|
|||
profileDescription: 'Tu identidad Nostr y nombre de visualización.',
|
||||
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
|
||||
login: 'Iniciar sesión',
|
||||
menu: 'Menú',
|
||||
backToHub: 'Volver al hub',
|
||||
hub: 'Hub',
|
||||
theme: 'Tema',
|
||||
|
|
@ -69,10 +68,6 @@ const messages: LocaleMessages = {
|
|||
hosting: 'Organizo',
|
||||
pastEvents: 'Eventos pasados',
|
||||
past: 'Pasado',
|
||||
filters: 'Filtros',
|
||||
clearAll: 'Limpiar todo',
|
||||
filteringBy: 'Filtrando por:',
|
||||
removeCategory: 'Quitar el filtro {category}',
|
||||
},
|
||||
categories: {
|
||||
concert: 'Concierto',
|
||||
|
|
@ -103,13 +98,15 @@ const messages: LocaleMessages = {
|
|||
},
|
||||
detail: {
|
||||
getTicket: 'Obtener boleto',
|
||||
going: 'Voy',
|
||||
maybe: 'Tal vez',
|
||||
notGoing: 'No voy',
|
||||
contactOrganizer: 'Contactar organizador',
|
||||
organizer: 'Organizador',
|
||||
location: 'Ubicación',
|
||||
when: 'Cuándo',
|
||||
tickets: 'Boletos',
|
||||
ticketsAvailable: '{count} boletos disponibles',
|
||||
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
|
||||
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
||||
unlimitedTickets: 'Boletos ilimitados',
|
||||
buyTicket: 'Comprar boleto',
|
||||
|
|
@ -202,8 +199,7 @@ const messages: LocaleMessages = {
|
|||
income: {
|
||||
title: 'A\u00f1adir ingreso',
|
||||
description: 'Enviar un ingreso para la organizaci\u00f3n',
|
||||
selectAccount: 'Seleccionar la cuenta de ingresos.',
|
||||
otherAccountHint: 'Use la cuenta "Other" si no está seguro.',
|
||||
selectAccount: 'Seleccionar la cuenta de ingresos',
|
||||
submitIncome: 'Enviar ingreso',
|
||||
notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ const messages: LocaleMessages = {
|
|||
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
|
||||
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
|
||||
login: 'Se connecter',
|
||||
menu: 'Menu',
|
||||
backToHub: 'Retour au hub',
|
||||
hub: 'Hub',
|
||||
theme: 'Thème',
|
||||
|
|
@ -69,10 +68,6 @@ const messages: LocaleMessages = {
|
|||
hosting: 'J\'organise',
|
||||
pastEvents: 'Événements passés',
|
||||
past: 'Passé',
|
||||
filters: 'Filtres',
|
||||
clearAll: 'Tout effacer',
|
||||
filteringBy: 'Filtré par :',
|
||||
removeCategory: 'Retirer le filtre {category}',
|
||||
},
|
||||
categories: {
|
||||
concert: 'Concert',
|
||||
|
|
@ -103,13 +98,15 @@ const messages: LocaleMessages = {
|
|||
},
|
||||
detail: {
|
||||
getTicket: 'Obtenir un billet',
|
||||
going: 'Présent',
|
||||
maybe: 'Peut-être',
|
||||
notGoing: 'Absent',
|
||||
contactOrganizer: "Contacter l'organisateur",
|
||||
organizer: 'Organisateur',
|
||||
location: 'Lieu',
|
||||
when: 'Quand',
|
||||
tickets: 'Billets',
|
||||
ticketsAvailable: '{count} billets disponibles',
|
||||
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
|
||||
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
||||
unlimitedTickets: 'Billets illimités',
|
||||
buyTicket: 'Acheter un billet',
|
||||
|
|
@ -130,7 +127,7 @@ const messages: LocaleMessages = {
|
|||
registered: 'Enregistré',
|
||||
},
|
||||
nav: {
|
||||
feed: 'Accueil',
|
||||
feed: 'Fil',
|
||||
calendar: 'Calendrier',
|
||||
map: 'Carte',
|
||||
favorites: 'Favoris',
|
||||
|
|
@ -202,8 +199,7 @@ const messages: LocaleMessages = {
|
|||
income: {
|
||||
title: 'Ajouter un revenu',
|
||||
description: 'Soumettre un revenu pour l\u2019organisation',
|
||||
selectAccount: 'S\u00e9lectionner le compte de revenus.',
|
||||
otherAccountHint: 'Utilisez le compte "Other" si vous n\'\u00eates pas s\u00fbr.',
|
||||
selectAccount: 'S\u00e9lectionner le compte de revenus',
|
||||
submitIncome: 'Soumettre le revenu',
|
||||
notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export interface LocaleMessages {
|
|||
profileDescription: string
|
||||
profileLoggedOutDescription: string
|
||||
login: string
|
||||
menu: string
|
||||
backToHub: string
|
||||
hub: string
|
||||
theme: string
|
||||
|
|
@ -70,21 +69,19 @@ export interface LocaleMessages {
|
|||
hosting: string
|
||||
pastEvents: string
|
||||
past: string
|
||||
filters: string
|
||||
clearAll: string
|
||||
filteringBy: string
|
||||
removeCategory: string
|
||||
}
|
||||
categories: Record<string, string>
|
||||
detail: {
|
||||
getTicket: string
|
||||
going: string
|
||||
maybe: string
|
||||
notGoing: string
|
||||
contactOrganizer: string
|
||||
organizer: string
|
||||
location: string
|
||||
when: string
|
||||
tickets: string
|
||||
ticketsAvailable: string
|
||||
ticketsRemainingOfTotal: string
|
||||
ticketsOwned: string
|
||||
unlimitedTickets: string
|
||||
buyTicket: string
|
||||
|
|
@ -179,7 +176,6 @@ export interface LocaleMessages {
|
|||
title: string
|
||||
description: string
|
||||
selectAccount: string
|
||||
otherAccountHint: string
|
||||
submitIncome: string
|
||||
notAvailable: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,5 +1,14 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Profile Settings</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Manage your profile information and Nostr identity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<form @submit="onSubmit" class="space-y-6">
|
||||
<!-- Profile Picture -->
|
||||
<FormField name="picture">
|
||||
|
|
@ -8,26 +17,22 @@
|
|||
<FormDescription>
|
||||
Upload a profile picture. This will be published to your Nostr profile.
|
||||
</FormDescription>
|
||||
<!-- Stack preview + upload on narrow viewports so the upload
|
||||
component's Gallery/Camera buttons don't push out of the
|
||||
sheet/dialog. -->
|
||||
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Current picture preview -->
|
||||
<div v-if="currentPictureUrl" class="relative shrink-0">
|
||||
<div v-if="currentPictureUrl" class="relative">
|
||||
<img
|
||||
:src="currentPictureUrl"
|
||||
alt="Profile picture"
|
||||
class="h-20 w-20 rounded-full object-cover border-2 border-border"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border shrink-0">
|
||||
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border">
|
||||
<User class="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- Upload component. Avatars are small; tighten the
|
||||
default compress knobs so a 4K phone photo lands as
|
||||
a ~200 KB 512px WebP. -->
|
||||
<div class="flex-1 min-w-0 w-full">
|
||||
<ImageUpload
|
||||
v-model="uploadedPicture"
|
||||
:multiple="false"
|
||||
|
|
@ -40,7 +45,6 @@
|
|||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
|
@ -87,6 +91,26 @@
|
|||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Lightning Address / NIP-05 (read-only info) -->
|
||||
<div class="rounded-lg border p-4 space-y-2 bg-muted/50">
|
||||
<h4 class="text-sm font-medium">Nostr Identity</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<Zap class="h-4 w-4 text-yellow-500" />
|
||||
<span class="text-muted-foreground">Lightning Address:</span>
|
||||
<code class="text-xs bg-background px-2 py-1 rounded">{{ lightningAddress }}</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Hash class="h-4 w-4 text-purple-500" />
|
||||
<span class="text-muted-foreground">NIP-05:</span>
|
||||
<code class="text-xs bg-background px-2 py-1 rounded">{{ nip05Preview }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
These identifiers are automatically derived from your username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="updateError" class="text-sm text-destructive">
|
||||
{{ updateError }}
|
||||
|
|
@ -111,6 +135,34 @@
|
|||
Your profile is broadcast to Nostr automatically when you save changes.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" class="w-full">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Log out of {{ user?.username || 'your account' }}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You'll need to sign in again to access your wallet, post in the
|
||||
forum, place orders, or use any feature that needs your account.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="onLogout" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Log out
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -121,7 +173,8 @@ import { toTypedSchema } from '@vee-validate/zod'
|
|||
import * as z from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { User } from 'lucide-vue-next'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { User, Zap, Hash } from 'lucide-vue-next'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
|
|
@ -131,13 +184,27 @@ import {
|
|||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import ImageUpload from './ImageUpload.vue'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { LogOut } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ImageUploadService } from '../services/ImageUploadService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
||||
// Services
|
||||
const { user, updateProfile } = useAuth()
|
||||
const { user, updateProfile, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||
const toast = useToast()
|
||||
|
||||
|
|
@ -157,14 +224,14 @@ const lightningDomain = computed(() =>
|
|||
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
|
||||
)
|
||||
|
||||
// Live preview of the user's NIP-05 / Lightning address — shown in the
|
||||
// username field's helper text so the consequence of a future rename is
|
||||
// visible inline.
|
||||
// Computed previews
|
||||
const nip05Preview = computed(() => {
|
||||
const username = form.values.username || currentUsername.value || 'username'
|
||||
return `${username}@${lightningDomain.value}`
|
||||
})
|
||||
|
||||
const lightningAddress = computed(() => nip05Preview.value)
|
||||
|
||||
// Form schema
|
||||
const profileFormSchema = toTypedSchema(z.object({
|
||||
username: z.string()
|
||||
|
|
@ -260,4 +327,17 @@ const updateUserProfile = async (formData: any) => {
|
|||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Log out + redirect to /login on this app's origin.
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
toast.success('Logged out')
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to log out'
|
||||
console.error('Error logging out:', error)
|
||||
toast.error(`Logout failed: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Heart } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useBookmarks } from '../composables/useBookmarks'
|
||||
import { useEventLikes } from '../composables/useEventLikes'
|
||||
import { NIP52_KINDS } from '../types/nip52'
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -16,56 +15,13 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, user } = useAuth()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { isBookmarked, toggleBookmark } = useBookmarks()
|
||||
const { track, likeCount, setSelf } = useEventLikes()
|
||||
|
||||
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||
const coord = computed(() => `${eventKind.value}:${props.pubkey}:${props.dTag}`)
|
||||
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
|
||||
|
||||
// Live count of how many people have favorited (liked) this event.
|
||||
const count = computed(() => likeCount(coord.value))
|
||||
|
||||
// Register this event so its like count is fetched + kept live.
|
||||
// `ready` gates the live-increment pop so the historical backlog that
|
||||
// streams in right after mount doesn't make every heart pop on load.
|
||||
const ready = ref(false)
|
||||
onMounted(() => {
|
||||
track(coord.value)
|
||||
setTimeout(() => (ready.value = true), 1500)
|
||||
})
|
||||
|
||||
// Keep the current user's own contribution in sync with the optimistic
|
||||
// heart state — instant like/un-like for self, and rollback-safe.
|
||||
watch(
|
||||
bookmarked,
|
||||
(now) => {
|
||||
const pk = user.value?.pubkey
|
||||
if (pk) setSelf(coord.value, pk, now)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Brief scale "pop" for tactile feedback.
|
||||
const popping = ref(false)
|
||||
function pop() {
|
||||
popping.value = true
|
||||
setTimeout(() => (popping.value = false), 220)
|
||||
}
|
||||
|
||||
// Pop on the user's own favorite (optimistic, fires immediately on tap).
|
||||
watch(bookmarked, (now, was) => {
|
||||
if (now && !was) pop()
|
||||
})
|
||||
|
||||
// Pop when the live count ticks up from someone else liking it too —
|
||||
// only once past the initial historical-load settle window.
|
||||
watch(count, (now, was) => {
|
||||
if (ready.value && now > was) pop()
|
||||
})
|
||||
|
||||
async function handleToggle() {
|
||||
function handleToggle() {
|
||||
if (!isAuthenticated.value) {
|
||||
toast.info('Log in to save favorites', {
|
||||
action: {
|
||||
|
|
@ -75,26 +31,18 @@ async function handleToggle() {
|
|||
})
|
||||
return
|
||||
}
|
||||
const ok = await toggleBookmark(eventKind.value, props.pubkey, props.dTag)
|
||||
if (!ok) {
|
||||
toast.error("Couldn't save favorite — please try again")
|
||||
}
|
||||
toggleBookmark(eventKind.value, props.pubkey, props.dTag)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1 px-2"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-label="bookmarked ? 'Remove favorite' : 'Add favorite'"
|
||||
@click.stop="handleToggle"
|
||||
>
|
||||
<Heart
|
||||
class="w-4 h-4 transition-transform duration-200 ease-out"
|
||||
:class="[{ 'fill-current': bookmarked }, popping ? 'scale-125' : 'scale-100']"
|
||||
/>
|
||||
<span v-if="count > 0" class="text-xs font-medium tabular-nums">{{ count }}</span>
|
||||
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
|
||||
</Button>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -432,7 +432,7 @@ const handleOpenChange = (open: boolean) => {
|
|||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="handleOpenChange">
|
||||
<DialogContent class="max-w-lg max-h-[90vh] p-0 overflow-x-hidden">
|
||||
<DialogContent class="max-w-lg max-h-[90vh] p-0">
|
||||
<DialogHeader class="px-6 pt-6 pb-2">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Calendar class="w-5 h-5" />
|
||||
|
|
@ -611,13 +611,9 @@ const handleOpenChange = (open: boolean) => {
|
|||
fiat amounts convert at checkout using current rates.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Pricing grid: 2 cols on narrow phones (Tickets/Price share
|
||||
a row, Currency takes its own), 3 cols once we have the
|
||||
breathing room. Plain `grid-cols-3` was overflowing the
|
||||
dialog on 360px-class viewports. -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<FormField v-slot="{ componentField }" name="amount_tickets">
|
||||
<FormItem class="min-w-0">
|
||||
<FormItem>
|
||||
<FormLabel>Tickets</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
|
||||
|
|
@ -628,7 +624,7 @@ const handleOpenChange = (open: boolean) => {
|
|||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="price_per_ticket">
|
||||
<FormItem class="min-w-0">
|
||||
<FormItem>
|
||||
<FormLabel>Price</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
|
||||
|
|
@ -639,11 +635,11 @@ const handleOpenChange = (open: boolean) => {
|
|||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="currency">
|
||||
<FormItem class="col-span-2 sm:col-span-1 min-w-0">
|
||||
<FormItem>
|
||||
<FormLabel>Price currency</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="sat" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -743,26 +739,12 @@ const handleOpenChange = (open: boolean) => {
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Actions. Stack full-width on narrow viewports so a wide
|
||||
localized "Submit Event" label can't push the Cancel
|
||||
button out of the dialog. Submit reads top-down on
|
||||
mobile (flex-col-reverse) to keep the primary action
|
||||
under the thumb. -->
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto"
|
||||
@click="handleOpenChange(false)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full sm:w-auto"
|
||||
:disabled="isLoading || !isFormValid"
|
||||
>
|
||||
<Button type="submit" :disabled="isLoading || !isFormValid">
|
||||
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
|
||||
{{
|
||||
isLoading
|
||||
|
|
|
|||
73
src/modules/events/components/DatePickerStrip.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -12,10 +12,6 @@ import type { Event } from '../types/event'
|
|||
|
||||
const props = defineProps<{
|
||||
events: Event[]
|
||||
/** When true, render only the month grid for date-picking — no
|
||||
* selected-day events panel — and emit selectDate on every day tap
|
||||
* (used inside the calendar popup). */
|
||||
pickerMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -72,21 +68,13 @@ function getDotCount(date: Date): number {
|
|||
return Math.min(getEventsForDay(date).length, 3)
|
||||
}
|
||||
|
||||
// Default the selection to today so the calendar opens on today's events
|
||||
// rather than an empty panel (currentMonth already starts on this month).
|
||||
const selectedDay = ref<Date | null>(new Date())
|
||||
const selectedDay = ref<Date | null>(null)
|
||||
const selectedDayEvents = computed(() => {
|
||||
if (!selectedDay.value) return []
|
||||
return getEventsForDay(selectedDay.value)
|
||||
})
|
||||
|
||||
function selectDay(date: Date) {
|
||||
// Picker mode: every tap selects + emits (parent closes the popup).
|
||||
if (props.pickerMode) {
|
||||
selectedDay.value = date
|
||||
emit('selectDate', date)
|
||||
return
|
||||
}
|
||||
if (selectedDay.value && isSameDay(selectedDay.value, date)) {
|
||||
selectedDay.value = null
|
||||
} else {
|
||||
|
|
@ -107,7 +95,7 @@ function nextMonth() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-4">
|
||||
<!-- Month navigation -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
|
||||
|
|
@ -135,7 +123,7 @@ function nextMonth() {
|
|||
<button
|
||||
v-for="date in calendarDays"
|
||||
:key="date.toISOString()"
|
||||
class="h-12 flex flex-col items-center justify-center relative p-0.5 rounded-lg transition-colors"
|
||||
class="aspect-square flex flex-col items-center justify-center relative p-1 rounded-lg transition-colors"
|
||||
:class="{
|
||||
'text-muted-foreground/40': !isSameMonth(date, currentMonth),
|
||||
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
|
||||
|
|
@ -157,9 +145,8 @@ function nextMonth() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected day events (hidden in picker mode — the popup just
|
||||
picks a day and closes). -->
|
||||
<div v-if="selectedDay && !pickerMode" class="border-t pt-4 space-y-2">
|
||||
<!-- Selected day events -->
|
||||
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
|
||||
<h3 class="text-sm font-medium text-muted-foreground">
|
||||
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
|
||||
<span v-if="selectedDayEvents.length > 0" class="ml-1">
|
||||
|
|
|
|||
|
|
@ -6,17 +6,12 @@ import { Card, CardContent } from '@/components/ui/card'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
|
||||
import BookmarkButton from './BookmarkButton.vue'
|
||||
import OrganizerCard from './OrganizerCard.vue'
|
||||
import { useDateLocale } from '../composables/useDateLocale'
|
||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const props = defineProps<{
|
||||
event: Event
|
||||
/** Render a compact row: no hero image, no summary, single-line
|
||||
* title, tighter padding. Used by the Hosting view where the
|
||||
* host already knows what their events look like. */
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -57,58 +52,42 @@ const priceDisplay = computed(() => {
|
|||
return `${info.price} ${info.currency}`
|
||||
})
|
||||
|
||||
const placeholderBg = computed(() => {
|
||||
// Generate a consistent hue from the event title
|
||||
const hash = props.event.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
const hue = hash % 360
|
||||
return `hsl(${hue}, 40%, 85%)`
|
||||
})
|
||||
|
||||
const isPast = computed(() => {
|
||||
const a = props.event
|
||||
const end = a.endDate ?? a.startDate
|
||||
if (!end || isNaN(end.getTime())) return false
|
||||
return end.getTime() < Date.now()
|
||||
})
|
||||
|
||||
// Pending / rejected events get a washed-out look so the user
|
||||
// sees at a glance the event isn't live, not just the small badge.
|
||||
const isNonApproved = computed(
|
||||
() => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
|
||||
@click="emit('click', event)"
|
||||
>
|
||||
<!-- Wash-out wrapper. The pending/rejected status badge below sits
|
||||
OUTSIDE this wrapper so it stays in full color and reads
|
||||
clearly even when the card is dimmed + desaturated. -->
|
||||
<div
|
||||
class="transition-opacity duration-200"
|
||||
:class="[
|
||||
compact ? 'flex flex-row' : 'flex flex-col',
|
||||
isNonApproved ? 'opacity-50 grayscale hover:opacity-90' : '',
|
||||
]"
|
||||
>
|
||||
<!-- Compact thumbnail — small square preview on the left of the
|
||||
row when the event carries an image. `self-center` keeps it
|
||||
vertically centered against a taller content column so we
|
||||
don't get a top-anchored thumb with dead space below. -->
|
||||
<img
|
||||
v-if="compact && event.image"
|
||||
:src="event.image"
|
||||
:alt="event.title"
|
||||
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- Image with overlaid badges. Cards without an image (or in
|
||||
compact mode) skip the hero area entirely and surface their
|
||||
badges inline at the top of the content block — the solid-
|
||||
color placeholder + calendar glyph wasn't communicating
|
||||
anything the title + details don't already. -->
|
||||
<div v-if="event.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg">
|
||||
<!-- Image / Placeholder -->
|
||||
<div class="relative aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
v-if="event.image"
|
||||
:src="event.image"
|
||||
:alt="event.title"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
:style="{ backgroundColor: placeholderBg }"
|
||||
>
|
||||
<Calendar class="w-12 h-12 text-foreground/20" />
|
||||
</div>
|
||||
|
||||
<!-- Category badge -->
|
||||
<Badge
|
||||
|
|
@ -138,13 +117,27 @@ const isNonApproved = computed(
|
|||
{{ priceDisplay }}
|
||||
</Badge>
|
||||
|
||||
<!-- Past badge — shown when the event has already ended. The
|
||||
pending/rejected status badge that used to share this slot
|
||||
is now an absolute overlay on Card root, above the wash-out,
|
||||
so we still suppress Past when isNonApproved (the status
|
||||
badge is more actionable in that case). -->
|
||||
<!-- Pending/rejected overlay for the creator's own non-approved
|
||||
drafts. Only present when the event originated from a
|
||||
local LNbits event (Nostr-sourced events have no
|
||||
lnbitsStatus). -->
|
||||
<Badge
|
||||
v-if="isPast && !isNonApproved"
|
||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||
class="absolute bottom-2 left-2 text-xs capitalize"
|
||||
>
|
||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
</Badge>
|
||||
|
||||
<!-- Past badge — shown when the event has already ended.
|
||||
Only relevant on the feed when the "Past events" filter
|
||||
chip is toggled on (otherwise these cards aren't rendered);
|
||||
on the detail page the card view isn't used. Suppressed
|
||||
when a pending/rejected status badge is taking the same
|
||||
slot — that case is the creator's own past draft, which is
|
||||
vanishingly rare and the status hint is more actionable. -->
|
||||
<Badge
|
||||
v-if="isPast && !(event.lnbitsStatus && event.lnbitsStatus !== 'approved')"
|
||||
variant="outline"
|
||||
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
||||
>
|
||||
|
|
@ -153,71 +146,27 @@ const isNonApproved = computed(
|
|||
</Badge>
|
||||
</div>
|
||||
|
||||
<CardContent
|
||||
:class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'"
|
||||
>
|
||||
<!-- Inline badge row (no-image variant + compact variant). Same
|
||||
badges as the image-overlay set, stacked horizontally at the
|
||||
top of the content area. The "Yours" chip is dropped in
|
||||
compact mode since every card in the hosting view is owned. -->
|
||||
<div v-if="!event.image || compact" class="flex flex-wrap items-center gap-1.5">
|
||||
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
|
||||
{{ categoryLabel }}
|
||||
</Badge>
|
||||
<Badge v-if="priceDisplay" class="text-xs">
|
||||
{{ priceDisplay }}
|
||||
</Badge>
|
||||
<Badge v-if="event.isMine && !compact" variant="outline" class="text-xs gap-1">
|
||||
<User class="w-3 h-3" />
|
||||
Yours
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="isPast && !isNonApproved"
|
||||
variant="outline"
|
||||
class="text-xs gap-1"
|
||||
>
|
||||
<History class="w-3 h-3" />
|
||||
{{ t('events.filters.past', 'Past') }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Title + Bookmark. Compact mode hides the bookmark (host's
|
||||
own event — bookmarking it would be noise) and clamps the
|
||||
title to a single line. -->
|
||||
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
||||
<!-- Title + Bookmark -->
|
||||
<div class="flex items-start gap-1">
|
||||
<h3
|
||||
:class="[
|
||||
'font-semibold text-foreground leading-tight flex-1',
|
||||
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
|
||||
]"
|
||||
>
|
||||
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
<BookmarkButton
|
||||
v-if="!compact"
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Summary (hidden in compact mode) -->
|
||||
<!-- Summary -->
|
||||
<p
|
||||
v-if="event.summary && !compact"
|
||||
v-if="event.summary"
|
||||
class="text-sm text-muted-foreground line-clamp-2"
|
||||
>
|
||||
{{ event.summary }}
|
||||
</p>
|
||||
|
||||
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'">
|
||||
<!-- Organizer — small avatar + display name. Hidden in compact
|
||||
mode (host's own roster, no need to tell them whose event
|
||||
it is) and on cards the user already owns. -->
|
||||
<OrganizerCard
|
||||
v-if="!compact"
|
||||
:pubkey="event.organizer.pubkey"
|
||||
compact
|
||||
/>
|
||||
|
||||
<div class="mt-auto space-y-1.5 pt-2">
|
||||
<!-- Date/Time -->
|
||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar class="w-3.5 h-3.5 shrink-0" />
|
||||
|
|
@ -245,7 +194,7 @@ const isNonApproved = computed(
|
|||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else-if="event.ticketInfo.available > 0">
|
||||
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||
</span>
|
||||
<span v-else class="text-destructive font-medium">
|
||||
{{ t('events.detail.soldOut') }}
|
||||
|
|
@ -267,22 +216,5 @@ const isNonApproved = computed(
|
|||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
<!-- Status badge — absolutely positioned on Card root so it sits
|
||||
ABOVE the wash-out wrapper and keeps its full color.
|
||||
Pending + rejected both lean on the destructive token so the
|
||||
non-approved state reads as "needs attention" in every theme;
|
||||
the label text differentiates the two specific states.
|
||||
Bottom-right with a slight downward spill so it anchors
|
||||
visually without competing with the category chip in the
|
||||
badge row (full cards) or the thumbnail (compact cards). -->
|
||||
<Badge
|
||||
v-if="isNonApproved"
|
||||
variant="destructive"
|
||||
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
|
||||
>
|
||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
</Badge>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ import type { Event } from '../types/event'
|
|||
defineProps<{
|
||||
events: Event[]
|
||||
isLoading?: boolean
|
||||
/** Render compact rows instead of full-image cards. Used by the
|
||||
* Hosting view so an operator can scan their roster of events
|
||||
* without the visual weight of hero images they already recognize. */
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -43,24 +39,20 @@ const { t } = useI18n()
|
|||
class="flex flex-col items-center justify-center py-16 text-center"
|
||||
>
|
||||
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||
<h3 class="text-lg font-medium text-foreground">
|
||||
<h3 class="text-lg font-medium text-foreground mb-1">
|
||||
{{ t('events.noEvents') }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('events.search.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Event grid — compact mode collapses to a single column of
|
||||
tight rows; default mode is the responsive card grid. The
|
||||
compact gap is bumped a notch so the status badge spilling
|
||||
past the card's bottom edge has room to sit between cards. -->
|
||||
<div
|
||||
v-else
|
||||
:class="compact ? 'flex flex-col gap-4' : 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'"
|
||||
>
|
||||
<!-- Event grid -->
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EventCard
|
||||
v-for="event in events"
|
||||
:key="event.nostrEventId"
|
||||
:event="event"
|
||||
:compact="compact"
|
||||
@click="emit('select', event)"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,8 @@ import { Input } from '@/components/ui/input'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ProfileService } from '@/modules/base/nostr/ProfileService'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
/** Event enriched with its resolved organizer display name for search. */
|
||||
type SearchableEvent = Event & { organizerName: string }
|
||||
|
||||
const props = defineProps<{
|
||||
events: Event[]
|
||||
}>()
|
||||
|
|
@ -27,13 +22,12 @@ const { dateLocale } = useDateLocale()
|
|||
const isOpen = ref(false)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const searchOptions: FuzzySearchOptions<SearchableEvent> = {
|
||||
const searchOptions: FuzzySearchOptions<Event> = {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'title', weight: 0.5 },
|
||||
{ name: 'summary', weight: 0.2 },
|
||||
{ name: 'description', weight: 0.15 },
|
||||
{ name: 'organizerName', weight: 0.1 },
|
||||
{ name: 'location', weight: 0.1 },
|
||||
{ name: 'tags', weight: 0.05 },
|
||||
],
|
||||
|
|
@ -45,20 +39,7 @@ const searchOptions: FuzzySearchOptions<SearchableEvent> = {
|
|||
resultLimit: 8,
|
||||
}
|
||||
|
||||
// Organizer display names aren't stored on the event (they're fetched
|
||||
// per-pubkey into the shared ProfileService cache). Read the resolved
|
||||
// name from that same reactive cache so search matches it; the corpus
|
||||
// recomputes as kind-0 metadata lands.
|
||||
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||
|
||||
function organizerNameFor(pubkey: string): string {
|
||||
const p = profileService?.profiles.get(pubkey)
|
||||
return p?.display_name ?? p?.name ?? ''
|
||||
}
|
||||
|
||||
const searchCorpus = computed<SearchableEvent[]>(() =>
|
||||
props.events.map((e) => ({ ...e, organizerName: organizerNameFor(e.organizer.pubkey) })),
|
||||
)
|
||||
const eventsRef = computed(() => props.events)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
|
|
@ -66,7 +47,7 @@ const {
|
|||
isSearching,
|
||||
clearSearch,
|
||||
setSearchQuery,
|
||||
} = useFuzzySearch(searchCorpus, searchOptions)
|
||||
} = useFuzzySearch(eventsRef, searchOptions)
|
||||
|
||||
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
||||
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
||||
|
|
@ -113,18 +94,6 @@ function handleClickOutside(e: MouseEvent) {
|
|||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
// Warm the shared profile cache for every organizer in the current
|
||||
// set so their names become searchable (fetches dedupe in the
|
||||
// service; the corpus reacts as kind-0 metadata arrives).
|
||||
if (profileService) {
|
||||
const seen = new Set<string>()
|
||||
for (const e of props.events) {
|
||||
const pk = e.organizer.pubkey
|
||||
if (seen.has(pk) || profileService.profiles.get(pk)) continue
|
||||
seen.add(pk)
|
||||
void profileService.getProfile(pk)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,37 +3,15 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
|||
import { User } from 'lucide-vue-next'
|
||||
import { useOrganizerProfile } from '../composables/useOrganizerProfile'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
pubkey: string
|
||||
/** Compact row variant — small avatar, single-line "By <name>".
|
||||
* Used on the events feed card where the organizer is a hint, not
|
||||
* the focus. Default (full) is used on the detail page. */
|
||||
compact?: boolean
|
||||
}>(),
|
||||
{ compact: false },
|
||||
)
|
||||
}>()
|
||||
|
||||
const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Compact: tiny avatar + "By <name>" on a single line -->
|
||||
<div v-if="compact" class="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
|
||||
<Avatar class="h-4 w-4 shrink-0">
|
||||
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
|
||||
<AvatarFallback class="bg-primary/10">
|
||||
<User class="w-2.5 h-2.5 text-primary" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="truncate">
|
||||
<template v-if="isLoading">Loading…</template>
|
||||
<template v-else>{{ displayName }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Full (default): 10x10 avatar with name + nip05/pubkey -->
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="h-10 w-10">
|
||||
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
|
||||
<AvatarFallback class="bg-primary/10">
|
||||
|
|
@ -42,14 +20,14 @@ const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
|||
</Avatar>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
<template v-if="isLoading">Loading…</template>
|
||||
<template v-if="isLoading">Loading...</template>
|
||||
<template v-else>{{ displayName }}</template>
|
||||
</p>
|
||||
<p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate">
|
||||
{{ profile.nip05 }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted-foreground font-mono truncate">
|
||||
{{ pubkey.slice(0, 16) }}…
|
||||
{{ pubkey.slice(0, 16) }}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -70,11 +70,6 @@ function increaseQuantity() {
|
|||
|
||||
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
|
||||
|
||||
// Free events (price 0): no invoice is minted — the backend issues the
|
||||
// tickets already-paid. Drop the payment-method selector and price line
|
||||
// and label the CTA "Get ticket" instead of "Proceed".
|
||||
const isFree = computed(() => props.event.price_per_ticket <= 0)
|
||||
|
||||
async function copyInvoice() {
|
||||
if (!paymentRequest.value) return
|
||||
try {
|
||||
|
|
@ -264,10 +259,7 @@ onUnmounted(() => {
|
|||
<DialogDescription>
|
||||
<span v-if="quantity > 1">
|
||||
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
|
||||
{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
|
||||
</span>
|
||||
<span v-else-if="isFree">
|
||||
Get a free ticket for <strong>{{ event.name }}</strong>
|
||||
{{ formatEventPrice(totalPrice, event.currency) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
|
||||
|
|
@ -383,9 +375,9 @@ onUnmounted(() => {
|
|||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
||||
{{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
||||
</span>
|
||||
<span class="text-sm font-medium">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span>
|
||||
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
|
||||
</div>
|
||||
<PriceConversionPreview
|
||||
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
||||
|
|
@ -404,7 +396,7 @@ onUnmounted(() => {
|
|||
Lightning rather than collapsing into a single "Fiat"
|
||||
catch-all. Hidden entirely for Lightning-only events to
|
||||
keep the dialog uncluttered. -->
|
||||
<div v-if="canChooseFiat && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div class="text-sm font-medium">Payment method</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Both methods charge the same amount via different rails.
|
||||
|
|
@ -445,17 +437,13 @@ onUnmounted(() => {
|
|||
class="w-full"
|
||||
>
|
||||
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
|
||||
<template v-else-if="isFree">
|
||||
<Ticket class="w-4 h-4 mr-2" />
|
||||
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
|
||||
</template>
|
||||
<template v-else-if="selectedMethod?.rail === 'fiat'">
|
||||
<CreditCard class="w-4 h-4 mr-2" />
|
||||
Continue to {{ selectedMethod.label }} checkout
|
||||
</template>
|
||||
<template v-else>
|
||||
<Zap class="w-4 h-4 mr-2" />
|
||||
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
|
||||
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
81
src/modules/events/components/RSVPButton.vue
Normal 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>
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { History } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { TemporalFilter } from '../types/filters'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: TemporalFilter
|
||||
showPast: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: TemporalFilter]
|
||||
'toggle-past': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -26,33 +23,16 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<!-- pb-1 pr-1 keep the theme's offset drop-shadow (neobrut casts a hard 4px
|
||||
shadow down and to the right) from being clipped at the scroll box's
|
||||
bottom/right edges (overflow-x-auto forces overflow-y to auto). -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 pr-1" style="-ms-overflow-style: none; scrollbar-width: none;">
|
||||
<div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
|
||||
<Button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="text-xs shrink-0"
|
||||
class="rounded-full text-xs shrink-0"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ t(option.labelKey) }}
|
||||
</Button>
|
||||
<!-- Past pill sits at the end of the same scrollable strip so it's
|
||||
discoverable alongside the time-window pills, without claiming
|
||||
a dropdown row of its own. Composes orthogonally with the
|
||||
temporal pills: e.g. "This Week" + Past narrows to days
|
||||
already past this week. -->
|
||||
<Button
|
||||
:variant="props.showPast ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="text-xs shrink-0 gap-1.5"
|
||||
@click="emit('toggle-past')"
|
||||
>
|
||||
<History class="w-3 h-3" />
|
||||
{{ t('events.filters.past', 'Past') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
|
|||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
|
||||
|
||||
/**
|
||||
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
|
||||
|
|
@ -22,16 +21,12 @@ interface BookmarkState {
|
|||
bookmarkedCoords: Set<string>
|
||||
/** The latest bookmark event we've seen */
|
||||
lastEventId: string | null
|
||||
/** `created_at` of the latest bookmark event — used to publish a
|
||||
* strictly-newer timestamp so relays push the update to open subs. */
|
||||
lastCreatedAt: number | null
|
||||
}
|
||||
|
||||
// Shared state across all component instances
|
||||
const state = ref<BookmarkState>({
|
||||
bookmarkedCoords: new Set(),
|
||||
lastEventId: null,
|
||||
lastCreatedAt: null,
|
||||
})
|
||||
const isLoaded = ref(false)
|
||||
|
||||
|
|
@ -70,7 +65,7 @@ export function useBookmarks() {
|
|||
}],
|
||||
onEvent: (event: NostrEvent) => {
|
||||
// Only process if newer than what we have
|
||||
if (state.value.lastCreatedAt != null && event.created_at <= state.value.lastCreatedAt) return
|
||||
if (state.value.lastEventId && event.created_at <= (state.value as any).lastCreatedAt) return
|
||||
|
||||
const coords = new Set<string>()
|
||||
for (const tag of event.tags) {
|
||||
|
|
@ -81,8 +76,8 @@ export function useBookmarks() {
|
|||
state.value = {
|
||||
bookmarkedCoords: coords,
|
||||
lastEventId: event.id,
|
||||
lastCreatedAt: event.created_at,
|
||||
}
|
||||
;(state.value as any).lastCreatedAt = event.created_at
|
||||
isLoaded.value = true
|
||||
},
|
||||
onEose: () => {
|
||||
|
|
@ -93,20 +88,9 @@ export function useBookmarks() {
|
|||
|
||||
/**
|
||||
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
|
||||
*
|
||||
* Updates local state OPTIMISTICALLY so the UI (heart fill) responds
|
||||
* instantly, then signs + publishes in the background. Signing routes
|
||||
* through the remote LNbits signer and publishing hits relays, so
|
||||
* awaiting both before flipping state made the heart lag ~1s. On
|
||||
* failure the optimistic change is rolled back. Resolves to whether
|
||||
* the change was persisted.
|
||||
*/
|
||||
async function toggleBookmark(
|
||||
eventKind: number,
|
||||
pubkey: string,
|
||||
dTag: string,
|
||||
): Promise<boolean> {
|
||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return false
|
||||
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
|
||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
||||
|
||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||
const newCoords = new Set(state.value.bookmarkedCoords)
|
||||
|
|
@ -117,29 +101,12 @@ export function useBookmarks() {
|
|||
newCoords.add(coord)
|
||||
}
|
||||
|
||||
// Optimistic flip — preserve the prior state so we can roll back if
|
||||
// signing or publishing fails. Keep lastEventId/lastCreatedAt until
|
||||
// the real event is confirmed.
|
||||
const prevState = state.value
|
||||
state.value = {
|
||||
bookmarkedCoords: newCoords,
|
||||
lastEventId: prevState.lastEventId,
|
||||
lastCreatedAt: prevState.lastCreatedAt,
|
||||
}
|
||||
|
||||
function rollback() {
|
||||
state.value = prevState
|
||||
}
|
||||
|
||||
// Build and publish updated bookmark list. Use a strictly-monotonic
|
||||
// created_at so a same-second re-toggle still outranks the prior
|
||||
// version and relays push it to open subscriptions (a bare
|
||||
// floor(Date.now()/1000) can tie and be silently dropped).
|
||||
// Build and publish updated bookmark list
|
||||
const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
|
||||
|
||||
const template: EventTemplate = {
|
||||
kind: BOOKMARK_KIND,
|
||||
created_at: monotonicCreatedAt(prevState.lastCreatedAt),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags,
|
||||
}
|
||||
|
|
@ -149,28 +116,19 @@ export function useBookmarks() {
|
|||
signedEvent = await signEventViaLnbits(template)
|
||||
} catch (err) {
|
||||
console.error('[useBookmarks] signEventViaLnbits failed:', err)
|
||||
rollback()
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||
if (!relayHub) {
|
||||
rollback()
|
||||
return false
|
||||
}
|
||||
if (!relayHub) return
|
||||
|
||||
const result = await relayHub.publishEvent(signedEvent)
|
||||
if (result.success > 0) {
|
||||
state.value = {
|
||||
bookmarkedCoords: newCoords,
|
||||
lastEventId: signedEvent.id,
|
||||
lastCreatedAt: template.created_at,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
rollback()
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
|
|||
|
|
@ -19,12 +19,19 @@ export function useEventDetail(eventId: string) {
|
|||
)
|
||||
|
||||
async function load() {
|
||||
// Already in cache
|
||||
if (event.value) return
|
||||
|
||||
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||
if (!nostrService) {
|
||||
error.value = 'Events service not available'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
// Scope both the subscription and the one-shot query to this
|
||||
// event's d-tag. Without this scope, the query asks every
|
||||
// relay for every kind-31922/31923 event and races a 5s timeout
|
||||
|
|
@ -32,15 +39,6 @@ export function useEventDetail(eventId: string) {
|
|||
// even when the event is reachable.
|
||||
const detailFilters = { dTags: [eventId] }
|
||||
|
||||
// Subscribe for LIVE updates regardless of cache state. NIP-52
|
||||
// calendar events are replaceable, so when the events extension
|
||||
// republishes after a ticket sells (updating tickets_sold /
|
||||
// tickets_available — see events services.py), the new version
|
||||
// arrives here and the reactive `event` (and its ticket counts)
|
||||
// updates without a reload. Subscribing only on a cache miss meant
|
||||
// arriving from the feed (event already cached) left the detail
|
||||
// page with no live subscription, so counts went stale until reload.
|
||||
if (unsubscribe) unsubscribe() // avoid leaking a sub if load() re-runs
|
||||
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||
(incoming) => {
|
||||
store.upsertEvent(incoming)
|
||||
|
|
@ -51,14 +49,6 @@ export function useEventDetail(eventId: string) {
|
|||
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)
|
||||
store.upsertEvents(results)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,42 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
||||
startOfMonth, endOfMonth, addDays,
|
||||
startOfMonth, endOfMonth, addDays, isSameDay,
|
||||
} from 'date-fns'
|
||||
import type { Event } from '../types/event'
|
||||
import type { EventCategory } from '../types/category'
|
||||
import type { TemporalFilter, EventFilters } from '../types/filters'
|
||||
import { DEFAULT_FILTERS } from '../types/filters'
|
||||
|
||||
// Filter state is hoisted to module scope so every `useEvents()` /
|
||||
// `useEventFilters()` call shares the same refs. The bottom-nav
|
||||
// Hosting tab in events-app/App.vue and the feed view in
|
||||
// EventsPage.vue both rely on this — without a shared instance,
|
||||
// tapping Hosting toggled a private ref the page never saw.
|
||||
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||
const selectedCategories = ref<EventCategory[]>([])
|
||||
// A specific day picked from the calendar popup. When set it takes
|
||||
// priority over the temporal pills + past/upcoming split (browse any
|
||||
// single day). Cleared independently of categories.
|
||||
const selectedDate = ref<Date | undefined>(undefined)
|
||||
const onlyOwnedTickets = ref(false)
|
||||
const onlyHosting = ref(false)
|
||||
const showPast = ref(false)
|
||||
|
||||
/**
|
||||
* Composable for managing event filter state and applying filters reactively.
|
||||
*/
|
||||
export function useEventFilters() {
|
||||
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||
const selectedCategories = ref<EventCategory[]>([])
|
||||
const selectedDate = ref<Date | undefined>(undefined)
|
||||
/**
|
||||
* When true, the feed is narrowed to events the current user
|
||||
* holds at least one paid ticket for. Crossed with the
|
||||
* `ownedEventIds` set from useOwnedTickets in useEvents
|
||||
* (this composable stays free of ticket fetching).
|
||||
*/
|
||||
const onlyOwnedTickets = ref(false)
|
||||
/**
|
||||
* When true, the feed is narrowed to events the current user
|
||||
* is hosting (organizer pubkey matches the signed-in user, or the
|
||||
* row is a local LNbits draft of theirs). Reads `event.isMine`
|
||||
* which `useEvents.tagOwnership()` populates.
|
||||
*/
|
||||
const onlyHosting = ref(false)
|
||||
/**
|
||||
* When false (default), events that have already ended are
|
||||
* hidden from the feed. Toggling on includes them so the user can
|
||||
* browse past events. The date-picker overrides this — picking a
|
||||
* specific past date shows that day's events regardless,
|
||||
* mirroring how it overrides the temporal pills.
|
||||
*/
|
||||
const showPast = ref(false)
|
||||
|
||||
const filters = computed<EventFilters>(() => ({
|
||||
temporal: temporal.value,
|
||||
|
|
@ -39,10 +49,10 @@ export function useEventFilters() {
|
|||
function applyFilters(events: Event[]): Event[] {
|
||||
let result = events
|
||||
|
||||
// Specific date filter (from DatePickerStrip) takes priority over
|
||||
// temporal. Picking a date also bypasses the past/upcoming split
|
||||
// so the user can browse events for any day they choose.
|
||||
if (selectedDate.value) {
|
||||
// Specific day picked from the calendar popup — takes priority over
|
||||
// the temporal pills and bypasses the past/upcoming split so any
|
||||
// day (past or future) can be browsed.
|
||||
const dayStart = startOfDay(selectedDate.value)
|
||||
const dayEnd = endOfDay(selectedDate.value)
|
||||
result = result.filter(a => {
|
||||
|
|
@ -50,9 +60,8 @@ export function useEventFilters() {
|
|||
return a.startDate <= dayEnd && eventEnd >= dayStart
|
||||
})
|
||||
} else {
|
||||
// Temporal filter (preset pills).
|
||||
// Temporal filter
|
||||
result = applyTemporalFilter(result, temporal.value)
|
||||
|
||||
// Past/upcoming split — the chip narrows to one side of "now",
|
||||
// mirroring the "My tickets" / "Hosting" mental model. Default
|
||||
// (showPast=false) is upcoming-only; toggling on flips to
|
||||
|
|
@ -84,16 +93,16 @@ export function useEventFilters() {
|
|||
|
||||
function setTemporal(value: TemporalFilter) {
|
||||
temporal.value = value
|
||||
selectedDate.value = undefined // a preset pill clears the day pick
|
||||
selectedDate.value = undefined // clear date pick when using temporal pills
|
||||
}
|
||||
|
||||
function selectDate(date: Date) {
|
||||
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
|
||||
selectedDate.value = undefined // toggle off
|
||||
} else {
|
||||
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) {
|
||||
|
|
@ -154,7 +163,6 @@ export function useEventFilters() {
|
|||
applyFilters,
|
||||
setTemporal,
|
||||
selectDate,
|
||||
clearSelectedDate,
|
||||
toggleCategory,
|
||||
clearCategories,
|
||||
toggleOwnedTickets,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ProfileService, UserProfile } from '@/modules/base/nostr/ProfileService'
|
||||
import type { Event as NostrEvent } from 'nostr-tools'
|
||||
|
||||
export interface OrganizerProfile {
|
||||
pubkey: string
|
||||
|
|
@ -14,92 +14,134 @@ export interface OrganizerProfile {
|
|||
website?: string
|
||||
}
|
||||
|
||||
function fromUserProfile(p: UserProfile): OrganizerProfile {
|
||||
return {
|
||||
pubkey: p.pubkey,
|
||||
name: p.name,
|
||||
displayName: p.display_name,
|
||||
about: p.about,
|
||||
picture: p.picture,
|
||||
nip05: p.nip05,
|
||||
}
|
||||
}
|
||||
// Global cache of fetched profiles
|
||||
const profileCache = ref<Map<string, OrganizerProfile>>(new Map())
|
||||
|
||||
/**
|
||||
* Composable for fetching and displaying organizer profiles (NIP-01 kind 0).
|
||||
*
|
||||
* Routes through the centralized ProfileService (registered by the base
|
||||
* module) so:
|
||||
* - the cache is shared with every other module that reads kind-0
|
||||
* metadata (nostr-feed, market, chat),
|
||||
* - the subscription waits for the relay hub to actually be connected
|
||||
* before firing (the previous local impl threw synchronously when a
|
||||
* component mounted before connection — silently leaving the user
|
||||
* with a pubkey-truncated fallback even after profiles arrived),
|
||||
* - duplicate fetches for the same pubkey collapse into one
|
||||
* subscription.
|
||||
* Uses its own relay subscription to avoid depending on the nostr-feed module.
|
||||
*/
|
||||
export function useOrganizerProfile(pubkey: string) {
|
||||
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||
const isLoading = ref(false)
|
||||
const profile = ref<OrganizerProfile | null>(profileCache.value.get(pubkey) ?? null)
|
||||
const isLoading = ref(!profile.value)
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
// Reactive read from the shared ProfileService cache. Updates the
|
||||
// moment a kind-0 lands for this pubkey, regardless of which module
|
||||
// triggered the fetch.
|
||||
const profile = computed<OrganizerProfile | null>(() => {
|
||||
if (!profileService) return null
|
||||
const p = profileService.profiles.get(pubkey)
|
||||
return p ? fromUserProfile(p) : null
|
||||
})
|
||||
|
||||
const displayName = computed(() => {
|
||||
const p = profile.value
|
||||
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!profileService || profile.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
await profileService.getProfile(pubkey)
|
||||
} finally {
|
||||
function load() {
|
||||
if (profileCache.value.has(pubkey)) {
|
||||
profile.value = profileCache.value.get(pubkey)!
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||
if (!relayHub) {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
unsubscribe = relayHub.subscribe({
|
||||
id: `profile-${pubkey}-${Date.now()}`,
|
||||
filters: [{
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1,
|
||||
}],
|
||||
onEvent: (event: NostrEvent) => {
|
||||
try {
|
||||
const metadata = JSON.parse(event.content)
|
||||
const p: OrganizerProfile = {
|
||||
pubkey,
|
||||
name: metadata.name,
|
||||
displayName: metadata.display_name,
|
||||
about: metadata.about,
|
||||
picture: metadata.picture,
|
||||
banner: metadata.banner,
|
||||
nip05: metadata.nip05,
|
||||
lud16: metadata.lud16,
|
||||
website: metadata.website,
|
||||
}
|
||||
profileCache.value.set(pubkey, p)
|
||||
profile.value = p
|
||||
} catch {
|
||||
// invalid metadata JSON
|
||||
}
|
||||
isLoading.value = false
|
||||
},
|
||||
onEose: () => {
|
||||
isLoading.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
profile,
|
||||
isLoading,
|
||||
displayName,
|
||||
get displayName() {
|
||||
const p = profile.value
|
||||
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch profiles for multiple pubkeys (for event cards).
|
||||
*
|
||||
* Thin wrapper around ProfileService.fetchProfiles so callers don't
|
||||
* have to know the service token. Useful for warming the cache before
|
||||
* a list of cards mounts.
|
||||
*/
|
||||
export function useBatchProfiles() {
|
||||
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||
|
||||
function fetchProfiles(pubkeys: string[]) {
|
||||
if (!profileService || pubkeys.length === 0) return
|
||||
void profileService.fetchProfiles(pubkeys)
|
||||
const uncached = pubkeys.filter(pk => !profileCache.value.has(pk))
|
||||
if (uncached.length === 0) return
|
||||
|
||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||
if (!relayHub) return
|
||||
|
||||
relayHub.subscribe({
|
||||
id: `batch-profiles-${Date.now()}`,
|
||||
filters: [{
|
||||
kinds: [0],
|
||||
authors: uncached,
|
||||
}],
|
||||
onEvent: (event: NostrEvent) => {
|
||||
try {
|
||||
const metadata = JSON.parse(event.content)
|
||||
profileCache.value.set(event.pubkey, {
|
||||
pubkey: event.pubkey,
|
||||
name: metadata.name,
|
||||
displayName: metadata.display_name,
|
||||
about: metadata.about,
|
||||
picture: metadata.picture,
|
||||
banner: metadata.banner,
|
||||
nip05: metadata.nip05,
|
||||
lud16: metadata.lud16,
|
||||
website: metadata.website,
|
||||
})
|
||||
} catch {
|
||||
// skip invalid
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getProfile(pubkey: string): OrganizerProfile | undefined {
|
||||
const p = profileService?.profiles.get(pubkey)
|
||||
return p ? fromUserProfile(p) : undefined
|
||||
return profileCache.value.get(pubkey)
|
||||
}
|
||||
|
||||
function getDisplayName(pubkey: string): string {
|
||||
const p = profileService?.profiles.get(pubkey)
|
||||
return p?.display_name ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||
const p = profileCache.value.get(pubkey)
|
||||
return p?.displayName ?? p?.name ?? `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||
}
|
||||
|
||||
return {
|
||||
profiles: profileCache,
|
||||
fetchProfiles,
|
||||
getProfile,
|
||||
getDisplayName,
|
||||
|
|
|
|||
248
src/modules/events/composables/useRSVP.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5,19 +5,12 @@ import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
|
|||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { PaymentService } from '@/core/services/PaymentService'
|
||||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import { useOwnedTickets } from './useOwnedTickets'
|
||||
|
||||
export function useTicketPurchase() {
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
|
||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||
|
||||
// Refresh the shared owned-tickets singleton after a purchase so the
|
||||
// feed/calendar "My tickets" filter and EventCard owned badges update
|
||||
// without a reload — purchase is exactly the "consumer that mutates
|
||||
// the ticket set" useOwnedTickets's docs anticipate.
|
||||
const { refresh: refreshOwnedTickets } = useOwnedTickets()
|
||||
|
||||
// Async operations
|
||||
const purchaseOperation = useAsyncOperation()
|
||||
|
||||
|
|
@ -94,38 +87,6 @@ export function useTicketPurchase() {
|
|||
* have to take it as an argument from the UI. */
|
||||
const currentEventId = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Advance to the ticket-QR success state for the given row ids. Shared
|
||||
* by the Lightning-poll path (payment confirmed) and the free-ticket
|
||||
* path (issued + paid server-side, no invoice) so both render one
|
||||
* scannable QR per attendee identically.
|
||||
*/
|
||||
async function finalizePurchasedTickets(ids: string[]) {
|
||||
// Ticket row(s) now exist — refresh the shared owned-tickets state so
|
||||
// the feed/calendar My-tickets filter and owned badges reflect the
|
||||
// purchase immediately (no reload). Runs in parallel with QR gen.
|
||||
void refreshOwnedTickets()
|
||||
|
||||
if (ids.length > 0) {
|
||||
purchasedTicketIds.value = ids
|
||||
purchasedTicketId.value = ids[0]
|
||||
const qrMap: Record<string, string> = {}
|
||||
for (const id of ids) {
|
||||
const dataUrl = await generateTicketQRCode(id)
|
||||
if (dataUrl) qrMap[id] = dataUrl
|
||||
}
|
||||
ticketQRCodes.value = qrMap
|
||||
ticketQRCode.value = qrMap[ids[0]] ?? null
|
||||
showTicketQR.value = true
|
||||
}
|
||||
|
||||
toast.success(
|
||||
ids.length > 1
|
||||
? `${ids.length} tickets purchased!`
|
||||
: 'Ticket purchased successfully!',
|
||||
)
|
||||
}
|
||||
|
||||
async function purchaseTicketForEvent(
|
||||
eventId: string,
|
||||
options: { quantity?: number } = {},
|
||||
|
|
@ -157,23 +118,13 @@ export function useTicketPurchase() {
|
|||
{ quantity: options.quantity },
|
||||
)
|
||||
|
||||
// Free tickets (price 0 / 100%-off promo): the backend already
|
||||
// issued + marked them paid, so there's no invoice to settle. Skip
|
||||
// the QR / payment-poll and jump straight to the ticket-QR success
|
||||
// state with the ids returned inline.
|
||||
if (invoice.paid) {
|
||||
paymentHash.value = invoice.paymentHash
|
||||
await finalizePurchasedTickets(invoice.ticketIds ?? [])
|
||||
return invoice
|
||||
}
|
||||
|
||||
// Otherwise the backend returns either a Lightning invoice or a fiat
|
||||
// checkout URL (post-events-v1.4.0). This composable only knows how
|
||||
// to drive the Lightning path; fiat would need a separate
|
||||
// redirect-to-provider flow that lives in PurchaseTicketDialog (it
|
||||
// has the user-visible payment-method selector). Reject the fiat
|
||||
// response here so callers get a clear error instead of a silent
|
||||
// broken QR.
|
||||
// Backend now returns either a Lightning invoice or a fiat
|
||||
// checkout URL (post-events-v1.4.0). This composable only knows
|
||||
// how to drive the Lightning path; fiat would need a separate
|
||||
// redirect-to-provider flow that lives in PurchaseTicketDialog
|
||||
// (it has the user-visible payment-method selector). Reject the
|
||||
// fiat response here so callers get a clear error instead of a
|
||||
// silent broken QR.
|
||||
if (invoice.isFiat || !invoice.paymentRequest) {
|
||||
throw new Error(
|
||||
'This event uses fiat checkout. Use the purchase dialog ' +
|
||||
|
|
@ -229,13 +180,32 @@ export function useTicketPurchase() {
|
|||
|
||||
// Multi-ticket purchases come back with `ticketIds` (N rows
|
||||
// sharing one invoice). Single-ticket purchases include
|
||||
// `ticketId` only.
|
||||
// `ticketId` only. Render one QR per row so each attendee
|
||||
// has their own scannable code at the door.
|
||||
const ids = result.ticketIds && result.ticketIds.length > 0
|
||||
? result.ticketIds
|
||||
: result.ticketId
|
||||
? [result.ticketId]
|
||||
: []
|
||||
await finalizePurchasedTickets(ids)
|
||||
|
||||
if (ids.length > 0) {
|
||||
purchasedTicketIds.value = ids
|
||||
purchasedTicketId.value = ids[0]
|
||||
const qrMap: Record<string, string> = {}
|
||||
for (const id of ids) {
|
||||
const dataUrl = await generateTicketQRCode(id)
|
||||
if (dataUrl) qrMap[id] = dataUrl
|
||||
}
|
||||
ticketQRCodes.value = qrMap
|
||||
ticketQRCode.value = qrMap[ids[0]] ?? null
|
||||
showTicketQR.value = true
|
||||
}
|
||||
|
||||
toast.success(
|
||||
ids.length > 1
|
||||
? `${ids.length} tickets purchased!`
|
||||
: 'Ticket purchased successfully!',
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking payment status:', err)
|
||||
|
|
|
|||
|
|
@ -188,38 +188,6 @@ export function useTicketScanner(eventId: Ref<string>) {
|
|||
isPaused.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a ticket as registered without going through the camera —
|
||||
* used when the host knows the attendee in person or accepts an
|
||||
* alternate proof of identity. Same backend endpoint as a scan
|
||||
* (so it also gates on event ownership and rejects unpaid /
|
||||
* already-registered tickets), but skips the scanner pause +
|
||||
* full-screen banner since the operator initiated the action
|
||||
* from the roster directly. Refreshes stats on success.
|
||||
*/
|
||||
async function registerManually(
|
||||
ticketId: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
|
||||
if (!adminKey) return { ok: false, error: 'No wallet admin key available' }
|
||||
try {
|
||||
await ticketApi.registerTicket(ticketId, adminKey)
|
||||
// Mirror the session-local dedup the scan path uses so a
|
||||
// subsequent QR scan of the same ticket reports "Already
|
||||
// scanned" instead of round-tripping a duplicate register.
|
||||
if (!scanned.value.some(r => r.ticketId === ticketId)) {
|
||||
scanned.value = [
|
||||
{ ticketId, name: null, registeredAt: new Date().toISOString() },
|
||||
...scanned.value,
|
||||
]
|
||||
}
|
||||
await refreshStats()
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
function clearScanned() {
|
||||
scanned.value = []
|
||||
lastScan.value = null
|
||||
|
|
@ -242,6 +210,5 @@ export function useTicketScanner(eventId: Ref<string>) {
|
|||
onDecode,
|
||||
resume,
|
||||
clearScanned,
|
||||
registerManually,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ export const eventsModule = createModulePlugin({
|
|||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/events/calendar',
|
||||
name: 'events-calendar',
|
||||
component: () => import('./views/EventsCalendarPage.vue'),
|
||||
meta: {
|
||||
title: 'Calendar',
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/events/map',
|
||||
name: 'events-map',
|
||||
|
|
|
|||
|
|
@ -105,10 +105,6 @@ export class TicketApiService {
|
|||
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
|
||||
fiatProvider: data.fiat_provider ?? undefined,
|
||||
isFiat: Boolean(data.is_fiat),
|
||||
// Free tickets: backend returns paid=true with the row ids inline
|
||||
// and no invoice to settle.
|
||||
paid: Boolean(data.paid),
|
||||
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,50 +3,12 @@ import { ref, computed } from 'vue'
|
|||
import type { Event } from '../types/event'
|
||||
import type { TicketedEvent } from '../types/ticket'
|
||||
|
||||
/** NIP-52 calendar event kinds. Date-based = 31922, time-based = 31923. */
|
||||
export const EVENT_KIND_DATE = 31922
|
||||
export const EVENT_KIND_TIME = 31923
|
||||
|
||||
/** The NIP-52 kind for an event, derived from its date/time type. */
|
||||
export function eventKind(event: Pick<Event, 'type'>): number {
|
||||
return event.type === 'date' ? EVENT_KIND_DATE : EVENT_KIND_TIME
|
||||
}
|
||||
|
||||
/**
|
||||
* Addressable-event coordinate `kind:pubkey:d-tag` (NIP-01 `a` tag form).
|
||||
*
|
||||
* NIP-52 calendar events are *addressable* (parameterized-replaceable):
|
||||
* their d-tag is scoped to the **author**, so the replacement key MUST
|
||||
* include the pubkey. Keying by the bare d-tag alone lets a different
|
||||
* author publishing the same d-tag overwrite a legit event in the store.
|
||||
* This mirrors NDK's `event.coordinate()` and welshman's `eventsByAddress`.
|
||||
*/
|
||||
export function eventCoordinate(
|
||||
event: Pick<Event, 'type' | 'organizer' | 'id'>,
|
||||
): string {
|
||||
return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A *provisional draft* is the caller's own LNbits event surfaced via REST
|
||||
* (`loadOwnEvents` → `ticketedEventToEvent`) before its NIP-52 event is on
|
||||
* a relay. It has no Nostr author yet, so its coordinate carries an empty
|
||||
* pubkey. The relay-published copy of the same event lands under the real
|
||||
* publisher's coordinate (`resolve_for_wallet`, which is NOT the user's
|
||||
* Nostr login key), so the two never share a coordinate and must be
|
||||
* reconciled by d-tag — see {@link upsertEvent}.
|
||||
*/
|
||||
function isProvisionalDraft(event: Pick<Event, 'organizer'>): boolean {
|
||||
return event.organizer.pubkey === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinia store for cached events from Nostr relays.
|
||||
* Deduplicates by NIP-52 addressable coordinate (kind:pubkey:d-tag).
|
||||
* Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag).
|
||||
*/
|
||||
export const useEventsStore = defineStore('events', () => {
|
||||
// State — keyed by addressable coordinate, NOT bare d-tag, so two
|
||||
// authors using the same d-tag are stored independently.
|
||||
// State
|
||||
const eventsMap = ref<Map<string, Event>>(new Map())
|
||||
const isLoading = ref(false)
|
||||
const lastUpdated = ref<Date | null>(null)
|
||||
|
|
@ -81,58 +43,14 @@ export const useEventsStore = defineStore('events', () => {
|
|||
|
||||
/**
|
||||
* Add or update an event in the store.
|
||||
*
|
||||
* Deduplicates by addressable coordinate (kind:pubkey:d-tag). A newer
|
||||
* version (by `created_at`) replaces an older one *for the same
|
||||
* coordinate only* — a same-d-tag event from a different author lands
|
||||
* under its own coordinate and never clobbers another author's event.
|
||||
*
|
||||
* Draft↔published reconciliation: a provisional draft (the caller's own
|
||||
* REST event, empty-pubkey coordinate) and the relay-published copy of
|
||||
* the same event have *different* coordinates, so without intervention a
|
||||
* creator would see their own event twice once it's published. We keep a
|
||||
* single card per d-tag: the published copy wins (real author, live
|
||||
* ticket counts) and inherits the draft's `isMine` so the creator keeps
|
||||
* the Yours badge + Hosting filter — the publisher key differs from their
|
||||
* Nostr login key, so ownership can't be re-derived by pubkey match.
|
||||
* Deduplicates by id (d-tag). Newer events replace older ones.
|
||||
*/
|
||||
function upsertEvent(event: Event) {
|
||||
const incomingIsDraft = isProvisionalDraft(event)
|
||||
const existing = eventsMap.value.get(event.id)
|
||||
|
||||
// Look for an existing entry for the same d-tag with the OPPOSITE
|
||||
// draft/published status — the pair that needs reconciling.
|
||||
let pairKey: string | undefined
|
||||
let pair: Event | undefined
|
||||
for (const [k, e] of eventsMap.value) {
|
||||
if (e.id === event.id && isProvisionalDraft(e) !== incomingIsDraft) {
|
||||
pairKey = k
|
||||
pair = e
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (pair && pairKey) {
|
||||
if (incomingIsDraft) {
|
||||
// Published copy already present — fold the draft's ownership into
|
||||
// it and drop the draft (don't add a second card).
|
||||
if (event.isMine && !pair.isMine) {
|
||||
eventsMap.value.set(pairKey, { ...pair, isMine: true })
|
||||
lastUpdated.value = new Date()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Incoming is the published copy superseding a draft — inherit the
|
||||
// draft's ownership, then remove the draft so only one card remains.
|
||||
if (pair.isMine) event.isMine = true
|
||||
eventsMap.value.delete(pairKey)
|
||||
}
|
||||
|
||||
const key = eventCoordinate(event)
|
||||
const existing = eventsMap.value.get(key)
|
||||
|
||||
// Only update if this is a newer version of the same coordinate.
|
||||
// Only update if this is a newer version
|
||||
if (!existing || event.createdAt >= existing.createdAt) {
|
||||
eventsMap.value.set(key, event)
|
||||
eventsMap.value.set(event.id, event)
|
||||
lastUpdated.value = new Date()
|
||||
}
|
||||
}
|
||||
|
|
@ -147,13 +65,10 @@ export const useEventsStore = defineStore('events', () => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove an event by its d-tag. Deletes every stored coordinate whose
|
||||
* d-tag matches (normally one — our calendar events are single-publisher).
|
||||
* Remove an event from the store.
|
||||
*/
|
||||
function removeEvent(id: string) {
|
||||
for (const [key, event] of eventsMap.value) {
|
||||
if (event.id === id) eventsMap.value.delete(key)
|
||||
}
|
||||
eventsMap.value.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -165,28 +80,10 @@ export const useEventsStore = defineStore('events', () => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get a single event by its full addressable coordinate (kind:pubkey:d-tag).
|
||||
* The precise, unambiguous lookup.
|
||||
*/
|
||||
function getByCoordinate(coordinate: string): Event | undefined {
|
||||
return eventsMap.value.get(coordinate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single event by its d-tag (the route identifier).
|
||||
*
|
||||
* Calendar events in this app are single-publisher, so a d-tag resolves
|
||||
* to one event in practice. If multiple authors ever share a d-tag, the
|
||||
* newest (by `created_at`) wins — deterministic rather than first-seen.
|
||||
* Use {@link getByCoordinate} when the author is known.
|
||||
* Get a single event by its id (d-tag).
|
||||
*/
|
||||
function getEventById(id: string): Event | undefined {
|
||||
let match: Event | undefined
|
||||
for (const event of eventsMap.value.values()) {
|
||||
if (event.id !== id) continue
|
||||
if (!match || event.createdAt >= match.createdAt) match = event
|
||||
}
|
||||
return match
|
||||
return eventsMap.value.get(id)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -207,7 +104,6 @@ export const useEventsStore = defineStore('events', () => {
|
|||
upsertEvents,
|
||||
removeEvent,
|
||||
clearAll,
|
||||
getByCoordinate,
|
||||
getEventById,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -78,8 +78,6 @@ export interface EventTicketInfo {
|
|||
available?: number
|
||||
/** Running paid count. */
|
||||
sold: number
|
||||
/** Total capacity (available + sold). Undefined means unlimited. */
|
||||
total?: number
|
||||
/** Whether the organizer enabled fiat checkout. */
|
||||
allowFiat: boolean
|
||||
/** Fiat settle currency when allowFiat is true. */
|
||||
|
|
@ -93,9 +91,6 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
|
|||
currency: ticket.currency,
|
||||
available: ticket.available,
|
||||
sold: ticket.sold,
|
||||
// Capacity isn't published directly; derive it from remaining + sold.
|
||||
// Undefined `available` means unlimited, so total stays undefined too.
|
||||
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
|
||||
allowFiat: ticket.allowFiat,
|
||||
fiatCurrency: ticket.fiatCurrency,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,12 +81,10 @@ export interface TicketPurchaseRequest {
|
|||
}
|
||||
|
||||
/**
|
||||
* Server response from `POST /tickets/{event_id}`. One of three shapes:
|
||||
* - Lightning: `paymentRequest` = bolt11 (`isFiat` false, `paid` false)
|
||||
* - Fiat: `fiatPaymentRequest` = a URL the buyer follows with `fiatProvider`
|
||||
* - Free: `paid` true with no `paymentRequest` — tickets already issued +
|
||||
* paid server-side (price 0 / 100%-off promo); `ticketIds` carries the
|
||||
* scannable rows so the client skips the payment step entirely.
|
||||
* Server response from `POST /tickets/{event_id}`. Either Lightning
|
||||
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
|
||||
* the buyer follows to complete payment with `fiatProvider`).
|
||||
* `isFiat` is the discriminator.
|
||||
*/
|
||||
export interface TicketPurchaseInvoice {
|
||||
paymentHash: string
|
||||
|
|
@ -94,10 +92,6 @@ export interface TicketPurchaseInvoice {
|
|||
fiatPaymentRequest?: string
|
||||
fiatProvider?: string
|
||||
isFiat: boolean
|
||||
/** Free tickets: already issued + paid, no invoice to settle. */
|
||||
paid?: boolean
|
||||
/** Row ids returned inline for the free path (no poll needed). */
|
||||
ticketIds?: string[]
|
||||
}
|
||||
|
||||
export interface TicketPaymentStatus {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ import {
|
|||
} from 'lucide-vue-next'
|
||||
import { useEventDetail } from '../composables/useEventDetail'
|
||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||
import RSVPButton from '../components/RSVPButton.vue'
|
||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||
import { NIP52_KINDS } from '../types/nip52'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||
|
|
@ -168,14 +170,41 @@ function goToMyTickets() {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
||||
<!-- Top bar — back-link only. Edit moves into the title row as a
|
||||
prominent icon button; Scan moves into the tickets section
|
||||
where it replaces the Buy-ticket CTA for the host. -->
|
||||
<div class="flex items-center mb-4">
|
||||
<!-- Top bar -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="openScannerPage"
|
||||
aria-label="Scan tickets"
|
||||
>
|
||||
<ScanLine class="w-4 h-4" />
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="openEditDialog"
|
||||
aria-label="Edit event"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<BookmarkButton
|
||||
v-if="event"
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
|
|
@ -204,115 +233,104 @@ function goToMyTickets() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Title + bookmark + captions -->
|
||||
<!-- Title + Category -->
|
||||
<div>
|
||||
<div class="flex flex-wrap items-start gap-2 mb-2">
|
||||
<!-- "Yours" leads the row in the highlighted variant so the
|
||||
ownership signal stands out against the neutral
|
||||
category/tag chips that follow. -->
|
||||
<Badge
|
||||
v-if="event.isMine"
|
||||
variant="secondary"
|
||||
class="shrink-0"
|
||||
>
|
||||
Yours
|
||||
</Badge>
|
||||
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
|
||||
{{ categoryLabel }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||
class="shrink-0 capitalize"
|
||||
class="shrink-0 mt-1 capitalize"
|
||||
>
|
||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="event.isMine"
|
||||
variant="outline"
|
||||
class="shrink-0 mt-1"
|
||||
>
|
||||
Yours
|
||||
</Badge>
|
||||
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
||||
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||
{{ event.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-1 shrink-0 mt-1">
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
variant="default"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:aria-label="t('events.detail.editEvent', 'Edit event')"
|
||||
@click="openEditDialog"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</Button>
|
||||
<!-- Hosts don't need to favorite their own event — the
|
||||
"Yours" badge already marks it, and the bookmark
|
||||
affordance is meant for discovery, not management. -->
|
||||
<BookmarkButton
|
||||
v-else
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="event.summary" class="text-muted-foreground mt-2">
|
||||
{{ event.summary }}
|
||||
</p>
|
||||
|
||||
<!-- When + Where captions -->
|
||||
<div class="mt-3 space-y-1 text-sm text-muted-foreground">
|
||||
<div class="flex items-start gap-1.5">
|
||||
<Calendar class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{{ dateDisplay }}
|
||||
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="event.location" class="flex items-start gap-1.5">
|
||||
<MapPin class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{{ event.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Description -->
|
||||
<div class="prose prose-sm max-w-none text-foreground">
|
||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||
<!-- Info section -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<!-- When -->
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Calendar class="w-4 h-4" />
|
||||
{{ t('events.detail.when') }}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
|
||||
<p v-if="event.timezone" class="text-xs text-muted-foreground/70">
|
||||
{{ event.timezone }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Host's primary CTA is to scan tickets at the door. Lives
|
||||
OUTSIDE the ticketInfo gate so it appears even when the
|
||||
event was published without AIO ticket tags — a host always
|
||||
gets to scan attempts. Stays available for past events too
|
||||
so the host can still verify attendance after the fact. -->
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
class="w-full gap-1.5"
|
||||
size="lg"
|
||||
@click="openScannerPage"
|
||||
>
|
||||
<ScanLine class="w-4 h-4" />
|
||||
{{ t('events.detail.scanTickets', 'Scan tickets') }}
|
||||
</Button>
|
||||
<!-- Where -->
|
||||
<div v-if="event.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<MapPin class="w-4 h-4" />
|
||||
{{ t('events.detail.location') }}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ event.location }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSVP -->
|
||||
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
|
||||
(31922 for date-based, 31923 for time-based). Without this prop the
|
||||
button would default to time-based for every event, leaving RSVPs
|
||||
on date-based events pointing at a non-existent event coord. -->
|
||||
<RSVPButton
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
||||
/>
|
||||
|
||||
<!-- Tickets — gated on the event carrying ticketInfo (set
|
||||
by the calendar→Event converter from the AIO custom
|
||||
tickets_* tags on the published event). Skipped for the
|
||||
host entirely — they have the Scan CTA above and don't
|
||||
need a Buy CTA for their own event. -->
|
||||
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
|
||||
tickets_* tags on the published event). Sections render
|
||||
bottom-up: availability count, then existing owned
|
||||
tickets (when count > 0) above a Purchase CTA (when
|
||||
capacity remains). -->
|
||||
<div v-if="event.ticketInfo" class="space-y-3">
|
||||
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Ticket class="w-4 h-4 shrink-0" />
|
||||
<span v-if="event.ticketInfo.available === undefined">
|
||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else-if="event.ticketInfo.available > 0">
|
||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||
</span>
|
||||
<span v-else class="text-destructive font-medium">
|
||||
{{ t('events.detail.soldOut') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ownedPaidCount > 0"
|
||||
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||
class="bg-primary/10 border border-primary/30 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
|
||||
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
||||
</div>
|
||||
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||
<Ticket class="w-4 h-4" />
|
||||
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
||||
</Button>
|
||||
|
|
@ -325,11 +343,10 @@ function goToMyTickets() {
|
|||
<History class="w-4 h-4 shrink-0" />
|
||||
{{ t('events.detail.pastEvent', 'This event has already happened') }}
|
||||
</div>
|
||||
<div v-else-if="canBuyTicket" class="space-y-1">
|
||||
<div v-else-if="canBuyTicket">
|
||||
<Button
|
||||
class="w-full gap-1.5"
|
||||
size="lg"
|
||||
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
|
||||
@click="openPurchaseDialog"
|
||||
>
|
||||
<Ticket class="w-4 h-4" />
|
||||
|
|
@ -342,14 +359,6 @@ function goToMyTickets() {
|
|||
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
|
||||
</span>
|
||||
</Button>
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
<span v-if="event.ticketInfo.available === undefined">
|
||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="ownedPaidCount === 0"
|
||||
|
|
@ -366,13 +375,6 @@ function goToMyTickets() {
|
|||
@update:is-open="showPurchaseDialog = $event"
|
||||
/>
|
||||
|
||||
<!-- External references -->
|
||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||
<!-- References would go here in future phases -->
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Organizer -->
|
||||
<div class="bg-muted/50 rounded-lg p-4">
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
|
|
@ -380,6 +382,18 @@ function goToMyTickets() {
|
|||
</p>
|
||||
<OrganizerCard :pubkey="event.organizer.pubkey" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Description -->
|
||||
<div class="prose prose-sm max-w-none text-foreground">
|
||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- External references -->
|
||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||
<!-- References would go here in future phases -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
27
src/modules/events/views/EventsCalendarPage.vue
Normal 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>
|
||||
|
|
@ -34,10 +34,7 @@ onMounted(() => {
|
|||
|
||||
<!-- No geotagged events -->
|
||||
<div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
|
||||
<!-- opacity-30 on the element (not /30 on the colour) so the icon's
|
||||
overlapping fold strokes fade uniformly instead of compounding
|
||||
alpha where they overlap. -->
|
||||
<Map class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<p class="text-muted-foreground">No geotagged events found</p>
|
||||
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
|
|
@ -8,85 +8,44 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { SlidersHorizontal, CalendarDays, Plus, X } from 'lucide-vue-next'
|
||||
import { format } from 'date-fns'
|
||||
import brandAppLogoUrl from '@brand-app-logo?url'
|
||||
import brandAppBannerUrl from '@brand-app-banner?url'
|
||||
// Brand name flows through VITE_APP_NAME (set in vite.events.config.ts
|
||||
// from brand.name). cfaun → "Oyez!", default → "Events", etc. Falls
|
||||
// back to the i18n string only when no brand is configured (shouldn't
|
||||
// happen in production builds, but defensive).
|
||||
const appName: string = (import.meta.env.VITE_APP_NAME as string) || ''
|
||||
// When the active brand ships a banner (wide logo+wordmark lockup), it
|
||||
// replaces the logo + name pair in the header. The flag is set at build
|
||||
// time; brandAppBannerUrl falls back to the logo when unset, so we only
|
||||
// render the banner when the flag is truthy.
|
||||
const hasBanner = (import.meta.env.VITE_APP_BANNER as string) === '1'
|
||||
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
import EventCalendarPopup from '../components/EventCalendarPopup.vue'
|
||||
import DatePickerStrip from '../components/DatePickerStrip.vue'
|
||||
import EventList from '../components/EventList.vue'
|
||||
import { useDateLocale } from '../composables/useDateLocale'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { dateLocale } = useDateLocale()
|
||||
const eventsStore = useEventsStore()
|
||||
|
||||
const {
|
||||
events,
|
||||
allEvents,
|
||||
isLoading,
|
||||
error,
|
||||
temporal,
|
||||
selectedCategories,
|
||||
selectedDate,
|
||||
hasActiveFilters,
|
||||
showPast,
|
||||
selectedDate,
|
||||
onlyOwnedTickets,
|
||||
onlyHosting,
|
||||
setTemporal,
|
||||
showPast,
|
||||
selectDate,
|
||||
clearSelectedDate,
|
||||
setTemporal,
|
||||
toggleCategory,
|
||||
clearCategories,
|
||||
toggleOwnedTickets,
|
||||
toggleHosting,
|
||||
togglePast,
|
||||
resetFilters,
|
||||
subscribe,
|
||||
} = useEvents()
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const filtersOpen = ref(false)
|
||||
const calendarOpen = ref(false)
|
||||
|
||||
// Events feeding the calendar popup's per-day dots. Respects the active
|
||||
// category filter (so the calendar reflects what the user is browsing),
|
||||
// but not the temporal/day filters — the calendar is for picking any
|
||||
// date. No categories selected ⇒ all events.
|
||||
const calendarEvents = computed(() =>
|
||||
selectedCategories.value.length
|
||||
? allEvents.value.filter(
|
||||
(e) => e.category && selectedCategories.value.includes(e.category),
|
||||
)
|
||||
: allEvents.value,
|
||||
)
|
||||
|
||||
// Human label for the active day filter, shown as a removable chip.
|
||||
const selectedDateLabel = computed(() =>
|
||||
selectedDate.value
|
||||
? format(selectedDate.value, 'EEE, MMM d', { locale: dateLocale.value })
|
||||
: '',
|
||||
)
|
||||
|
||||
// Badge count on the Filters trigger so the user can see at a glance
|
||||
// that hidden toggles (categories) are currently active even when the
|
||||
// collapsible is closed. Past lives on the temporal strip now and
|
||||
// has its own visible pressed state, so it doesn't need to count here.
|
||||
const filterCount = computed(
|
||||
() => selectedCategories.value.length,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
|
|
@ -95,123 +54,85 @@ onMounted(() => {
|
|||
function handleSelectEvent(event: Event) {
|
||||
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||
}
|
||||
|
||||
// Create-activity CTA in the Hosting view. Replaces the old bottom-nav
|
||||
// Create entry; shown only while the Hosting filter is active.
|
||||
function openCreate() {
|
||||
eventsStore.editingEvent = null
|
||||
eventsStore.showCreateDialog = true
|
||||
}
|
||||
|
||||
function onSelectDate(date: Date) {
|
||||
// The popup closes itself; just apply the day filter.
|
||||
selectDate(date)
|
||||
}
|
||||
|
||||
// Safety: never let the date-picker popup persist across navigation —
|
||||
// e.g. it should not reappear when returning to the feed from an event
|
||||
// detail page.
|
||||
onBeforeRouteLeave(() => {
|
||||
calendarOpen.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- pt-2.5 (not py-4) so the header logo/banner sits centered on the
|
||||
same horizontal axis as the fixed top-right profile icon. -->
|
||||
<div class="container mx-auto pt-2.5 pb-4 px-4">
|
||||
<!-- Page header. A brand may ship a wide banner (logo + wordmark in
|
||||
one image) that replaces the logo + name pair; otherwise we show
|
||||
the brand-kit logo (per-standalone override or global) paired
|
||||
with the standalone's localized name. Both resolve at build time
|
||||
via @brand-app-banner / @brand-app-logo so deployers can override
|
||||
without touching this component. -->
|
||||
<h1 class="mb-3 text-xl sm:text-2xl font-bold text-foreground">
|
||||
<img
|
||||
v-if="hasBanner"
|
||||
:src="brandAppBannerUrl"
|
||||
:alt="appName || t('events.title')"
|
||||
class="h-12 sm:h-14 w-auto max-w-full"
|
||||
/>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<img
|
||||
:src="brandAppLogoUrl"
|
||||
:alt="appName || t('events.title')"
|
||||
class="h-10 w-10 sm:h-12 sm:w-12 shrink-0"
|
||||
/>
|
||||
{{ appName || t('events.title') }}
|
||||
</span>
|
||||
<div class="container mx-auto py-6 px-4">
|
||||
<!-- Page header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||
{{ t('events.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Search with dropdown overlay -->
|
||||
<div class="mb-3">
|
||||
<div class="mb-4">
|
||||
<EventSearchOverlay
|
||||
:events="events"
|
||||
@select="handleSelectEvent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
|
||||
column; only the temporal pills scroll horizontally. The
|
||||
Filters icon (with a count badge when categories are active)
|
||||
opens a collapsible that hosts category chips below. Past is
|
||||
a pill at the end of the temporal strip and doesn't live in
|
||||
the dropdown anymore. Hidden in the Hosting view — the
|
||||
operator's roster doesn't need them. -->
|
||||
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0 flex flex-col items-center gap-0.5">
|
||||
<CollapsibleTrigger as-child>
|
||||
<!-- Date picker strip (p'a semana style) -->
|
||||
<div class="mb-4">
|
||||
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
|
||||
</div>
|
||||
|
||||
<!-- Temporal filter pills -->
|
||||
<div class="mb-4">
|
||||
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||
</div>
|
||||
|
||||
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
||||
"Hosting") narrow the feed to events the signed-in user
|
||||
has skin in and are hidden when logged out. The "Past events"
|
||||
chip is always visible since past-browsing doesn't require an
|
||||
account. -->
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<template v-if="isAuthenticated">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-full h-8 w-8 relative"
|
||||
:class="{ 'bg-accent text-accent-foreground': filtersOpen || filterCount > 0 }"
|
||||
:aria-label="t('events.filters.filters', 'Filters')"
|
||||
:aria-expanded="filtersOpen"
|
||||
:variant="onlyOwnedTickets ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="toggleOwnedTickets"
|
||||
>
|
||||
<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" />
|
||||
<span
|
||||
v-if="filterCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] h-[16px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold flex items-center justify-center"
|
||||
>
|
||||
{{ filterCount }}
|
||||
Categories
|
||||
<span v-if="selectedCategories.length > 0" class="text-xs bg-primary text-primary-foreground rounded-full px-1.5">
|
||||
{{ selectedCategories.length }}
|
||||
</span>
|
||||
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': filtersOpen }" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 px-1 text-[10px] text-muted-foreground"
|
||||
@click="resetFilters"
|
||||
>
|
||||
{{ t('events.filters.clearAll', 'Clear all') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 pt-0.5">
|
||||
<TemporalFilterBar
|
||||
:model-value="temporal"
|
||||
:show-past="showPast"
|
||||
@update:model-value="setTemporal"
|
||||
@toggle-past="togglePast"
|
||||
/>
|
||||
</div>
|
||||
<!-- Calendar shortcut — opens the date-picker popup to filter the
|
||||
feed to a single day. Highlighted while a day filter is
|
||||
active. -->
|
||||
<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">
|
||||
<CollapsibleContent class="mt-2">
|
||||
<CategoryFilterBar
|
||||
:selected="selectedCategories"
|
||||
@toggle="toggleCategory"
|
||||
|
|
@ -220,61 +141,24 @@ onBeforeRouteLeave(() => {
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Active day-filter chip — removing it clears ONLY the date
|
||||
selection (categories have their own clear in the filter
|
||||
dropdown). Shown when a day is picked from the calendar popup. -->
|
||||
<div v-if="selectedDate" class="mb-3 flex">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-7 gap-1.5"
|
||||
:aria-label="t('events.filters.clearDate', 'Clear date filter')"
|
||||
@click="clearSelectedDate"
|
||||
>
|
||||
<CalendarDays class="w-3.5 h-3.5" />
|
||||
{{ selectedDateLabel }}
|
||||
<X class="w-3.5 h-3.5" />
|
||||
<!-- Active filters indicator -->
|
||||
<div v-if="hasActiveFilters" class="flex items-center gap-2 mb-4">
|
||||
<span class="text-xs text-muted-foreground">Filters active</span>
|
||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="resetFilters">
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Create-activity CTA — shown when the Hosting bottom-nav tab is
|
||||
active. Replaces the dedicated Create entry that used to live
|
||||
in the bottom nav; lives here so it shows up exactly when the
|
||||
user is in the "events I'm running" view. -->
|
||||
<Button
|
||||
v-if="onlyHosting"
|
||||
class="w-full mb-3 gap-1.5"
|
||||
@click="openCreate"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
{{ t('events.createNew') }}
|
||||
</Button>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Event feed. The Hosting view renders compact rows so the
|
||||
operator can scan their roster without the visual weight of
|
||||
hero images they already recognize. -->
|
||||
<!-- Event feed -->
|
||||
<EventList
|
||||
:events="events"
|
||||
:is-loading="isLoading"
|
||||
:compact="onlyHosting"
|
||||
@select="handleSelectEvent"
|
||||
/>
|
||||
|
||||
<!-- Date-picker popup: month grid with per-day event dots. Picking a
|
||||
day filters the feed to it and closes. -->
|
||||
<EventCalendarPopup
|
||||
v-model:open="calendarOpen"
|
||||
:events="calendarEvents"
|
||||
:selected-categories="selectedCategories"
|
||||
:title="t('events.nav.calendar', 'Calendar')"
|
||||
:description="t('events.calendar.pickDay', 'Pick a day to see its events')"
|
||||
@select-date="onSelectDate"
|
||||
@toggle-category="toggleCategory"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,118 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, onBeforeRouteLeave } from 'vue-router'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useUserTickets } from '../composables/useUserTickets'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { format, startOfDay, endOfDay } from 'date-fns'
|
||||
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-vue-next'
|
||||
import EventCalendarPopup from '../components/EventCalendarPopup.vue'
|
||||
import { useDateLocale } from '../composables/useDateLocale'
|
||||
import type { Event } from '../types/event'
|
||||
import { format } from 'date-fns'
|
||||
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
const { isAuthenticated, userDisplay } = useAuth()
|
||||
const { dateLocale } = useDateLocale()
|
||||
const {
|
||||
tickets,
|
||||
paidTickets,
|
||||
pendingTickets,
|
||||
registeredTickets,
|
||||
groupedTickets,
|
||||
isLoading,
|
||||
error,
|
||||
refresh
|
||||
} = useUserTickets()
|
||||
|
||||
// Subscribe to the events feed so we can map ticket.eventId → event.title.
|
||||
// The events store is shared (pinia), so if the user has already visited
|
||||
// the feed this is a no-op fresh subscription; nothing depends on it
|
||||
// being the canonical one.
|
||||
const eventsStore = useEventsStore()
|
||||
const { subscribe: subscribeToEvents } = useEvents()
|
||||
|
||||
function eventTitleFor(eventId: string): string | null {
|
||||
return eventsStore.getEventById(eventId)?.title ?? null
|
||||
}
|
||||
|
||||
function eventShortLabel(eventId: string): string {
|
||||
return `Event: ${eventId.slice(0, 8)}…`
|
||||
}
|
||||
|
||||
// Past/upcoming toggle. Defaults to upcoming. An event whose end (or
|
||||
// start, if no end) is before now counts as past; events not yet
|
||||
// resolved from relays are treated as upcoming so their tickets stay
|
||||
// visible until we know otherwise.
|
||||
const showPast = ref(false)
|
||||
|
||||
function isGroupPast(eventId: string): boolean {
|
||||
const ev = eventsStore.getEventById(eventId)
|
||||
if (!ev) return false
|
||||
const end = ev.endDate ?? ev.startDate
|
||||
return end < new Date()
|
||||
}
|
||||
|
||||
// Calendar popup: visualise the days the user has events. Picking a day
|
||||
// filters the ticket list to it (overriding the upcoming/past toggle);
|
||||
// clearing it returns to the toggle.
|
||||
const calendarOpen = ref(false)
|
||||
const selectedDay = ref<Date | null>(null)
|
||||
|
||||
// The user's events (resolved from their ticket groups) — feeds the
|
||||
// calendar popup's per-day dots.
|
||||
const myEvents = computed<Event[]>(() => {
|
||||
const out: Event[] = []
|
||||
for (const g of groupedTickets.value) {
|
||||
const ev = eventsStore.getEventById(g.eventId)
|
||||
if (ev) out.push(ev)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const selectedDayLabel = computed(() =>
|
||||
selectedDay.value
|
||||
? format(selectedDay.value, 'EEE, MMM d', { locale: dateLocale.value })
|
||||
: '',
|
||||
)
|
||||
|
||||
function isGroupOnDay(eventId: string, day: Date): boolean {
|
||||
const ev = eventsStore.getEventById(eventId)
|
||||
if (!ev) return false
|
||||
const end = ev.endDate ?? ev.startDate
|
||||
return ev.startDate <= endOfDay(day) && end >= startOfDay(day)
|
||||
}
|
||||
|
||||
function onSelectDay(date: Date) {
|
||||
selectedDay.value = date
|
||||
}
|
||||
|
||||
// Don't let the calendar popup persist across navigation.
|
||||
onBeforeRouteLeave(() => {
|
||||
calendarOpen.value = false
|
||||
})
|
||||
|
||||
const visibleGroups = computed(() => {
|
||||
if (selectedDay.value) {
|
||||
return groupedTickets.value.filter(g => isGroupOnDay(g.eventId, selectedDay.value!))
|
||||
}
|
||||
return groupedTickets.value.filter(g => isGroupPast(g.eventId) === showPast.value)
|
||||
})
|
||||
|
||||
// Tab counts derived from the visible (past/upcoming-filtered) groups so
|
||||
// the badges match what's actually shown.
|
||||
const visibleCounts = computed(() => {
|
||||
let all = 0, paid = 0, pending = 0, registered = 0
|
||||
for (const g of visibleGroups.value) {
|
||||
all += g.tickets.length
|
||||
paid += g.paidCount
|
||||
pending += g.pendingCount
|
||||
registered += g.registeredCount
|
||||
}
|
||||
return { all, paid, pending, registered }
|
||||
})
|
||||
|
||||
const qrCodes = ref<Record<string, string>>({})
|
||||
const currentTicketIndex = ref<Record<string, number>>({})
|
||||
|
||||
|
|
@ -201,10 +110,6 @@ watch(groupedTickets, async (newGroups) => {
|
|||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
// Kick off the events subscription so eventTitleFor() can resolve
|
||||
// names as relay events stream in. Fire-and-forget — the QR cards
|
||||
// render fine while the title is still loading.
|
||||
subscribeToEvents()
|
||||
if (isAuthenticated.value) {
|
||||
await refresh()
|
||||
}
|
||||
|
|
@ -251,83 +156,23 @@ onMounted(async () => {
|
|||
</div>
|
||||
|
||||
<div v-else-if="tickets.length > 0">
|
||||
<!-- Filter row — own row, left-aligned so it clears the fixed
|
||||
top-right hamburger menu. Upcoming/Past toggle by default;
|
||||
when a day is picked from the calendar it's replaced by a
|
||||
removable date chip (the day overrides the toggle). The
|
||||
calendar button opens a popup visualising the user's event
|
||||
dates. -->
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<div v-if="!selectedDay" class="inline-flex rounded-md border p-0.5">
|
||||
<Button
|
||||
:variant="!showPast ? 'default' : 'ghost'"
|
||||
size="sm"
|
||||
class="h-7"
|
||||
@click="showPast = false"
|
||||
>
|
||||
Upcoming
|
||||
</Button>
|
||||
<Button
|
||||
:variant="showPast ? 'default' : 'ghost'"
|
||||
size="sm"
|
||||
class="h-7"
|
||||
@click="showPast = true"
|
||||
>
|
||||
Past
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-7 gap-1.5"
|
||||
aria-label="Clear day filter"
|
||||
@click="selectedDay = null"
|
||||
>
|
||||
<CalendarDays class="w-3.5 h-3.5" />
|
||||
{{ selectedDayLabel }}
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:class="{ 'bg-accent text-accent-foreground': selectedDay }"
|
||||
aria-label="Open calendar"
|
||||
@click="calendarOpen = true"
|
||||
>
|
||||
<CalendarDays class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs default-value="all" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="all">All ({{ visibleCounts.all }})</TabsTrigger>
|
||||
<TabsTrigger value="paid">Paid ({{ visibleCounts.paid }})</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pending ({{ visibleCounts.pending }})</TabsTrigger>
|
||||
<TabsTrigger value="registered">Registered ({{ visibleCounts.registered }})</TabsTrigger>
|
||||
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger>
|
||||
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger>
|
||||
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- All Tickets Tab -->
|
||||
<TabsContent value="all">
|
||||
<ScrollArea class="h-[600px] w-full pr-4">
|
||||
<div v-if="visibleGroups.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
{{ selectedDay ? 'No tickets on this day' : (showPast ? 'No past tickets' : 'No upcoming tickets') }}
|
||||
</div>
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in visibleGroups" :key="group.eventId" class="flex flex-col">
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardTitle class="text-foreground min-w-0 flex-1">
|
||||
<RouterLink
|
||||
:to="{ name: 'event-detail', params: { id: group.eventId } }"
|
||||
class="hover:underline truncate block"
|
||||
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
|
||||
>
|
||||
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
|
||||
</RouterLink>
|
||||
</CardTitle>
|
||||
<Badge variant="outline" class="shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
|
||||
<Badge variant="outline">
|
||||
{{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
@ -413,19 +258,11 @@ onMounted(async () => {
|
|||
<!-- Paid, Pending, Registered tabs follow the same pattern but filter -->
|
||||
<TabsContent value="paid">
|
||||
<ScrollArea class="h-[600px] w-full pr-4">
|
||||
<div v-if="visibleCounts.paid === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div>
|
||||
<div v-if="paidTickets.length === 0" class="text-center py-8 text-muted-foreground">No paid tickets</div>
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in visibleGroups.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||
<Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-foreground min-w-0">
|
||||
<RouterLink
|
||||
:to="{ name: 'event-detail', params: { id: group.eventId } }"
|
||||
class="hover:underline truncate block"
|
||||
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
|
||||
>
|
||||
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
|
||||
</RouterLink>
|
||||
</CardTitle>
|
||||
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
|
||||
<CardDescription>{{ group.paidCount }} paid ticket{{ group.paidCount !== 1 ? 's' : '' }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -438,19 +275,11 @@ onMounted(async () => {
|
|||
|
||||
<TabsContent value="pending">
|
||||
<ScrollArea class="h-[600px] w-full pr-4">
|
||||
<div v-if="visibleCounts.pending === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div>
|
||||
<div v-if="pendingTickets.length === 0" class="text-center py-8 text-muted-foreground">No pending tickets</div>
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in visibleGroups.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
|
||||
<Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-foreground min-w-0">
|
||||
<RouterLink
|
||||
:to="{ name: 'event-detail', params: { id: group.eventId } }"
|
||||
class="hover:underline truncate block"
|
||||
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
|
||||
>
|
||||
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
|
||||
</RouterLink>
|
||||
</CardTitle>
|
||||
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
|
||||
<CardDescription>{{ group.pendingCount }} pending ticket{{ group.pendingCount !== 1 ? 's' : '' }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -463,19 +292,11 @@ onMounted(async () => {
|
|||
|
||||
<TabsContent value="registered">
|
||||
<ScrollArea class="h-[600px] w-full pr-4">
|
||||
<div v-if="visibleCounts.registered === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div>
|
||||
<div v-if="registeredTickets.length === 0" class="text-center py-8 text-muted-foreground">No registered tickets</div>
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card v-for="group in visibleGroups.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||
<Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-foreground min-w-0">
|
||||
<RouterLink
|
||||
:to="{ name: 'event-detail', params: { id: group.eventId } }"
|
||||
class="hover:underline truncate block"
|
||||
:title="eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId)"
|
||||
>
|
||||
{{ eventTitleFor(group.eventId) ?? eventShortLabel(group.eventId) }}
|
||||
</RouterLink>
|
||||
</CardTitle>
|
||||
<CardTitle class="text-foreground">Event: {{ group.eventId.slice(0, 8) }}...</CardTitle>
|
||||
<CardDescription>{{ group.registeredCount }} registered ticket{{ group.registeredCount !== 1 ? 's' : '' }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -487,15 +308,5 @@ onMounted(async () => {
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Calendar popup: dots show the days the user has events; picking
|
||||
one filters the ticket list to that day. -->
|
||||
<EventCalendarPopup
|
||||
v-model:open="calendarOpen"
|
||||
:events="myEvents"
|
||||
title="Your event dates"
|
||||
description="Pick a day to see your tickets for it"
|
||||
@select-date="onSelectDay"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
|
|
@ -10,20 +9,15 @@ import {
|
|||
Ticket,
|
||||
ScanLine,
|
||||
RefreshCw,
|
||||
UserCheck,
|
||||
Search,
|
||||
} from 'lucide-vue-next'
|
||||
import { format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
||||
import type { EventTicket } from '../composables/useTicketScanner'
|
||||
import { useEventDetail } from '../composables/useEventDetail'
|
||||
import { useFuzzySearch } from '@/composables/useFuzzySearch'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -41,14 +35,8 @@ const {
|
|||
refreshStats,
|
||||
onDecode,
|
||||
resume,
|
||||
registerManually,
|
||||
} = useTicketScanner(eventId)
|
||||
|
||||
// Tracks tickets currently mid-register (manual button click), so each
|
||||
// row can render a per-row spinner without blocking the rest of the
|
||||
// list. A Set keeps add/remove O(1).
|
||||
const pendingRegister = ref<Set<string>>(new Set())
|
||||
|
||||
const scannerOpen = ref(true)
|
||||
const activeTab = ref<'scanner' | 'list'>('scanner')
|
||||
|
||||
|
|
@ -76,55 +64,11 @@ const remainingCount = computed(() => {
|
|||
return Math.max(0, soldCount.value - registeredCount.value)
|
||||
})
|
||||
|
||||
// Full ticket roster, sorted so unregistered (actionable) rows lead
|
||||
// and registered rows follow most-recent-first. Powers the Tickets
|
||||
// tab where the host can manually register attendees who can prove
|
||||
// identity but can't present a scannable QR.
|
||||
const allTickets = computed<EventTicket[]>(() => {
|
||||
const list = eventStats.value?.tickets ?? []
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.registered !== b.registered) return a.registered ? 1 : -1
|
||||
if (a.registered && b.registered) {
|
||||
return (b.registeredAt ?? '').localeCompare(a.registeredAt ?? '')
|
||||
}
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
const totalTicketsCount = computed(() => eventStats.value?.tickets.length ?? 0)
|
||||
const unregisteredCount = computed(
|
||||
() => allTickets.value.filter(t => !t.registered).length,
|
||||
// Registered tickets only — what the "Scanned" tab shows.
|
||||
const registeredTickets = computed(
|
||||
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
|
||||
)
|
||||
|
||||
// Fuzzy match on holder name + ticket id. When the search box is
|
||||
// empty, Fuse returns the list in its incoming order so our
|
||||
// unregistered-first sort is preserved.
|
||||
const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
|
||||
allTickets,
|
||||
{
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.7 },
|
||||
{ name: 'id', weight: 0.3 },
|
||||
],
|
||||
threshold: 0.3,
|
||||
ignoreLocation: true,
|
||||
},
|
||||
matchAllWhenSearchEmpty: true,
|
||||
},
|
||||
)
|
||||
|
||||
async function handleManualRegister(ticket: EventTicket) {
|
||||
pendingRegister.value.add(ticket.id)
|
||||
const res = await registerManually(ticket.id)
|
||||
pendingRegister.value.delete(ticket.id)
|
||||
if (res.ok) {
|
||||
toast.success(`Registered ${ticket.name || ticket.id.slice(0, 8) + '…'}`)
|
||||
} else {
|
||||
toast.error(res.error || 'Failed to register')
|
||||
}
|
||||
}
|
||||
|
||||
function handleResult(qrText: string) {
|
||||
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
||||
// already throttles, and useTicketScanner.onDecode dedups the same
|
||||
|
|
@ -190,7 +134,7 @@ function fmtTime(iso: string) {
|
|||
<p class="text-2xl font-bold text-foreground">
|
||||
{{ remainingCount ?? '—' }}
|
||||
</p>
|
||||
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Not scanned</p>
|
||||
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -212,21 +156,13 @@ function fmtTime(iso: string) {
|
|||
|
||||
<Tabs v-model="activeTab" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2 mb-4">
|
||||
<!-- Icon + label wrapped in a real flex container so they
|
||||
share a gap and items-center alignment. TabsTrigger's
|
||||
internal slot lives in an inline span, so a `gap-1.5`
|
||||
on the trigger itself never reaches these two children. -->
|
||||
<TabsTrigger value="scanner">
|
||||
<span class="inline-flex items-center justify-center gap-1.5">
|
||||
<TabsTrigger value="scanner" class="gap-1.5">
|
||||
<ScanLine class="w-4 h-4" />
|
||||
Scanner
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list">
|
||||
<span class="inline-flex items-center justify-center gap-1.5">
|
||||
<TabsTrigger value="list" class="gap-1.5">
|
||||
<Ticket class="w-4 h-4" />
|
||||
Tickets ({{ totalTicketsCount }})
|
||||
</span>
|
||||
Scanned ({{ registeredCount }})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -254,83 +190,39 @@ function fmtTime(iso: string) {
|
|||
<TabsContent value="list" class="mt-0">
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm font-medium text-foreground">
|
||||
{{ registeredCount }} / {{ totalTicketsCount }} registered
|
||||
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
|
||||
· {{ unregisteredCount }} to go
|
||||
</span>
|
||||
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
|
||||
</h2>
|
||||
|
||||
<!-- Fuzzy filter on holder name + ticket id (Fuse.js via
|
||||
useFuzzySearch). Empty query → all rows in their
|
||||
sort order; typing → reordered by relevance. -->
|
||||
<div v-if="allTickets.length > 0" class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="Search by name or ticket id…"
|
||||
class="pl-8 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Unregistered rows lead the list so the operator can act
|
||||
on the actionable ones first; tap "Register" to mark an
|
||||
attendee present without a QR (e.g. lost phone, known
|
||||
in person). Failures surface as a toast; the row reverts. -->
|
||||
<ScrollArea v-if="searchedTickets.length > 0" class="h-[60vh]">
|
||||
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
|
||||
<ul class="space-y-1.5 pr-3">
|
||||
<li
|
||||
v-for="ticket in searchedTickets"
|
||||
:key="ticket.id"
|
||||
v-for="record in registeredTickets"
|
||||
:key="record.id"
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge
|
||||
v-if="ticket.registered && ticket.registeredAt"
|
||||
v-if="record.registeredAt"
|
||||
variant="secondary"
|
||||
class="text-[10px] font-mono px-1.5"
|
||||
>
|
||||
{{ fmtTime(ticket.registeredAt) }}
|
||||
{{ fmtTime(record.registeredAt) }}
|
||||
</Badge>
|
||||
<span v-if="ticket.name" class="font-medium text-foreground">
|
||||
{{ ticket.name }}
|
||||
<span v-if="record.name" class="font-medium text-foreground">
|
||||
{{ record.name }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
||||
{{ ticket.id }}
|
||||
{{ record.id }}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle2
|
||||
v-if="ticket.registered"
|
||||
class="w-4 h-4 text-emerald-500 shrink-0"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="shrink-0 gap-1"
|
||||
:disabled="pendingRegister.has(ticket.id)"
|
||||
@click="handleManualRegister(ticket)"
|
||||
>
|
||||
<RefreshCw
|
||||
v-if="pendingRegister.has(ticket.id)"
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
<UserCheck v-else class="w-3.5 h-3.5" />
|
||||
Register
|
||||
</Button>
|
||||
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
|
||||
</li>
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
<p
|
||||
v-else-if="allTickets.length === 0"
|
||||
class="text-sm text-muted-foreground text-center py-12"
|
||||
>
|
||||
No tickets sold yet.
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground text-center py-12">
|
||||
No tickets match “{{ searchQuery }}”.
|
||||
No tickets scanned yet.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -84,12 +84,8 @@
|
|||
|
||||
<!-- Step 1: Account Selection -->
|
||||
<div v-if="currentStep === 1">
|
||||
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 mb-4 text-sm">
|
||||
<span class="text-muted-foreground">Select the account for this expense.</span>
|
||||
<span class="inline-flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle class="h-4 w-4 shrink-0" />
|
||||
Use the "Other" account if you're not sure.
|
||||
</span>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Select the account for this expense
|
||||
</p>
|
||||
<AccountSelector
|
||||
v-model="selectedAccount"
|
||||
|
|
@ -107,17 +103,18 @@
|
|||
<FormLabel>Description *</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="A detailed description of the purchase/invoice/bill, and what it was used for (event/project/etc)..."
|
||||
placeholder="e.g., Biocoop, Ferme des Croquantes, Foix Market, etc"
|
||||
v-bind="componentField"
|
||||
rows="3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Describe what this expense was for
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<!-- Amount + Currency -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Amount -->
|
||||
<FormField v-slot="{ componentField }" name="amount">
|
||||
<FormItem>
|
||||
|
|
@ -131,6 +128,9 @@
|
|||
step="0.01"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Amount in selected currency
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
|
@ -141,7 +141,7 @@
|
|||
<FormLabel>Currency *</FormLabel>
|
||||
<Select v-bind="componentField">
|
||||
<FormControl>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
|
@ -155,21 +155,26 @@
|
|||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Currency for this expense
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- Reference (optional) -->
|
||||
<FormField v-slot="{ componentField }" name="reference">
|
||||
<FormItem>
|
||||
<FormLabel>Reference (optional)</FormLabel>
|
||||
<FormLabel>Reference</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., Invoice #123, Receipt #456"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional reference number or note
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
|
@ -274,7 +279,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { DollarSign, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock, AlertTriangle } from 'lucide-vue-next'
|
||||
import { DollarSign, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock } from 'lucide-vue-next'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
|
|
|||
|
|
@ -240,28 +240,21 @@ export default class WalletService extends BaseService {
|
|||
let endpoint = ''
|
||||
let body: any = {}
|
||||
|
||||
// Determine payment type and prepare request.
|
||||
// QR-scanned invoices are uppercase bech32, so detection must be
|
||||
// case-insensitive. Match BOLT11 by its HRP (lnbc / lntb / lntbs /
|
||||
// lnbcrt) rather than a bare "ln" prefix, which would also swallow
|
||||
// bech32 LNURLs and misroute them to the bolt11 endpoint.
|
||||
const dest = request.destination.trim()
|
||||
const lower = dest.toLowerCase()
|
||||
|
||||
if (lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lntbs') || lower.startsWith('lnbcrt')) {
|
||||
// Lightning invoice (BOLT11) — send the lowercase canonical form
|
||||
// Determine payment type and prepare request
|
||||
if (request.destination.startsWith('ln')) {
|
||||
// Lightning invoice
|
||||
endpoint = `${config.api.baseUrl}/api/v1/payments`
|
||||
body = {
|
||||
out: true,
|
||||
bolt11: lower
|
||||
bolt11: request.destination
|
||||
}
|
||||
} else if (dest.includes('@') || lower.startsWith('lnurl')) {
|
||||
} else if (request.destination.includes('@') || request.destination.toLowerCase().startsWith('lnurl')) {
|
||||
// Lightning address or LNURL
|
||||
endpoint = `${config.api.baseUrl}/api/v1/payments/lnurl`
|
||||
body = {
|
||||
lnurl: dest.includes('@')
|
||||
? `https://${dest.split('@')[1]}/.well-known/lnurlp/${dest.split('@')[0]}`
|
||||
: dest,
|
||||
lnurl: request.destination.includes('@')
|
||||
? `https://${request.destination.split('@')[1]}/.well-known/lnurlp/${request.destination.split('@')[0]}`
|
||||
: request.destination,
|
||||
amount: request.amount * 1000, // Convert to millisats
|
||||
comment: request.comment || ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,21 +231,41 @@ export class WalletWebSocketService extends BaseService {
|
|||
this.handlePaymentNotification(data.payment)
|
||||
}
|
||||
|
||||
// Handle wallet balance update.
|
||||
// `wallet_balance` is authoritative and already POST-payment for BOTH
|
||||
// directions: LNbits re-fetches the wallet AFTER the payment settles
|
||||
// before emitting the notification (core services/payments.py
|
||||
// `_send_payment_notification_in_background` → "fetch balance again" →
|
||||
// services/notifications.py `send_ws_payment_notification` →
|
||||
// `wallet.balance`). Use it as-is. The previous code subtracted the
|
||||
// outgoing amount on top, double-deducting (100 → send 10 → showed 80,
|
||||
// refresh corrected to 90).
|
||||
// Handle wallet balance update
|
||||
if (data.wallet_balance !== undefined) {
|
||||
console.log('WalletWebSocketService: Updating balance to', data.wallet_balance, 'sats', {
|
||||
console.log('WalletWebSocketService: Processing balance update', {
|
||||
newBalance: data.wallet_balance,
|
||||
hasPayment: !!data.payment,
|
||||
paymentAmount: data.payment?.amount
|
||||
})
|
||||
this.updateWalletBalance(data.wallet_balance)
|
||||
|
||||
let finalBalance = data.wallet_balance
|
||||
|
||||
// For outgoing payments, LNbits sends pre-payment balance, so we need to adjust
|
||||
// For incoming payments, LNbits sends post-payment balance, so use as-is
|
||||
if (data.payment && data.payment.amount < 0) {
|
||||
// Outgoing payment - subtract the payment amount from the balance
|
||||
const paymentSats = Math.abs(data.payment.amount) / 1000
|
||||
finalBalance = data.wallet_balance - paymentSats
|
||||
console.log('WalletWebSocketService: Adjusting balance for outgoing payment', {
|
||||
originalBalance: data.wallet_balance,
|
||||
paymentSats: paymentSats,
|
||||
finalBalance: finalBalance
|
||||
})
|
||||
} else if (data.payment && data.payment.amount > 0) {
|
||||
// Incoming payment - use balance as-is (already post-payment)
|
||||
console.log('WalletWebSocketService: Using balance as-is for incoming payment', {
|
||||
balance: data.wallet_balance
|
||||
})
|
||||
} else {
|
||||
// No payment in message - use balance as-is
|
||||
console.log('WalletWebSocketService: Using balance as-is (no payment)', {
|
||||
balance: data.wallet_balance
|
||||
})
|
||||
}
|
||||
|
||||
console.log('WalletWebSocketService: Updating balance to', finalBalance, 'sats')
|
||||
this.updateWalletBalance(finalBalance)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -13,29 +13,16 @@ import PreferencesRow from '@/components/layout/PreferencesRow.vue'
|
|||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
// The app registry. Today this is a static in-code catalog; the view below
|
||||
// treats it as an opaque list, so the source can later move to a runtime
|
||||
// feed (an LNbits /hub/apps response or a NIP-78 replaceable event) to
|
||||
// support installing/removing apps per-instance without a rebuild — without
|
||||
// touching the render path.
|
||||
interface Module {
|
||||
label: string
|
||||
chakra: string
|
||||
icon: any
|
||||
bgClass: string
|
||||
glow: string
|
||||
/** Env var holding this app's deployed URL. Absent/empty ⇒ not provisioned. */
|
||||
envKey?: string
|
||||
/** Maturity badge ('alpha' | 'beta' | …), shown under the label. */
|
||||
status?: string
|
||||
/** When true, the tile is ghosted out unless the user is logged in. */
|
||||
authRequired?: boolean
|
||||
/**
|
||||
* Soft kill-switch for a *provisioned* app: false ⇒ rendered greyed-out
|
||||
* and non-clickable ('inactive') even though it's deployed. Default (unset)
|
||||
* is active. Seam for a future install/disable model; no app sets it yet.
|
||||
*/
|
||||
active?: boolean
|
||||
/** Unread count for the corner badge. Wire to real data via #32. */
|
||||
unread?: number
|
||||
}
|
||||
|
|
@ -51,57 +38,17 @@ const modules: Module[] = [
|
|||
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Libra', chakra: 'Sahasrara', icon: Scale, bgClass: '', glow: 'rgba(160,80,220,0.5)', envKey: 'VITE_HUB_LIBRA_URL', status: 'beta', authRequired: true },
|
||||
]
|
||||
// Crown at top, root at bottom
|
||||
const orderedModules = computed(() => [...modules].reverse())
|
||||
|
||||
const token = computed(() => localStorage.getItem('lnbits_access_token') || '')
|
||||
|
||||
/**
|
||||
* Per-tile availability, resolved from deploy config (env URLs) + auth state.
|
||||
* Explicit states keep the render path declarative and make each future step
|
||||
* a small change rather than another overloaded null check.
|
||||
*/
|
||||
type Availability =
|
||||
| 'available' // provisioned + reachable → link
|
||||
| 'auth-locked' // provisioned, needs login, logged out → greyed, prompts login
|
||||
| 'inactive' // provisioned but switched off (active:false) → greyed, inert
|
||||
| 'unavailable' // not provisioned on this instance → hidden entirely
|
||||
|
||||
/** This app's deployed URL, or undefined when not provisioned on this build. */
|
||||
function appUrl(m: Module): string | undefined {
|
||||
const url = m.envKey ? (import.meta.env[m.envKey] as string | undefined) : undefined
|
||||
return url || undefined
|
||||
}
|
||||
|
||||
function availabilityOf(m: Module): Availability {
|
||||
if (!appUrl(m)) return 'unavailable'
|
||||
if (m.active === false) return 'inactive'
|
||||
if (m.authRequired && !isAuthenticated.value) return 'auth-locked'
|
||||
return 'available'
|
||||
}
|
||||
|
||||
interface Tile {
|
||||
module: Module
|
||||
availability: Availability
|
||||
/** Final href (with auth token appended) — only set for 'available'. */
|
||||
href?: string
|
||||
}
|
||||
|
||||
// Crown at top, root at bottom. Unavailable (not-deployed) apps are dropped
|
||||
// here, so the grid only ever renders provisioned tiles.
|
||||
const tiles = computed<Tile[]>(() =>
|
||||
[...modules]
|
||||
.reverse()
|
||||
.map((m) => {
|
||||
const availability = availabilityOf(m)
|
||||
return {
|
||||
module: m,
|
||||
availability,
|
||||
href: availability === 'available' ? withToken(appUrl(m)!) : undefined,
|
||||
}
|
||||
})
|
||||
.filter((t) => t.availability !== 'unavailable'),
|
||||
)
|
||||
|
||||
function withToken(url: string): string {
|
||||
function hubLink(m: Module): string | null {
|
||||
if (!m.envKey) return null
|
||||
// Auth-only modules (wallet, chat, libra, tasks) are ghosted when not logged in.
|
||||
if (m.authRequired && !isAuthenticated.value) return null
|
||||
const url = import.meta.env[m.envKey] as string | undefined
|
||||
if (!url) return null
|
||||
if (isAuthenticated.value && token.value) {
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
return `${url}${sep}token=${encodeURIComponent(token.value)}`
|
||||
|
|
@ -109,35 +56,22 @@ function withToken(url: string): string {
|
|||
return url
|
||||
}
|
||||
|
||||
function tileTag(t: Tile): 'a' | 'button' | 'div' {
|
||||
if (t.availability === 'available') return 'a'
|
||||
if (t.availability === 'auth-locked') return 'button'
|
||||
return 'div' // inactive
|
||||
function isAuthGated(m: Module): boolean {
|
||||
return !!(m.authRequired && !isAuthenticated.value)
|
||||
}
|
||||
|
||||
function tileClass(t: Tile): string {
|
||||
switch (t.availability) {
|
||||
case 'available':
|
||||
return 'bg-card/60 hover:bg-card/40 border-white/10 hover:border-white/25 cursor-pointer'
|
||||
case 'auth-locked':
|
||||
return 'bg-card/70 hover:bg-card/55 border-white/5 opacity-60 cursor-pointer'
|
||||
default: // inactive
|
||||
return 'bg-card/70 border-white/5 opacity-60 cursor-not-allowed'
|
||||
}
|
||||
}
|
||||
|
||||
function onTileClick(t: Tile, event: Event) {
|
||||
// Auth-locked tiles aren't anchors; intercept and prompt login.
|
||||
if (t.availability === 'auth-locked') {
|
||||
function onTileClick(m: Module, event: Event) {
|
||||
// Ghosted auth-required tiles aren't anchors; intercept and toast.
|
||||
if (isAuthGated(m)) {
|
||||
event.preventDefault()
|
||||
toast.info(`${t.module.label} requires login`, {
|
||||
toast.info(`${m.label} requires login`, {
|
||||
action: {
|
||||
label: 'Log in',
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
}
|
||||
// 'inactive' tiles silently do nothing.
|
||||
// "Coming soon" tiles (no envKey, no authRequired) silently do nothing.
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -168,27 +102,33 @@ function onTileClick(t: Tile, event: Event) {
|
|||
|
||||
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
||||
<component
|
||||
v-for="t in tiles"
|
||||
:key="t.module.label"
|
||||
:is="tileTag(t)"
|
||||
:href="t.href"
|
||||
v-for="m in orderedModules"
|
||||
:key="m.label"
|
||||
:is="hubLink(m) ? 'a' : (isAuthGated(m) ? 'button' : 'div')"
|
||||
:href="hubLink(m) || undefined"
|
||||
class="relative flex flex-col items-center justify-center gap-1 rounded-xl border backdrop-blur-sm transition-all p-2 min-h-0"
|
||||
:class="tileClass(t)"
|
||||
@click="onTileClick(t, $event)"
|
||||
:class="[
|
||||
hubLink(m)
|
||||
? 'bg-card/60 hover:bg-card/40 border-white/10 hover:border-white/25 cursor-pointer'
|
||||
: isAuthGated(m)
|
||||
? 'bg-card/70 hover:bg-card/55 border-white/5 opacity-60 cursor-pointer'
|
||||
: 'bg-card/70 border-white/5 opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
@click="onTileClick(m, $event)"
|
||||
>
|
||||
<component :is="t.module.icon" class="h-7 w-7 text-foreground/90" :style="{ filter: `drop-shadow(0 0 8px ${t.module.glow})` }" />
|
||||
<component :is="m.icon" class="h-7 w-7 text-foreground/90" :style="{ filter: `drop-shadow(0 0 8px ${m.glow})` }" />
|
||||
<div class="text-center leading-tight">
|
||||
<p class="text-sm font-semibold text-foreground drop-shadow">{{ t.module.label }}</p>
|
||||
<p v-if="t.module.status" class="text-[9px] font-light text-muted-foreground">{{ t.module.status }}</p>
|
||||
<p class="text-sm font-semibold text-foreground drop-shadow">{{ m.label }}</p>
|
||||
<p v-if="m.status" class="text-[9px] font-light text-muted-foreground">{{ m.status }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Notification badge — wired to data once #32 lands. Hidden when unread is falsy/0. -->
|
||||
<span
|
||||
v-if="t.module.unread"
|
||||
v-if="m.unread"
|
||||
class="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-semibold flex items-center justify-center shadow ring-1 ring-background/60"
|
||||
:aria-label="`${t.module.unread} unread`"
|
||||
:aria-label="`${m.unread} unread`"
|
||||
>
|
||||
{{ t.module.unread > 99 ? '99+' : t.module.unread }}
|
||||
{{ m.unread > 99 ? '99+' : m.unread }}
|
||||
</span>
|
||||
</component>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<!-- Logo and Title -->
|
||||
<div class="text-center space-y-6">
|
||||
<div class="flex justify-center">
|
||||
<img src="@brand-app-logo" alt="Logo" class="h-24 w-24 sm:h-32 sm:w-32" />
|
||||
<img src="@/assets/logo.png" alt="Logo" class="h-24 w-24 sm:h-32 sm:w-32" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Virtual Realm</h1>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<!-- Welcome Section -->
|
||||
<div class="text-center space-y-2 sm:space-y-4">
|
||||
<div class="flex justify-center">
|
||||
<img src="@brand-app-logo" alt="Logo" class="h-34 w-34 sm:h-42 sm:w-42 lg:h-50 lg:w-50" />
|
||||
<img src="@/assets/logo.png" alt="Logo" class="h-34 w-34 sm:h-42 sm:w-42 lg:h-50 lg:w-50" />
|
||||
</div>
|
||||
<div class="space-y-1 sm:space-y-3">
|
||||
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight">Welcome to the Virtual Realm</h1>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,31 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
// Brand-kit alias for the active standalone's logo. Resolved at build
|
||||
// time by vite-branding.ts (per-standalone override or global). The
|
||||
// `?url` import returns the asset's URL string just like any other
|
||||
// vite static asset.
|
||||
declare module '@brand-app-logo?url' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
// Optional brand banner (wide logo+wordmark lockup). Resolved at build
|
||||
// time by vite-branding.ts; falls back to the logo when the active brand
|
||||
// ships no banner, so the import always resolves. The component gates on
|
||||
// `VITE_APP_BANNER` before rendering it.
|
||||
declare module '@brand-app-banner?url' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** Brand name, set from brand.json in vite.<app>.config.ts. */
|
||||
readonly VITE_APP_NAME?: string
|
||||
/** '1' when the active brand ships a banner, '' otherwise. */
|
||||
readonly VITE_APP_BANNER?: string
|
||||
/** Brand default theme mode ('light'|'dark'|'system'), set from brand.json. */
|
||||
readonly VITE_BRAND_THEME?: string
|
||||
/** Brand default palette name, set from brand.json. */
|
||||
readonly VITE_BRAND_PALETTE?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Tasks — Work Orders</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Tasks">
|
||||
<meta name="description" content="Decentralized task management on Nostr">
|
||||
|
|
|
|||
208
vite-branding.ts
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
|
||||
|
||||
function chatHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -46,7 +45,6 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
chatHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -61,19 +59,20 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Chat — Encrypted',
|
||||
short_name: 'Chat',
|
||||
description: 'End-to-end encrypted Nostr chat',
|
||||
theme_color: brand.themeColor ?? '#16a34a',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
theme_color: '#16a34a',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -82,10 +81,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['social', 'communication'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -103,16 +102,11 @@ export default defineConfig(({ mode }) => ({
|
|||
}),
|
||||
],
|
||||
resolve: {
|
||||
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
|
||||
// with optional `?url` query) doesn't get shadowed by the bare aliases.
|
||||
alias: [
|
||||
brandAppLogoAliasEntry('chat'),
|
||||
brandHubLogoAliasEntry(),
|
||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||
alias: {
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/chat-app/app.config.ts', import.meta.url)) },
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
],
|
||||
'@/app.config': fileURLToPath(new URL('./src/chat-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-chat',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { defineConfig } from 'vite'
|
|||
import Inspect from 'vite-plugin-inspect'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
|
||||
|
||||
// https://vite.dev/config/
|
||||
//
|
||||
|
|
@ -13,11 +12,6 @@ import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubL
|
|||
// the entire origin and blocked Chrome from offering installs for the
|
||||
// path-mounted standalones at /libra/, /market/, etc. The hub is a
|
||||
// launcher page; users install the standalones they actually use.
|
||||
|
||||
// Brand name flows into index.html's `%VITE_APP_NAME% Hub` title via
|
||||
// Vite's HTML env-var substitution.
|
||||
process.env.VITE_APP_NAME = brand.name
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-hub',
|
||||
|
|
@ -26,7 +20,6 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
Inspect(),
|
||||
|
|
@ -49,14 +42,9 @@ export default defineConfig(({ mode }) => ({
|
|||
})
|
||||
],
|
||||
resolve: {
|
||||
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
|
||||
// with optional `?url` query) doesn't get shadowed by the bare alias.
|
||||
alias: [
|
||||
brandAppLogoAliasEntry(),
|
||||
brandHubLogoAliasEntry(),
|
||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
]
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
|
|
|
|||
|
|
@ -5,16 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import {
|
||||
brand,
|
||||
brandAlias,
|
||||
brandAppBannerAliasEntry,
|
||||
brandAppLogoAliasEntry,
|
||||
brandAssetsPlugin,
|
||||
brandHubLogoAliasEntry,
|
||||
brandManifestName,
|
||||
resolveAppBanner,
|
||||
} from './vite-branding'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to events.html
|
||||
|
|
@ -51,19 +41,14 @@ function eventsHtmlPlugin(): Plugin {
|
|||
* VITE_BASE_PATH=/events/ → app.ariege.io/events/ (shared auth)
|
||||
* (default: /) → bouge.ariege.io (standalone subdomain)
|
||||
*
|
||||
* Brand name resolves from brand.json under $BRAND_DIR (see
|
||||
* vite-branding.ts and aiolabs/webapp#95). Surfaced into Vite's HTML
|
||||
* env-var substitution as VITE_APP_NAME for templated titles.
|
||||
* Set VITE_APP_NAME to brand the standalone (PWA name, HTML title, console
|
||||
* logs). cfaun overrides to "Bouge" via NixOS. Defaults to "Events".
|
||||
*/
|
||||
const APP_NAME = brandManifestName()
|
||||
const APP_NAME = process.env.VITE_APP_NAME || 'Events'
|
||||
// Surface the resolved value back into env so Vite's HTML %VITE_APP_NAME%
|
||||
// substitution picks up the fallback when nothing was explicitly set.
|
||||
process.env.VITE_APP_NAME = APP_NAME
|
||||
|
||||
// When the active brand ships a banner (wide logo+wordmark lockup), the
|
||||
// events header renders it in place of the logo + name pair. Surfaced as
|
||||
// a '1'/'' flag the component reads; the actual file comes through the
|
||||
// @brand-app-banner alias below. See branding/README.md.
|
||||
process.env.VITE_APP_BANNER = resolveAppBanner('events') ? '1' : ''
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
|
|
@ -73,7 +58,6 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
eventsHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -91,19 +75,20 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: APP_NAME,
|
||||
short_name: brand.shortName ?? APP_NAME,
|
||||
short_name: APP_NAME,
|
||||
description: 'Discover events near you',
|
||||
theme_color: brand.themeColor ?? '#1f2937',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
theme_color: '#1f2937',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -111,10 +96,10 @@ export default defineConfig(({ mode }) => ({
|
|||
id: 'aiolabs-events',
|
||||
categories: ['social', 'entertainment', 'lifestyle'],
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -132,16 +117,9 @@ export default defineConfig(({ mode }) => ({
|
|||
}),
|
||||
],
|
||||
resolve: {
|
||||
// Array form so we can mix the per-standalone logo regex (needs to
|
||||
// match `@brand-app-logo?url` query suffix) with the bare string
|
||||
// aliases without one shadowing the other.
|
||||
alias: [
|
||||
brandAppLogoAliasEntry('events'),
|
||||
brandAppBannerAliasEntry('events'),
|
||||
brandHubLogoAliasEntry(),
|
||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
],
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-events',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
|
||||
|
||||
function forumHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -46,7 +45,6 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
forumHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -61,19 +59,20 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Forum — Discussions',
|
||||
short_name: 'Forum',
|
||||
description: 'Decentralized link aggregator and discussion forum on Nostr',
|
||||
theme_color: brand.themeColor ?? '#2563eb',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
theme_color: '#2563eb',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -82,10 +81,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['social', 'news'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -103,16 +102,11 @@ export default defineConfig(({ mode }) => ({
|
|||
}),
|
||||
],
|
||||
resolve: {
|
||||
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
|
||||
// with optional `?url` query) doesn't get shadowed by the bare aliases.
|
||||
alias: [
|
||||
brandAppLogoAliasEntry('forum'),
|
||||
brandHubLogoAliasEntry(),
|
||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||
alias: {
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)) },
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
],
|
||||
'@/app.config': fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-forum',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to libra.html
|
||||
|
|
@ -51,7 +50,6 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
libraHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -68,19 +66,20 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Libra — Team Accounting',
|
||||
short_name: 'Libra',
|
||||
description: 'Team accounting and expense management',
|
||||
theme_color: brand.themeColor ?? '#1f2937',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
theme_color: '#1f2937',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -89,10 +88,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['finance', 'business', 'productivity'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -110,20 +109,15 @@ export default defineConfig(({ mode }) => ({
|
|||
}),
|
||||
],
|
||||
resolve: {
|
||||
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
|
||||
// with optional `?url` query) doesn't get shadowed by the bare aliases.
|
||||
alias: [
|
||||
brandAppLogoAliasEntry('libra'),
|
||||
brandHubLogoAliasEntry(),
|
||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||
alias: {
|
||||
// ORDER MATTERS — @rollup/plugin-alias is first-match-wins.
|
||||
// The more specific @/app.config remap must precede the @ prefix
|
||||
// alias, otherwise '@/app.config' matches '@' first and resolves
|
||||
// to ./src/app.config (the hub config). ExpensesAPI etc. import
|
||||
// from @/app.config and need the per-app config.
|
||||
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/accounting-app/app.config.ts', import.meta.url)) },
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
],
|
||||
'@/app.config': fileURLToPath(new URL('./src/accounting-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-libra',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
|
||||
|
||||
function marketHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -46,7 +45,6 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
marketHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -61,19 +59,20 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Market — Nostr',
|
||||
short_name: 'Market',
|
||||
description: 'Decentralized marketplace on Nostr with Lightning payments',
|
||||
theme_color: brand.themeColor ?? '#dc2626',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
theme_color: '#dc2626',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -82,10 +81,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['shopping', 'business'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -103,16 +102,11 @@ export default defineConfig(({ mode }) => ({
|
|||
}),
|
||||
],
|
||||
resolve: {
|
||||
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
|
||||
// with optional `?url` query) doesn't get shadowed by the bare aliases.
|
||||
alias: [
|
||||
brandAppLogoAliasEntry('market'),
|
||||
brandHubLogoAliasEntry(),
|
||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||
alias: {
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)) },
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
],
|
||||
'@/app.config': fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-market',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
|
||||
|
||||
function restaurantHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -51,7 +50,6 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
restaurantHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -66,12 +64,13 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Restaurant — Order',
|
||||
|
|
@ -79,8 +78,8 @@ export default defineConfig(({ mode }) => ({
|
|||
description: 'Order from your local Nostr-native restaurant with Lightning payments',
|
||||
// Green to differentiate from market red. PDF tile is purple
|
||||
// (see ~/dev/shared/extensions/restaurant/static/image/restaurant.png).
|
||||
theme_color: brand.themeColor ?? '#16a34a',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
theme_color: '#16a34a',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -89,10 +88,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['food', 'shopping'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -110,16 +109,11 @@ export default defineConfig(({ mode }) => ({
|
|||
}),
|
||||
],
|
||||
resolve: {
|
||||
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
|
||||
// with optional `?url` query) doesn't get shadowed by the bare aliases.
|
||||
alias: [
|
||||
brandAppLogoAliasEntry('restaurant'),
|
||||
brandHubLogoAliasEntry(),
|
||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||
alias: {
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/restaurant-app/app.config.ts', import.meta.url)) },
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
],
|
||||
'@/app.config': fileURLToPath(new URL('./src/restaurant-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-restaurant',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
|
||||
|
||||
function tasksHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -46,7 +45,6 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
tasksHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -61,19 +59,20 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Tasks — Work Orders',
|
||||
short_name: 'Tasks',
|
||||
description: 'Decentralized task management on Nostr',
|
||||
theme_color: brand.themeColor ?? '#4338ca',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
theme_color: '#4338ca',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -82,10 +81,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['productivity', 'business'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -103,16 +102,11 @@ export default defineConfig(({ mode }) => ({
|
|||
}),
|
||||
],
|
||||
resolve: {
|
||||
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
|
||||
// with optional `?url` query) doesn't get shadowed by the bare aliases.
|
||||
alias: [
|
||||
brandAppLogoAliasEntry('tasks'),
|
||||
brandHubLogoAliasEntry(),
|
||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||
alias: {
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/tasks-app/app.config.ts', import.meta.url)) },
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
],
|
||||
'@/app.config': fileURLToPath(new URL('./src/tasks-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-tasks',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAppLogoAliasEntry, brandAssetsPlugin, brandHubLogoAliasEntry } from './vite-branding'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to wallet.html
|
||||
|
|
@ -50,7 +49,6 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
walletHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -67,19 +65,20 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Wallet — Lightning',
|
||||
short_name: 'Wallet',
|
||||
description: 'Lightning Network wallet — send, receive, and manage sats',
|
||||
theme_color: brand.themeColor ?? '#eab308',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
theme_color: '#eab308',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -88,10 +87,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['finance'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -109,16 +108,11 @@ export default defineConfig(({ mode }) => ({
|
|||
}),
|
||||
],
|
||||
resolve: {
|
||||
// Array form so the per-standalone logo regex (matches `@brand-app-logo`
|
||||
// with optional `?url` query) doesn't get shadowed by the bare aliases.
|
||||
alias: [
|
||||
brandAppLogoAliasEntry('wallet'),
|
||||
brandHubLogoAliasEntry(),
|
||||
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
|
||||
alias: {
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
{ find: '@/app.config', replacement: fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)) },
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
],
|
||||
'@/app.config': fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-wallet',
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
@ -6,8 +6,9 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Wallet — Lightning</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Wallet">
|
||||
<meta name="description" content="Lightning Network wallet — send, receive, and manage sats">
|
||||
|
|
|
|||