Compare commits
148 commits
main
...
feat/chate
| Author | SHA1 | Date | |
|---|---|---|---|
| fb0478c028 | |||
| 7dbabfaa81 | |||
| 6ea94692a1 | |||
| ded2fd10f8 | |||
| 56e1d7a780 | |||
| c369beab92 | |||
| 3e55decfad | |||
| 718d6e76e1 | |||
| 3eb45b04a7 | |||
| 59a5046437 | |||
| 65770ad9be | |||
| 3720dbc35a | |||
| 654be4bf8a | |||
| 8b5baeb06d | |||
| 7dbe04afa3 | |||
| a1b9aaf2a2 | |||
| 4162e0af99 | |||
| 262e674f9f | |||
| a68fed916d | |||
| 0a21c092ff | |||
| 9c0abfae1f | |||
| 4faa57f87a | |||
| b080d802c5 | |||
| 6e4e2f3d0c | |||
| 82f7e5659a | |||
| 9eb75a2017 | |||
| cd70e65425 | |||
| 1e43e8ed0a | |||
| 411ac0c93c | |||
| cbd4159f72 | |||
| 89a1fcb030 | |||
| 5d3ebcc5e4 | |||
| b853fbce4b | |||
| bd78e3227d | |||
| 891b5072f8 | |||
| f85848a50b | |||
| 0c5b577499 | |||
| c035fc2bfc | |||
| 9f8f2a233b | |||
| 8a9ea269de | |||
| 87f86647e7 | |||
| 3ffb3bf983 | |||
| 49e7afb0ab | |||
| 807f8200b1 | |||
| eb9ae54215 | |||
| afb57a3918 | |||
| 36791c8121 | |||
| 2cc8e34b9d | |||
| 8419ca4660 | |||
| a7fd686cdd | |||
| f3612f82ac | |||
| 0e98a21db3 | |||
| c037d45255 | |||
| bdf015f817 | |||
| 8ade942c32 | |||
| 83ea3e609c | |||
| 3514d93451 | |||
| 2febf0926d | |||
| c6f626df08 | |||
| 4b3b905225 | |||
| 327092c022 | |||
| db4c9b8bf3 | |||
| 628f85a074 | |||
| a9d39b341e | |||
| 8f85a5819b | |||
| 35c62d6ff1 | |||
| 568e6267d8 | |||
| fddc26387e | |||
| e17b4e05ad | |||
| b6d1626951 | |||
| 3d37b7393d | |||
| 4f4452057a | |||
| 9810b11cc5 | |||
| 9d98f3fdf2 | |||
| 76af245192 | |||
| e54938ef06 | |||
| 5753e34499 | |||
| b665e3de07 | |||
| f7642db611 | |||
| 8c9a8ab945 | |||
| 3d1b888307 | |||
| c53bc525ef | |||
| 1433a2039b | |||
| 084bfc52eb | |||
| cdc8158e1b | |||
| 52e9d11ea9 | |||
| 32a7389d0b | |||
| c9fc3652bb | |||
| 1249d33aac | |||
| 8a0f40910a | |||
| 1c004df99c | |||
| f20b404d09 | |||
| 79e20f1e07 | |||
| 1f68660783 | |||
| 2e55a45ed6 | |||
| d4d088fb50 | |||
| 8f2c401e00 | |||
| 138f9905bf | |||
| db497d8a06 | |||
| 42bff96c58 | |||
| c8930aee3e | |||
| 4b8fd34bba | |||
| 5700ac1d1a | |||
| 80b8219494 | |||
| 443c8b6a37 | |||
| 5e3d77efec | |||
| 60369ce1b1 | |||
| 6d4a9f8c22 | |||
| c6455b3235 | |||
| 7af0f364cb | |||
| 00f6a99f92 | |||
| 2c7597c25f | |||
| 4f7a5dcd88 | |||
| 54efff1cc6 | |||
| cf9fc2db9d | |||
| b7fd8c99e5 | |||
| 31e48c8f1d | |||
| ba7c1e4cdc | |||
| 4de88f92f5 | |||
| 1dfb025df3 | |||
| e86be3229d | |||
| e174048052 | |||
| 85419ea5fa | |||
| 5f6eb5b142 | |||
| 37c2b78836 | |||
| d92690d3f1 | |||
| fb0f687c07 | |||
| d871093168 | |||
| 247b0a6d5b | |||
| de03fac69f | |||
| f2967616d3 | |||
| 4fc026d14d | |||
| 378a16d621 | |||
| b0ee932e77 | |||
| 0ede6f70db | |||
| 14283f62e0 | |||
| 08568fc0c0 | |||
| fadf5407a5 | |||
| be427f1821 | |||
| 3dfed23b43 | |||
| 3efae30e84 | |||
| faf41cd1c0 | |||
| 4e7502b30c | |||
| ce5a1a6a56 | |||
| 88ab432629 | |||
| eebb566323 | |||
| 50a345ce4e | |||
| a8c997ca8d |
1
.gitignore
vendored
|
|
@ -29,6 +29,7 @@ dist.tar.gz
|
||||||
|
|
||||||
# auto-generated build file for PWA
|
# auto-generated build file for PWA
|
||||||
dev-dist/sw.js
|
dev-dist/sw.js
|
||||||
|
public/icons/
|
||||||
aio-shadcn-vite.code-workspace
|
aio-shadcn-vite.code-workspace
|
||||||
dev-dist
|
dev-dist
|
||||||
.specstory/history
|
.specstory/history
|
||||||
|
|
|
||||||
55
CLAUDE.md
|
|
@ -712,8 +712,63 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
|
||||||
|
|
||||||
# Optional: Disable WebSocket if needed
|
# Optional: Disable WebSocket if needed
|
||||||
VITE_WEBSOCKET_ENABLED=true
|
VITE_WEBSOCKET_ENABLED=true
|
||||||
|
|
||||||
|
# Brand kit override (defaults to ./branding/default)
|
||||||
|
BRAND_DIR=branding/cfaun
|
||||||
|
|
||||||
|
# Per-standalone brand override (set by build pipeline, not directly)
|
||||||
|
BRAND_APP=events
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Brand kit (white-label PWA branding)
|
||||||
|
|
||||||
|
The webapp ships a brand kit architecture so the hub + every standalone
|
||||||
|
(events, wallet, chat, market, …) can be rebranded per deployment without
|
||||||
|
forking the codebase. See `branding/README.md` for the deployer contract.
|
||||||
|
|
||||||
|
**Single source of truth:** `branding/<dep>/` holds `logo.{svg,png}` +
|
||||||
|
`brand.json`. `vite-branding.ts` reads brand.json and exposes a `@brand`
|
||||||
|
import alias. `pwa-assets.config.ts` + `@vite-pwa/assets-generator` derive
|
||||||
|
the full PWA icon set from the single logo source.
|
||||||
|
|
||||||
|
**brand.json schema:** `{ name, shortName?, themeColor?, backgroundColor? }`
|
||||||
|
— `name` drives the manifest. `themeColor`/`backgroundColor` are optional
|
||||||
|
chrome overrides; when unset, each standalone's per-app accent applies.
|
||||||
|
|
||||||
|
**In-app logo:** components reference `@brand/logo.png`. Active consumers:
|
||||||
|
`Login.vue`, `LoginDemo.vue`, `AppSidebar.vue`, `MobileDrawer.vue`. The
|
||||||
|
Vite alias resolves to the active brand dir at build time.
|
||||||
|
|
||||||
|
**Generated icons:** `public/icons/` is gitignored. `brandAssetsPlugin()`
|
||||||
|
(registered first in every `vite.*.config.ts`'s plugins[]) runs the
|
||||||
|
generator once per build/dev start via `buildStart`. Outputs match the
|
||||||
|
existing filename convention (`icon-192.png`, `icon-maskable-512.png`,
|
||||||
|
…) so HTML `<link>` hrefs and VitePWA `manifest.icons` reference
|
||||||
|
`/icons/<name>` consistently across all 9 configs.
|
||||||
|
|
||||||
|
**Per-standalone override:** `branding/<dep>/icons/<app>/logo.{svg,png}`
|
||||||
|
is checked before the brand's primary logo. The standalone build sets
|
||||||
|
`BRAND_APP`; deployers just put files in the right place.
|
||||||
|
|
||||||
|
**Switching brands:**
|
||||||
|
```bash
|
||||||
|
BRAND_DIR=branding/cfaun pnpm build:events
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adding a new in-app logo consumer:** use `<img src="@brand/logo.png">`
|
||||||
|
instead of `@/assets/logo.png`. The latter still works for non-brand
|
||||||
|
assets (`@/assets/bitcoin.svg`, etc.) — it's only the logo that moved.
|
||||||
|
|
||||||
|
**NixOS deployment:** `flake.nix` exposes
|
||||||
|
`lib.mkWebapp { pkgs, brandDir ? ./branding/default, app ? "main" }`.
|
||||||
|
Server-deploy hosts call it from their `services/webapp.nix`:
|
||||||
|
`inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "events"; }`.
|
||||||
|
Builder pins `pkgs.pnpm_10` regardless of consumer's nixpkgs (keeps
|
||||||
|
pnpmDeps hash stable downstream), uses `autoPatchelfHook` to handle
|
||||||
|
prebuilt sharp binaries, and sets `CI=true` to bypass pnpm's
|
||||||
|
interactive modules-purge prompt. Per-host migration tracked in
|
||||||
|
aiolabs/server-deploy#8.
|
||||||
|
|
||||||
## Payment Rails Pattern
|
## Payment Rails Pattern
|
||||||
|
|
||||||
Shared primitives for modules that mix Lightning + fiat (and, future,
|
Shared primitives for modules that mix Lightning + fiat (and, future,
|
||||||
|
|
|
||||||
172
branding/README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# 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"; }'
|
||||||
|
```
|
||||||
4
branding/default/brand.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"name": "AIO",
|
||||||
|
"shortName": "AIO"
|
||||||
|
}
|
||||||
BIN
branding/default/logo.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
|
|
@ -6,9 +6,8 @@
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
|
||||||
<title>Chat — Encrypted</title>
|
<title>Chat — Encrypted</title>
|
||||||
<meta name="apple-mobile-web-app-title" content="Chat">
|
<meta name="apple-mobile-web-app-title" content="Chat">
|
||||||
<meta name="description" content="End-to-end encrypted Nostr chat">
|
<meta name="description" content="End-to-end encrypted Nostr chat">
|
||||||
|
|
|
||||||
19
chatelet.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="icon" href="/icons/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<title>%VITE_APP_NAME%</title>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||||
|
<meta name="description" content="Book a room">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/chatelet-app/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
76
docs/02-modules/events-module/guest-checkout.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Events — guest checkout, fiat return flow and ticket delivery
|
||||||
|
|
||||||
|
Buyers can purchase **paid** tickets without an LNbits account. This note is
|
||||||
|
the wire contract between the webapp and the `aiolabs/events` extension
|
||||||
|
(≥ v1.6.1-aio.8) for that flow. Free tickets still require a login
|
||||||
|
(events#29: no anonymous free issuance).
|
||||||
|
|
||||||
|
## Identity on `POST /events/api/v1/tickets/{event_id}`
|
||||||
|
|
||||||
|
| Buyer | Body fields | Auth header |
|
||||||
|
|---|---|---|
|
||||||
|
| Logged in | `user_id` + `email` (delivery address, prefilled from the account, editable) | `Authorization: Bearer <token>` |
|
||||||
|
| Guest | `name` + `email` | none (the endpoint is anonymous; the instance `X-API-KEY` is always sent) |
|
||||||
|
|
||||||
|
Every purchase also sends `frontend_url` — this app's root
|
||||||
|
(`window.location.origin + import.meta.env.BASE_URL`, no trailing slash;
|
||||||
|
`src/modules/events/lib/frontendRoot.ts`). The extension only accepts it when
|
||||||
|
its origin is on the LNbits `LNBITS_CORS_ALLOWED_ORIGINS` allowlist (or the
|
||||||
|
LNbits base / custom-frontend origin); otherwise the request fails with
|
||||||
|
`frontend_url origin is not allowed.` and the dialog shows that message.
|
||||||
|
|
||||||
|
`payment_method` is `lightning` or `fiat`. The event's enabled rails come from
|
||||||
|
the NIP-52 tag `tickets_payment_methods` (`"lightning,fiat"`); when the tag is
|
||||||
|
absent the legacy rule applies (Lightning always, fiat iff
|
||||||
|
`tickets_allow_fiat`). See `lib/paymentMethods.ts`. A rail that is off is
|
||||||
|
rejected by the backend with `Payment method not enabled for this event.`
|
||||||
|
|
||||||
|
## Return from the fiat provider
|
||||||
|
|
||||||
|
The extension composes both Stripe URLs under `frontend_url`:
|
||||||
|
|
||||||
|
| Purpose | URL |
|
||||||
|
|---|---|
|
||||||
|
| success | `<root>/events/{event_id}?checkout=success&tickets=<id1,id2>` |
|
||||||
|
| cancel | `<root>/events/{event_id}?checkout=cancelled` |
|
||||||
|
|
||||||
|
Before redirecting (same tab, `window.location.assign`), the purchase dialog
|
||||||
|
writes `localStorage['events:pending-checkout']` =
|
||||||
|
`{ eventId, paymentHash, ticketIds, email, ts }` (`lib/pendingCheckout.ts`).
|
||||||
|
`EventDetailPage` strips the query on arrival, then:
|
||||||
|
|
||||||
|
- `success` → opens `CheckoutReturnDialog`, which polls the anonymous
|
||||||
|
`GET /events/api/v1/tickets/{ticket_id}` for every id every 2 s (60 s budget,
|
||||||
|
retry button) until all are `paid`, then renders one QR per ticket, the
|
||||||
|
"emailed to" note (email from the localStorage record) and a link to the
|
||||||
|
ticket page. Logged-in buyers also get `useOwnedTickets().refresh()`.
|
||||||
|
- `cancelled` → info toast, record cleared. Nothing was charged; the
|
||||||
|
extension purges unpaid rows after 24 h.
|
||||||
|
|
||||||
|
## Public ticket page
|
||||||
|
|
||||||
|
`/events/ticket/:ticketId` (`views/TicketPage.vue`, `requiresAuth: false`) is
|
||||||
|
where the emailed link lands. It reads the `PublicTicket` (no id / email /
|
||||||
|
user_id in the payload; the id in the URL is the door credential, exactly as
|
||||||
|
in the LNbits UI), resolves the event name (Nostr store → scoped relay query
|
||||||
|
→ public REST, which 410s on sold-out events and is treated as non-fatal) and
|
||||||
|
renders the QR.
|
||||||
|
|
||||||
|
## QR rendering
|
||||||
|
|
||||||
|
All `ticket://<id>` QRs (purchase success, return dialog, ticket page, My
|
||||||
|
Tickets) go through `lib/ticketQr.ts`: error-correction level H with the
|
||||||
|
brand logo (`@brand/logo.png`) centred at ≤ 20 % width on a white pad, so the
|
||||||
|
in-app image matches the PNG the extension embeds in the ticket email
|
||||||
|
(`GET /events/api/v1/qr/{ticket_id}`). Canvas failures fall back to the plain
|
||||||
|
QR.
|
||||||
|
|
||||||
|
## Organizer side
|
||||||
|
|
||||||
|
`CreateEventDialog` publishes `extra.payment_methods` (Lightning / Card
|
||||||
|
checkboxes; at least one) and keeps `allow_fiat` in sync for older readers.
|
||||||
|
Card is disabled with a tooltip when the organizer's LNbits user has no fiat
|
||||||
|
provider (`useFiatProviders().hasAnyProvider`).
|
||||||
|
|
||||||
|
`resendTicketEmail` returns the structured `TicketResendResult`
|
||||||
|
(`{ ticket, email: {attempted, sent, error}, nostr: {...} }`).
|
||||||
|
|
@ -182,6 +182,13 @@ interface ModulePlugin {
|
||||||
|
|
||||||
**See:** [[market-module/index|📖 Market Module Documentation]]
|
**See:** [[market-module/index|📖 Market Module Documentation]]
|
||||||
|
|
||||||
|
### **Events Module** 🎟️
|
||||||
|
**Purpose:** Ticketed events (Nostr NIP-52 feed + LNbits events extension)
|
||||||
|
**Location:** `src/modules/events/`
|
||||||
|
**Dependencies:** `['base']`
|
||||||
|
|
||||||
|
**See:** [[events-module/guest-checkout|📖 Guest checkout, fiat return flow and ticket delivery]]
|
||||||
|
|
||||||
## Module Development
|
## Module Development
|
||||||
|
|
||||||
### **Creating a New Module**
|
### **Creating a New Module**
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,19 @@ in this file follows from that single fact.
|
||||||
|
|
||||||
## Strictly-monotonic `created_at` per coord
|
## Strictly-monotonic `created_at` per coord
|
||||||
|
|
||||||
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
**Canonical helper:** `src/lib/nostr/timestamp.ts` —
|
||||||
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
|
`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).
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
|
||||||
|
|
||||||
const lastPublishAt = new Map<string, number>()
|
const lastPublishAt = new Map<string, number>()
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const createdAt = monotonicCreatedAt(lastPublishAt.get(coord))
|
||||||
const previous = lastPublishAt.get(coord) ?? 0
|
|
||||||
const createdAt = Math.max(now, previous + 1)
|
|
||||||
…
|
…
|
||||||
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
|
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
|
||||||
<title>%VITE_APP_NAME%</title>
|
<title>%VITE_APP_NAME%</title>
|
||||||
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||||
<meta name="description" content="Discover events near you">
|
<meta name="description" content="Discover events near you">
|
||||||
|
|
|
||||||
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"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
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
{
|
||||||
|
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,9 +6,8 @@
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
|
||||||
<title>Forum — Discussions</title>
|
<title>Forum — Discussions</title>
|
||||||
<meta name="apple-mobile-web-app-title" content="Forum">
|
<meta name="apple-mobile-web-app-title" content="Forum">
|
||||||
<meta name="description" content="Decentralized link aggregator and discussion forum on Nostr">
|
<meta name="description" content="Decentralized link aggregator and discussion forum on Nostr">
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<!-- <meta name="theme-color" content="#ffffff"> -->
|
<!-- <meta name="theme-color" content="#ffffff"> -->
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
|
||||||
<title>%VITE_APP_NAME% Hub</title>
|
<title>%VITE_APP_NAME% Hub</title>
|
||||||
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
|
||||||
<title>Libra — Accounting</title>
|
<title>Libra — Accounting</title>
|
||||||
<meta name="apple-mobile-web-app-title" content="Libra">
|
<meta name="apple-mobile-web-app-title" content="Libra">
|
||||||
<meta name="description" content="Team accounting and expense management">
|
<meta name="description" content="Team accounting and expense management">
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
|
||||||
<title>Market — Nostr</title>
|
<title>Market — Nostr</title>
|
||||||
<meta name="apple-mobile-web-app-title" content="Market">
|
<meta name="apple-mobile-web-app-title" content="Market">
|
||||||
<meta name="description" content="Decentralized marketplace on Nostr with Lightning payments">
|
<meta name="description" content="Decentralized marketplace on Nostr with Lightning payments">
|
||||||
|
|
|
||||||
12
package.json
|
|
@ -5,10 +5,13 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"generate-pwa-assets": "node scripts/generate-pwa-assets.mjs",
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview --host",
|
"preview": "vite preview --host",
|
||||||
"analyze": "vite build --mode analyze",
|
"analyze": "vite build --mode analyze",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"dev:events": "vite --host --config vite.events.config.ts",
|
"dev:events": "vite --host --config vite.events.config.ts",
|
||||||
"build:events": "vue-tsc -b && vite build --config vite.events.config.ts",
|
"build:events": "vue-tsc -b && vite build --config vite.events.config.ts",
|
||||||
"preview:events": "vite preview --host --config vite.events.config.ts",
|
"preview:events": "vite preview --host --config vite.events.config.ts",
|
||||||
|
|
@ -33,8 +36,11 @@
|
||||||
"dev:restaurant": "vite --host --config vite.restaurant.config.ts",
|
"dev:restaurant": "vite --host --config vite.restaurant.config.ts",
|
||||||
"build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts",
|
"build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts",
|
||||||
"preview:restaurant": "vite preview --host --config vite.restaurant.config.ts",
|
"preview:restaurant": "vite preview --host --config vite.restaurant.config.ts",
|
||||||
"dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"",
|
"dev:chatelet": "vite --host --config vite.chatelet.config.ts",
|
||||||
"build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant",
|
"build:chatelet": "vue-tsc -b && vite build --config vite.chatelet.config.ts",
|
||||||
|
"preview:chatelet": "vite preview --host --config vite.chatelet.config.ts",
|
||||||
|
"dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant,chatelet -c blue,magenta,cyan,yellow,green,blue,red,gray,green,cyan \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\" \"npm:dev:chatelet\"",
|
||||||
|
"build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant && VITE_BASE_PATH=/chatelet/ npm run build:chatelet",
|
||||||
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
||||||
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
||||||
"electron:package": "electron-builder",
|
"electron:package": "electron-builder",
|
||||||
|
|
@ -92,6 +98,7 @@
|
||||||
"@types/node": "^22.18.1",
|
"@types/node": "^22.18.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/rollup-plugin-visualizer": "^4.2.3",
|
"@types/rollup-plugin-visualizer": "^4.2.3",
|
||||||
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
|
@ -105,6 +112,7 @@
|
||||||
"vite-plugin-image-optimizer": "^1.1.7",
|
"vite-plugin-image-optimizer": "^1.1.7",
|
||||||
"vite-plugin-inspect": "^0.8.3",
|
"vite-plugin-inspect": "^0.8.3",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
|
"vitest": "^4.1.9",
|
||||||
"vue-tsc": "^2.2.0",
|
"vue-tsc": "^2.2.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
|
|
|
||||||
346
pnpm-lock.yaml
generated
|
|
@ -150,6 +150,9 @@ importers:
|
||||||
'@types/rollup-plugin-visualizer':
|
'@types/rollup-plugin-visualizer':
|
||||||
specifier: ^4.2.3
|
specifier: ^4.2.3
|
||||||
version: 4.2.4
|
version: 4.2.4
|
||||||
|
'@vite-pwa/assets-generator':
|
||||||
|
specifier: ^1.0.2
|
||||||
|
version: 1.0.2
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))
|
version: 5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))
|
||||||
|
|
@ -188,7 +191,10 @@ importers:
|
||||||
version: 0.8.9(rollup@4.60.4)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
|
version: 0.8.9(rollup@4.60.4)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
|
||||||
vite-plugin-pwa:
|
vite-plugin-pwa:
|
||||||
specifier: ^0.21.1
|
specifier: ^0.21.1
|
||||||
version: 0.21.2(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1)
|
version: 0.21.2(@vite-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))
|
||||||
vue-tsc:
|
vue-tsc:
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.12(typescript@5.6.3)
|
version: 2.2.12(typescript@5.6.3)
|
||||||
|
|
@ -711,6 +717,9 @@ packages:
|
||||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@canvas/image-data@1.1.0':
|
||||||
|
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
|
||||||
|
|
||||||
'@electron-forge/cli@7.11.2':
|
'@electron-forge/cli@7.11.2':
|
||||||
resolution: {integrity: sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg==}
|
resolution: {integrity: sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg==}
|
||||||
engines: {node: '>= 16.4.0'}
|
engines: {node: '>= 16.4.0'}
|
||||||
|
|
@ -1283,6 +1292,9 @@ packages:
|
||||||
'@polka/url@1.0.0-next.29':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
|
'@quansync/fs@1.0.0':
|
||||||
|
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
|
||||||
|
|
||||||
'@rollup/plugin-babel@6.1.0':
|
'@rollup/plugin-babel@6.1.0':
|
||||||
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
|
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
@ -1486,6 +1498,9 @@ packages:
|
||||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0':
|
||||||
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.21':
|
'@swc/helpers@0.5.21':
|
||||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||||
|
|
||||||
|
|
@ -1626,6 +1641,12 @@ packages:
|
||||||
'@types/cacheable-request@6.0.3':
|
'@types/cacheable-request@6.0.3':
|
||||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2':
|
||||||
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
|
@ -1697,6 +1718,11 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.24.0
|
zod: ^3.24.0
|
||||||
|
|
||||||
|
'@vite-pwa/assets-generator@1.0.2':
|
||||||
|
resolution: {integrity: sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==}
|
||||||
|
engines: {node: '>=16.14.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@vitejs/plugin-vue@5.2.4':
|
'@vitejs/plugin-vue@5.2.4':
|
||||||
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
@ -1704,6 +1730,35 @@ packages:
|
||||||
vite: ^5.0.0 || ^6.0.0
|
vite: ^5.0.0 || ^6.0.0
|
||||||
vue: ^3.2.25
|
vue: ^3.2.25
|
||||||
|
|
||||||
|
'@vitest/expect@4.1.9':
|
||||||
|
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
|
||||||
|
|
||||||
|
'@vitest/mocker@4.1.9':
|
||||||
|
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/pretty-format@4.1.9':
|
||||||
|
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
|
||||||
|
|
||||||
|
'@vitest/runner@4.1.9':
|
||||||
|
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
|
||||||
|
|
||||||
|
'@vitest/snapshot@4.1.9':
|
||||||
|
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
|
||||||
|
|
||||||
|
'@vitest/spy@4.1.9':
|
||||||
|
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
|
||||||
|
|
||||||
|
'@vitest/utils@4.1.9':
|
||||||
|
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
||||||
|
|
||||||
'@volar/language-core@2.4.15':
|
'@volar/language-core@2.4.15':
|
||||||
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
||||||
|
|
||||||
|
|
@ -2015,6 +2070,10 @@ packages:
|
||||||
asn1.js@5.4.1:
|
asn1.js@5.4.1:
|
||||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
async-function@1.0.0:
|
async-function@1.0.0:
|
||||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -2121,6 +2180,10 @@ packages:
|
||||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cac@6.7.14:
|
||||||
|
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
cacache@16.1.3:
|
cacache@16.1.3:
|
||||||
resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
|
resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
|
||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||||
|
|
@ -2152,6 +2215,10 @@ packages:
|
||||||
caniuse-lite@1.0.30001793:
|
caniuse-lite@1.0.30001793:
|
||||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||||
|
|
||||||
|
chai@6.2.2:
|
||||||
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -2267,6 +2334,10 @@ packages:
|
||||||
engines: {node: ^14.13.0 || >=16.0.0}
|
engines: {node: ^14.13.0 || >=16.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
consola@3.4.2:
|
||||||
|
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
||||||
|
engines: {node: ^14.18.0 || >=16.10.0}
|
||||||
|
|
||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
|
@ -2366,6 +2437,14 @@ packages:
|
||||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
decode-bmp@0.2.1:
|
||||||
|
resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==}
|
||||||
|
engines: {node: '>=8.6.0'}
|
||||||
|
|
||||||
|
decode-ico@0.4.1:
|
||||||
|
resolution: {integrity: sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==}
|
||||||
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -2583,6 +2662,9 @@ packages:
|
||||||
estree-walker@2.0.2:
|
estree-walker@2.0.2:
|
||||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
esutils@2.0.3:
|
esutils@2.0.3:
|
||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -2606,6 +2688,10 @@ packages:
|
||||||
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
|
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
expect-type@1.3.0:
|
||||||
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
exponential-backoff@3.1.3:
|
exponential-backoff@3.1.3:
|
||||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||||
|
|
||||||
|
|
@ -2890,6 +2976,9 @@ packages:
|
||||||
humanize-ms@1.2.1:
|
humanize-ms@1.2.1:
|
||||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||||
|
|
||||||
|
ico-endec@0.1.6:
|
||||||
|
resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -3563,6 +3652,10 @@ packages:
|
||||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
obug@2.1.3:
|
||||||
|
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
ohash@2.0.11:
|
ohash@2.0.11:
|
||||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||||
|
|
||||||
|
|
@ -3693,6 +3786,9 @@ packages:
|
||||||
pathe@1.1.2:
|
pathe@1.1.2:
|
||||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
pe-library@1.0.1:
|
pe-library@1.0.1:
|
||||||
resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==}
|
resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==}
|
||||||
engines: {node: '>=14', npm: '>=7'}
|
engines: {node: '>=14', npm: '>=7'}
|
||||||
|
|
@ -3800,6 +3896,9 @@ packages:
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
quansync@1.0.0:
|
||||||
|
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
|
@ -4020,6 +4119,9 @@ packages:
|
||||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
sharp-ico@0.1.5:
|
||||||
|
resolution: {integrity: sha512-a3jODQl82NPp1d5OYb0wY+oFaPk7AvyxipIowCHk7pBsZCWgbe0yAkU2OOXdoH0ENyANhyOQbs9xkAiRHcF02Q==}
|
||||||
|
|
||||||
sharp@0.33.5:
|
sharp@0.33.5:
|
||||||
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
|
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
@ -4060,6 +4162,9 @@ packages:
|
||||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
signal-exit@3.0.7:
|
signal-exit@3.0.7:
|
||||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||||
|
|
||||||
|
|
@ -4140,6 +4245,12 @@ packages:
|
||||||
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
|
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
|
||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||||
|
|
||||||
|
stackback@0.0.2:
|
||||||
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
std-env@4.1.0:
|
||||||
|
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -4307,10 +4418,21 @@ packages:
|
||||||
tiny-each-async@2.0.3:
|
tiny-each-async@2.0.3:
|
||||||
resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==}
|
resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==}
|
||||||
|
|
||||||
|
tinybench@2.9.0:
|
||||||
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
|
tinyexec@1.2.4:
|
||||||
|
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tinyglobby@0.2.16:
|
tinyglobby@0.2.16:
|
||||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinyrainbow@3.1.0:
|
||||||
|
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
tmp-promise@3.0.3:
|
tmp-promise@3.0.3:
|
||||||
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
||||||
|
|
||||||
|
|
@ -4322,6 +4444,9 @@ packages:
|
||||||
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
|
to-data-view@1.1.0:
|
||||||
|
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
|
|
@ -4397,6 +4522,12 @@ packages:
|
||||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
unconfig-core@7.5.0:
|
||||||
|
resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
|
||||||
|
|
||||||
|
unconfig@7.5.0:
|
||||||
|
resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
|
@ -4536,6 +4667,47 @@ packages:
|
||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitest@4.1.9:
|
||||||
|
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
|
||||||
|
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@opentelemetry/api': ^1.9.0
|
||||||
|
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||||
|
'@vitest/browser-playwright': 4.1.9
|
||||||
|
'@vitest/browser-preview': 4.1.9
|
||||||
|
'@vitest/browser-webdriverio': 4.1.9
|
||||||
|
'@vitest/coverage-istanbul': 4.1.9
|
||||||
|
'@vitest/coverage-v8': 4.1.9
|
||||||
|
'@vitest/ui': 4.1.9
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@opentelemetry/api':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-playwright':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-preview':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-webdriverio':
|
||||||
|
optional: true
|
||||||
|
'@vitest/coverage-istanbul':
|
||||||
|
optional: true
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
vscode-uri@3.1.0:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
|
|
@ -4656,6 +4828,11 @@ packages:
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
word-wrap@1.2.5:
|
word-wrap@1.2.5:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -5456,6 +5633,8 @@ snapshots:
|
||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@canvas/image-data@1.1.0': {}
|
||||||
|
|
||||||
'@electron-forge/cli@7.11.2(encoding@0.1.13)(lightningcss@1.32.0)':
|
'@electron-forge/cli@7.11.2(encoding@0.1.13)(lightningcss@1.32.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@electron-forge/core': 7.11.2(encoding@0.1.13)(lightningcss@1.32.0)
|
'@electron-forge/core': 7.11.2(encoding@0.1.13)(lightningcss@1.32.0)
|
||||||
|
|
@ -6234,6 +6413,10 @@ snapshots:
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|
||||||
|
'@quansync/fs@1.0.0':
|
||||||
|
dependencies:
|
||||||
|
quansync: 1.0.0
|
||||||
|
|
||||||
'@rollup/plugin-babel@6.1.0(@babel/core@7.29.0)(rollup@4.60.4)':
|
'@rollup/plugin-babel@6.1.0(@babel/core@7.29.0)(rollup@4.60.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
|
|
@ -6369,6 +6552,8 @@ snapshots:
|
||||||
|
|
||||||
'@sindresorhus/is@4.6.0': {}
|
'@sindresorhus/is@4.6.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.21':
|
'@swc/helpers@0.5.21':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
@ -6485,6 +6670,13 @@ snapshots:
|
||||||
'@types/node': 22.19.19
|
'@types/node': 22.19.19
|
||||||
'@types/responselike': 1.0.3
|
'@types/responselike': 1.0.3
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/deep-eql': 4.0.2
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
@ -6559,11 +6751,61 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@vite-pwa/assets-generator@1.0.2':
|
||||||
|
dependencies:
|
||||||
|
cac: 6.7.14
|
||||||
|
colorette: 2.0.20
|
||||||
|
consola: 3.4.2
|
||||||
|
sharp: 0.33.5
|
||||||
|
sharp-ico: 0.1.5
|
||||||
|
unconfig: 7.5.0
|
||||||
|
|
||||||
'@vitejs/plugin-vue@5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))':
|
'@vitejs/plugin-vue@5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
vue: 3.5.34(typescript@5.6.3)
|
vue: 3.5.34(typescript@5.6.3)
|
||||||
|
|
||||||
|
'@vitest/expect@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/spy': 4.1.9
|
||||||
|
'@vitest/utils': 4.1.9
|
||||||
|
chai: 6.2.2
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/mocker@4.1.9(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 4.1.9
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
|
|
||||||
|
'@vitest/pretty-format@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/runner@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 4.1.9
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/snapshot@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 4.1.9
|
||||||
|
'@vitest/utils': 4.1.9
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/spy@4.1.9': {}
|
||||||
|
|
||||||
|
'@vitest/utils@4.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 4.1.9
|
||||||
|
convert-source-map: 2.0.0
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@volar/language-core@2.4.15':
|
'@volar/language-core@2.4.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/source-map': 2.4.15
|
'@volar/source-map': 2.4.15
|
||||||
|
|
@ -6925,6 +7167,8 @@ snapshots:
|
||||||
minimalistic-assert: 1.0.1
|
minimalistic-assert: 1.0.1
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
async-function@1.0.0: {}
|
async-function@1.0.0: {}
|
||||||
|
|
||||||
async@3.2.6: {}
|
async@3.2.6: {}
|
||||||
|
|
@ -7030,6 +7274,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
run-applescript: 7.1.0
|
run-applescript: 7.1.0
|
||||||
|
|
||||||
|
cac@6.7.14: {}
|
||||||
|
|
||||||
cacache@16.1.3:
|
cacache@16.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@npmcli/fs': 2.1.2
|
'@npmcli/fs': 2.1.2
|
||||||
|
|
@ -7086,6 +7332,8 @@ snapshots:
|
||||||
|
|
||||||
caniuse-lite@1.0.30001793: {}
|
caniuse-lite@1.0.30001793: {}
|
||||||
|
|
||||||
|
chai@6.2.2: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
|
@ -7193,6 +7441,8 @@ snapshots:
|
||||||
tree-kill: 1.2.2
|
tree-kill: 1.2.2
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
|
|
||||||
|
consola@3.4.2: {}
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
copy-anything@4.0.5:
|
copy-anything@4.0.5:
|
||||||
|
|
@ -7287,6 +7537,17 @@ snapshots:
|
||||||
|
|
||||||
decamelize@1.2.0: {}
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
|
decode-bmp@0.2.1:
|
||||||
|
dependencies:
|
||||||
|
'@canvas/image-data': 1.1.0
|
||||||
|
to-data-view: 1.1.0
|
||||||
|
|
||||||
|
decode-ico@0.4.1:
|
||||||
|
dependencies:
|
||||||
|
'@canvas/image-data': 1.1.0
|
||||||
|
decode-bmp: 0.2.1
|
||||||
|
to-data-view: 1.1.0
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-response: 3.1.0
|
mimic-response: 3.1.0
|
||||||
|
|
@ -7609,6 +7870,10 @@ snapshots:
|
||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.9
|
||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
eta@3.5.0: {}
|
eta@3.5.0: {}
|
||||||
|
|
@ -7629,6 +7894,8 @@ snapshots:
|
||||||
signal-exit: 3.0.7
|
signal-exit: 3.0.7
|
||||||
strip-eof: 1.0.0
|
strip-eof: 1.0.0
|
||||||
|
|
||||||
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
exponential-backoff@3.1.3: {}
|
exponential-backoff@3.1.3: {}
|
||||||
|
|
||||||
external-editor@3.1.0:
|
external-editor@3.1.0:
|
||||||
|
|
@ -7973,6 +8240,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
ico-endec@0.1.6: {}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
@ -8568,6 +8837,8 @@ snapshots:
|
||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
object-keys: 1.1.1
|
object-keys: 1.1.1
|
||||||
|
|
||||||
|
obug@2.1.3: {}
|
||||||
|
|
||||||
ohash@2.0.11: {}
|
ohash@2.0.11: {}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
|
|
@ -8686,6 +8957,8 @@ snapshots:
|
||||||
|
|
||||||
pathe@1.1.2: {}
|
pathe@1.1.2: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
pe-library@1.0.1: {}
|
pe-library@1.0.1: {}
|
||||||
|
|
||||||
pend@1.2.0: {}
|
pend@1.2.0: {}
|
||||||
|
|
@ -8769,6 +9042,8 @@ snapshots:
|
||||||
pngjs: 5.0.0
|
pngjs: 5.0.0
|
||||||
yargs: 15.4.1
|
yargs: 15.4.1
|
||||||
|
|
||||||
|
quansync@1.0.0: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
quick-lru@5.1.1: {}
|
quick-lru@5.1.1: {}
|
||||||
|
|
@ -9058,6 +9333,12 @@ snapshots:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
es-object-atoms: 1.1.2
|
es-object-atoms: 1.1.2
|
||||||
|
|
||||||
|
sharp-ico@0.1.5:
|
||||||
|
dependencies:
|
||||||
|
decode-ico: 0.4.1
|
||||||
|
ico-endec: 0.1.6
|
||||||
|
sharp: 0.33.5
|
||||||
|
|
||||||
sharp@0.33.5:
|
sharp@0.33.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
color: 4.2.3
|
color: 4.2.3
|
||||||
|
|
@ -9126,6 +9407,8 @@ snapshots:
|
||||||
side-channel-map: 1.0.1
|
side-channel-map: 1.0.1
|
||||||
side-channel-weakmap: 1.0.2
|
side-channel-weakmap: 1.0.2
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
signal-exit@3.0.7: {}
|
signal-exit@3.0.7: {}
|
||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
@ -9202,6 +9485,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass: 3.3.6
|
minipass: 3.3.6
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
std-env@4.1.0: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|
@ -9370,11 +9657,17 @@ snapshots:
|
||||||
tiny-each-async@2.0.3:
|
tiny-each-async@2.0.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
|
tinyexec@1.2.4: {}
|
||||||
|
|
||||||
tinyglobby@0.2.16:
|
tinyglobby@0.2.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinyrainbow@3.1.0: {}
|
||||||
|
|
||||||
tmp-promise@3.0.3:
|
tmp-promise@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
tmp: 0.2.5
|
tmp: 0.2.5
|
||||||
|
|
@ -9387,6 +9680,8 @@ snapshots:
|
||||||
tmp@0.2.5:
|
tmp@0.2.5:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
to-data-view@1.1.0: {}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
|
|
@ -9462,6 +9757,19 @@ snapshots:
|
||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
which-boxed-primitive: 1.1.1
|
which-boxed-primitive: 1.1.1
|
||||||
|
|
||||||
|
unconfig-core@7.5.0:
|
||||||
|
dependencies:
|
||||||
|
'@quansync/fs': 1.0.0
|
||||||
|
quansync: 1.0.0
|
||||||
|
|
||||||
|
unconfig@7.5.0:
|
||||||
|
dependencies:
|
||||||
|
'@quansync/fs': 1.0.0
|
||||||
|
defu: 6.1.7
|
||||||
|
jiti: 2.7.0
|
||||||
|
quansync: 1.0.0
|
||||||
|
unconfig-core: 7.5.0
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||||
|
|
@ -9543,7 +9851,7 @@ snapshots:
|
||||||
- rollup
|
- rollup
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
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):
|
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):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
pretty-bytes: 6.1.1
|
pretty-bytes: 6.1.1
|
||||||
|
|
@ -9551,6 +9859,8 @@ snapshots:
|
||||||
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
workbox-build: 7.4.1
|
workbox-build: 7.4.1
|
||||||
workbox-window: 7.4.1
|
workbox-window: 7.4.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@vite-pwa/assets-generator': 1.0.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
|
@ -9569,6 +9879,33 @@ snapshots:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
terser: 5.48.0
|
terser: 5.48.0
|
||||||
|
|
||||||
|
vitest@4.1.9(@types/node@22.19.19)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)):
|
||||||
|
dependencies:
|
||||||
|
'@vitest/expect': 4.1.9
|
||||||
|
'@vitest/mocker': 4.1.9(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
|
||||||
|
'@vitest/pretty-format': 4.1.9
|
||||||
|
'@vitest/runner': 4.1.9
|
||||||
|
'@vitest/snapshot': 4.1.9
|
||||||
|
'@vitest/spy': 4.1.9
|
||||||
|
'@vitest/utils': 4.1.9
|
||||||
|
es-module-lexer: 2.1.0
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
obug: 2.1.3
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.4
|
||||||
|
std-env: 4.1.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 1.2.4
|
||||||
|
tinyglobby: 0.2.16
|
||||||
|
tinyrainbow: 3.1.0
|
||||||
|
vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.19
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- msw
|
||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.6.3)):
|
vue-demi@0.14.10(vue@3.5.34(typescript@5.6.3)):
|
||||||
|
|
@ -9731,6 +10068,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
siginfo: 2.0.0
|
||||||
|
stackback: 0.0.2
|
||||||
|
|
||||||
word-wrap@1.2.5:
|
word-wrap@1.2.5:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
|
@ -1,3 +0,0 @@
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 227 B |
54
pwa-assets.config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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,9 +6,8 @@
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
|
||||||
<title>Restaurant — Order</title>
|
<title>Restaurant — Order</title>
|
||||||
<meta name="apple-mobile-web-app-title" content="Restaurant">
|
<meta name="apple-mobile-web-app-title" content="Restaurant">
|
||||||
<meta name="description" content="Order from your local Nostr-native restaurant with Lightning payments">
|
<meta name="description" content="Order from your local Nostr-native restaurant with Lightning payments">
|
||||||
|
|
|
||||||
19
scripts/generate-pwa-assets.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/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,8 +78,12 @@
|
||||||
|
|
||||||
<!-- Step 1: Revenue Account Selection -->
|
<!-- Step 1: Revenue Account Selection -->
|
||||||
<div v-if="currentStep === 1">
|
<div v-if="currentStep === 1">
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 mb-4 text-sm">
|
||||||
{{ t('libra.income.selectAccount') }}
|
<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>
|
</p>
|
||||||
<AccountSelector
|
<AccountSelector
|
||||||
v-model="selectedRevenueAccount"
|
v-model="selectedRevenueAccount"
|
||||||
|
|
@ -96,16 +100,16 @@
|
||||||
<FormLabel>Description *</FormLabel>
|
<FormLabel>Description *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="e.g., Workshop fee, Donation, Service revenue"
|
placeholder="A detailed description of the income source, and what it was for (event/project/etc)..."
|
||||||
v-bind="componentField"
|
v-bind="componentField"
|
||||||
rows="3"
|
rows="3"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Describe the source of this income</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<FormField v-slot="{ componentField }" name="amount">
|
<FormField v-slot="{ componentField }" name="amount">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Amount *</FormLabel>
|
<FormLabel>Amount *</FormLabel>
|
||||||
|
|
@ -118,7 +122,6 @@
|
||||||
step="0.01"
|
step="0.01"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Amount in selected currency</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -128,7 +131,7 @@
|
||||||
<FormLabel>Currency *</FormLabel>
|
<FormLabel>Currency *</FormLabel>
|
||||||
<Select v-bind="componentField">
|
<Select v-bind="componentField">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger class="w-full">
|
||||||
<SelectValue placeholder="Select currency" />
|
<SelectValue placeholder="Select currency" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
@ -138,18 +141,17 @@
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>Currency for this income</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="reference">
|
<FormField v-slot="{ componentField }" name="reference">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Reference</FormLabel>
|
<FormLabel>Reference (optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="e.g., Invoice #123, Receipt #456" v-bind="componentField" />
|
<Input placeholder="e.g., Invoice #123, Receipt #456" v-bind="componentField" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Optional reference number or note</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -201,7 +203,6 @@ import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
|
@ -221,7 +222,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { TrendingUp, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock } from 'lucide-vue-next'
|
import { TrendingUp, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock, AlertTriangle } from 'lucide-vue-next'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@
|
||||||
/* Default palette: Catppuccin (Latte for light, Mocha for dark).
|
/* Default palette: Catppuccin (Latte for light, Mocha for dark).
|
||||||
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
|
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
|
||||||
:root {
|
:root {
|
||||||
|
/* Rendered height of the fixed BottomNav (h-14 + safe-area + border), kept
|
||||||
|
current by BottomNav.vue. Pin anything that must sit flush above the nav
|
||||||
|
with `bottom: var(--bottom-nav-height)`; 0 when no nav is mounted. */
|
||||||
|
--bottom-nav-height: 0px;
|
||||||
--background: oklch(0.9578 0.0058 264.5321);
|
--background: oklch(0.9578 0.0058 264.5321);
|
||||||
--foreground: oklch(0.4355 0.0430 279.3250);
|
--foreground: oklch(0.4355 0.0430 279.3250);
|
||||||
--card: oklch(1.0000 0 0);
|
--card: oklch(1.0000 0 0);
|
||||||
|
|
@ -221,3 +225,16 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Disable enter/exit animations on reka-ui overlays (dialog, sheet,
|
||||||
|
* popover, dropdown, tooltip, …) app-wide. They animate via the
|
||||||
|
* data-state open/closed attribute; zeroing the duration keeps the final
|
||||||
|
* state but removes the motion (overlays appear/disappear instantly).
|
||||||
|
* Pulse/spin loaders and CSS transitions (e.g. hovers, the favourite
|
||||||
|
* heart pop) are unaffected.
|
||||||
|
*/
|
||||||
|
[data-state='open'],
|
||||||
|
[data-state='closed'] {
|
||||||
|
animation-duration: 0s !important;
|
||||||
|
}
|
||||||
|
|
|
||||||
16
src/chatelet-app/App.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppShell from '@/components/layout/AppShell.vue'
|
||||||
|
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||||
|
|
||||||
|
// Chatelet's in-page navigation is route-driven (browse → detail); the shell
|
||||||
|
// only contributes the always-on Profile entry on the bottom row.
|
||||||
|
const tabs: BottomTab[] = []
|
||||||
|
|
||||||
|
function isActive(_path: string): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell :tabs="tabs" :is-active="isActive" />
|
||||||
|
</template>
|
||||||
54
src/chatelet-app/app.config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import type { AppConfig } from '@/core/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone chatelet app configuration.
|
||||||
|
* Enables base + chatelet modules only.
|
||||||
|
*/
|
||||||
|
export const appConfig: AppConfig = {
|
||||||
|
modules: {
|
||||||
|
base: {
|
||||||
|
name: 'base',
|
||||||
|
enabled: true,
|
||||||
|
lazy: false,
|
||||||
|
config: {
|
||||||
|
nostr: {
|
||||||
|
relays: JSON.parse(
|
||||||
|
import.meta.env.VITE_NOSTR_RELAYS ||
|
||||||
|
'["wss://relay.damus.io", "wss://nos.lol"]',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
sessionTimeout: 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
pwa: {
|
||||||
|
autoPrompt: true,
|
||||||
|
},
|
||||||
|
imageUpload: {
|
||||||
|
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
|
||||||
|
maxSizeMB: 10,
|
||||||
|
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chatelet: {
|
||||||
|
name: 'chatelet',
|
||||||
|
enabled: true,
|
||||||
|
lazy: false,
|
||||||
|
config: {
|
||||||
|
apiConfig: {
|
||||||
|
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
|
||||||
|
apiKey: import.meta.env.VITE_API_KEY || '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
features: {
|
||||||
|
pwa: true,
|
||||||
|
pushNotifications: true,
|
||||||
|
electronApp: false,
|
||||||
|
developmentMode: import.meta.env.DEV,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default appConfig
|
||||||
128
src/chatelet-app/app.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { pluginManager } from '@/core/plugin-manager'
|
||||||
|
import { eventBus } from '@/core/event-bus'
|
||||||
|
import { container } from '@/core/di-container'
|
||||||
|
|
||||||
|
import appConfig from './app.config'
|
||||||
|
import baseModule from '@/modules/base'
|
||||||
|
import chateletModule from '@/modules/chatelet'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
import '@/assets/index.css'
|
||||||
|
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||||
|
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||||
|
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||||
|
|
||||||
|
const APP_NAME = (import.meta.env.VITE_APP_NAME as string) || 'Chatelet'
|
||||||
|
const APP_LABEL =
|
||||||
|
APP_NAME.toLowerCase() === 'chatelet' ? 'Chatelet' : `Chatelet (${APP_NAME})`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the standalone chatelet app (guest room booking).
|
||||||
|
*/
|
||||||
|
export async function createAppInstance() {
|
||||||
|
console.log(`🚀 Starting ${APP_LABEL}...`)
|
||||||
|
|
||||||
|
// Accept token from URL before anything else (cross-subdomain auth relay)
|
||||||
|
acceptTokenFromUrl(APP_NAME)
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
const moduleRoutes = [
|
||||||
|
...(baseModule.routes || []),
|
||||||
|
...(chateletModule.routes || []),
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', redirect: '/chatelet' },
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component:
|
||||||
|
import.meta.env.VITE_DEMO_MODE === 'true'
|
||||||
|
? () => import('@/pages/LoginDemo.vue')
|
||||||
|
: () => import('@/pages/Login.vue'),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
...moduleRoutes,
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('./views/SettingsPage.vue'),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
catchAllRoute,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
installLenientAuthGuard(router)
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(i18n)
|
||||||
|
|
||||||
|
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
|
||||||
|
if (defaultLocale && !localStorage.getItem('user-locale')) {
|
||||||
|
await changeLocale(defaultLocale)
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginManager.init(app, router)
|
||||||
|
|
||||||
|
const moduleRegistrations = []
|
||||||
|
if (appConfig.modules.base.enabled) {
|
||||||
|
moduleRegistrations.push(
|
||||||
|
pluginManager.register(baseModule, appConfig.modules.base),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (appConfig.modules.chatelet?.enabled) {
|
||||||
|
moduleRegistrations.push(
|
||||||
|
pluginManager.register(chateletModule, appConfig.modules.chatelet),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(moduleRegistrations)
|
||||||
|
await pluginManager.installAll()
|
||||||
|
|
||||||
|
const { auth } = await import('@/composables/useAuthService')
|
||||||
|
await auth.initialize()
|
||||||
|
markAuthReady(auth)
|
||||||
|
|
||||||
|
app.config.errorHandler = (err, _vm, info) => {
|
||||||
|
console.error('Global error:', err, info)
|
||||||
|
eventBus.emit('app:error', { error: err, info }, 'app')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appConfig.features.developmentMode) {
|
||||||
|
;(window as any).__pluginManager = pluginManager
|
||||||
|
;(window as any).__eventBus = eventBus
|
||||||
|
;(window as any).__container = container
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ ${APP_LABEL} initialized`)
|
||||||
|
return { app, router }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startApp() {
|
||||||
|
try {
|
||||||
|
const { app } = await createAppInstance()
|
||||||
|
app.mount('#app')
|
||||||
|
console.log(`🎉 ${APP_LABEL} started!`)
|
||||||
|
eventBus.emit('app:started', {}, 'app')
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`💥 Failed to start ${APP_LABEL}:`, error)
|
||||||
|
document.getElementById('app')!.innerHTML = `
|
||||||
|
<div style="padding: 20px; text-align: center; color: red;">
|
||||||
|
<h1>Failed to Start</h1>
|
||||||
|
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||||
|
<p>Please refresh the page.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/chatelet-app/main.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { startApp } from './app'
|
||||||
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||||
|
import 'vue-sonner/style.css'
|
||||||
|
|
||||||
|
cleanupStaleDevServiceWorkers()
|
||||||
|
|
||||||
|
// PWA service worker with periodic updates
|
||||||
|
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||||
|
registerSW({
|
||||||
|
onRegistered(r) {
|
||||||
|
r &&
|
||||||
|
setInterval(() => {
|
||||||
|
r.update()
|
||||||
|
}, intervalMS)
|
||||||
|
},
|
||||||
|
onOfflineReady() {
|
||||||
|
console.log(
|
||||||
|
`${(import.meta.env.VITE_APP_NAME as string) || 'Chatelet'} ready to work offline`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
startApp()
|
||||||
55
src/chatelet-app/views/SettingsPage.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Sun, Moon, LogIn, LogOut } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { useTheme } from '@/components/theme-provider'
|
||||||
|
import { auth } from '@/composables/useAuthService'
|
||||||
|
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => auth.isAuthenticated.value)
|
||||||
|
const userPubkey = computed(() => auth.currentUser.value?.pubkey)
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await auth.logout()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground mb-6">Settings</h1>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-foreground">Theme</span>
|
||||||
|
<Button variant="outline" size="sm" @click="toggleTheme">
|
||||||
|
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
|
||||||
|
<Moon v-else class="h-4 w-4" />
|
||||||
|
<span class="ml-2">{{ theme === 'dark' ? 'Light' : 'Dark' }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div v-if="isAuthenticated" class="space-y-2">
|
||||||
|
<p class="text-sm text-muted-foreground">Signed in as</p>
|
||||||
|
<p class="text-sm text-foreground break-all">{{ userPubkey }}</p>
|
||||||
|
<Button variant="outline" size="sm" @click="handleLogout">
|
||||||
|
<LogOut class="h-4 w-4 mr-2" /> Log out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<Button variant="outline" size="sm" @click="router.push('/login')">
|
||||||
|
<LogIn class="h-4 w-4 mr-2" /> Log in
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -4,24 +4,23 @@ import { useRoute } from 'vue-router'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { useTheme } from '@/components/theme-provider'
|
import { useTheme } from '@/components/theme-provider'
|
||||||
import BottomNav, { type BottomTab } from './BottomNav.vue'
|
import BottomNav, { type BottomTab } from './BottomNav.vue'
|
||||||
import HubPill from './HubPill.vue'
|
import StandaloneMenu, { type SidebarNavItem } from './StandaloneMenu.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** App-specific tabs displayed before the constant Profile entry. */
|
/** App-specific tabs displayed before the constant Profile entry. */
|
||||||
tabs: BottomTab[]
|
tabs: BottomTab[]
|
||||||
/** Active-tab matcher. Forwarded to BottomNav. */
|
/** Active-tab matcher. Forwarded to BottomNav. */
|
||||||
isActive: (path: string) => boolean
|
isActive: (path: string) => boolean
|
||||||
/** Hide the top-right HubPill — only true when this shell is rendering
|
/** Hide the top-right standalone menu — only true when this shell is
|
||||||
* the hub itself. Standalones leave this false (default). */
|
* rendering the hub itself. Standalones leave this false (default). */
|
||||||
hideHub?: boolean
|
hideHub?: boolean
|
||||||
/** Forwarded to BottomNav. Hub passes true so logged-out users can still
|
/** App-specific nav items rendered at the top of the standalone menu. */
|
||||||
* reach prefs from the sheet. Standalones leave it false. */
|
sidebarNav?: SidebarNavItem[]
|
||||||
loggedOutOpensSheet?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
hideHub: false,
|
hideHub: false,
|
||||||
loggedOutOpensSheet: false,
|
sidebarNav: () => [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -45,11 +44,13 @@ const isLoginPage = computed(() => route.path === '/login')
|
||||||
v-if="!isLoginPage"
|
v-if="!isLoginPage"
|
||||||
:tabs="props.tabs"
|
:tabs="props.tabs"
|
||||||
:is-active="props.isActive"
|
:is-active="props.isActive"
|
||||||
:logged-out-opens-sheet="props.loggedOutOpensSheet"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HubPill v-if="!props.hideHub && !isLoginPage" />
|
<StandaloneMenu
|
||||||
|
v-if="!props.hideHub && !isLoginPage"
|
||||||
|
:items="props.sidebarNav"
|
||||||
|
/>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
||||||
<!-- Default slot for shell-level overlays (dialogs, sheets, etc.) that
|
<!-- Default slot for shell-level overlays (dialogs, sheets, etc.) that
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const isActive = (href: string) => {
|
||||||
<div class="flex h-16 shrink-0 items-center">
|
<div class="flex h-16 shrink-0 items-center">
|
||||||
<router-link to="/" class="flex items-center gap-2">
|
<router-link to="/" class="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src="@/assets/logo.png"
|
src="@brand-app-logo"
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
|
import { useResizeObserver } from '@vueuse/core'
|
||||||
|
|
||||||
export interface BottomTab {
|
export interface BottomTab {
|
||||||
/** Translated label shown under the icon. */
|
/** Translated label shown under the icon. */
|
||||||
|
|
@ -18,6 +19,11 @@ export interface BottomTab {
|
||||||
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
|
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
|
||||||
* for auth-required tabs when the user is logged out. */
|
* for auth-required tabs when the user is logged out. */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
/** Per-tab active-state override for entries whose active condition
|
||||||
|
* doesn't reduce to "current route starts with this.path" — e.g. a
|
||||||
|
* "Hosting" tab that is active when a feed-filter ref is on. When
|
||||||
|
* set it wins over the App-level `isActive(path)` matcher. */
|
||||||
|
isActive?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -25,13 +31,25 @@ interface Props {
|
||||||
/** Active-tab matcher. Each app has its own nesting rules so we don't try
|
/** Active-tab matcher. Each app has its own nesting rules so we don't try
|
||||||
* to derive a one-size-fits-all default — consumer supplies the function. */
|
* to derive a one-size-fits-all default — consumer supplies the function. */
|
||||||
isActive: (path: string) => boolean
|
isActive: (path: string) => boolean
|
||||||
/** When true (Hub), the unauthenticated profile button still opens the
|
|
||||||
* sheet so logged-out users can change theme/lang. When false (standalones),
|
|
||||||
* unauth profile button routes straight to /login. */
|
|
||||||
loggedOutOpensSheet?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: false })
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Publish the nav's rendered height (h-14 + safe-area padding + border,
|
||||||
|
// whatever the device makes of it) as `--bottom-nav-height` on <html>, so
|
||||||
|
// anything that needs to sit flush above the nav — sticky action bars,
|
||||||
|
// toasts — can use `bottom: var(--bottom-nav-height)` instead of
|
||||||
|
// re-deriving the CSS and drifting when insets or font scaling differ.
|
||||||
|
const navRef = ref<HTMLElement | null>(null)
|
||||||
|
function publishHeight() {
|
||||||
|
const h = navRef.value?.getBoundingClientRect().height ?? 0
|
||||||
|
document.documentElement.style.setProperty('--bottom-nav-height', `${Math.round(h)}px`)
|
||||||
|
}
|
||||||
|
useResizeObserver(navRef, publishHeight)
|
||||||
|
onMounted(publishHeight)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.documentElement.style.setProperty('--bottom-nav-height', '0px')
|
||||||
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
@ -42,10 +60,16 @@ function onTabClick(tab: BottomTab) {
|
||||||
}
|
}
|
||||||
if (tab.path) router.push(tab.path)
|
if (tab.path) router.push(tab.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTabActive(tab: BottomTab): boolean {
|
||||||
|
if (tab.isActive) return tab.isActive()
|
||||||
|
return !!tab.path && props.isActive(tab.path)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav
|
<nav
|
||||||
|
ref="navRef"
|
||||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||||
>
|
>
|
||||||
|
|
@ -56,12 +80,12 @@ function onTabClick(tab: BottomTab) {
|
||||||
:key="tab.name"
|
:key="tab.name"
|
||||||
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
tab.path && props.isActive(tab.path)
|
isTabActive(tab)
|
||||||
? 'text-primary'
|
? 'text-primary'
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
tab.disabled ? 'opacity-50' : '',
|
tab.disabled ? 'opacity-50' : '',
|
||||||
]"
|
]"
|
||||||
:aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
|
:aria-current="isTabActive(tab) ? 'page' : undefined"
|
||||||
@click="onTabClick(tab)"
|
@click="onTabClick(tab)"
|
||||||
>
|
>
|
||||||
<component :is="tab.icon" class="w-5 h-5" />
|
<component :is="tab.icon" class="w-5 h-5" />
|
||||||
|
|
@ -73,10 +97,6 @@ function onTabClick(tab: BottomTab) {
|
||||||
{{ tab.badge > 99 ? '99+' : tab.badge }}
|
{{ tab.badge > 99 ? '99+' : tab.badge }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Always-on Profile entry, appended on the right. Consumers don't
|
|
||||||
pass it; the shell owns it so it's identical across every app. -->
|
|
||||||
<ProfileSheetTrigger :logged-out-opens-sheet="props.loggedOutOpensSheet" />
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<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">
|
<SheetHeader class="px-6 py-4 border-b border-border">
|
||||||
<SheetTitle class="flex items-center gap-2">
|
<SheetTitle class="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src="@/assets/logo.png"
|
src="@brand-app-logo"
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,116 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Home, LogIn } from 'lucide-vue-next'
|
import { Check, Copy, LogIn, LogOut, Pencil, Zap } from 'lucide-vue-next'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
||||||
|
import { toastService } from '@/core/services/ToastService'
|
||||||
import PreferencesRow from './PreferencesRow.vue'
|
import PreferencesRow from './PreferencesRow.vue'
|
||||||
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
|
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isAuthenticated, user } = useAuth()
|
const { isAuthenticated, user, logout } = useAuth()
|
||||||
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
||||||
|
|
||||||
const npubPreview = computed(() => {
|
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 pubkey = user.value?.pubkey
|
const pubkey = user.value?.pubkey
|
||||||
if (!pubkey) return ''
|
if (!pubkey) return ''
|
||||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`
|
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
|
||||||
})
|
})
|
||||||
|
|
||||||
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
|
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
|
||||||
|
|
||||||
|
const copiedField = ref<string | null>(null)
|
||||||
|
async function copyToClipboard(text: string, field: string) {
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
copiedField.value = field
|
||||||
|
toastService.success(t('common.nav.copied', 'Copied to clipboard'))
|
||||||
|
setTimeout(() => {
|
||||||
|
if (copiedField.value === field) copiedField.value = null
|
||||||
|
}, 1500)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed:', err)
|
||||||
|
toastService.error(t('common.nav.copyFailed', 'Failed to copy'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editProfileOpen = ref(false)
|
||||||
|
|
||||||
function goLogin() {
|
function goLogin() {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
try {
|
||||||
|
await logout()
|
||||||
|
toastService.success(t('common.nav.loggedOut', 'Logged out'))
|
||||||
|
router.push('/login')
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to log out'
|
||||||
|
toastService.error(`${t('common.nav.logoutFailed', 'Logout failed')}: ${msg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- Fill the sheet exactly (h-full) so the footer stays stuck to the
|
||||||
|
bottom while only the region above it scrolls. The sheet host already
|
||||||
|
has overflow-y-auto, but with an exact-fit child it never triggers —
|
||||||
|
the inner flex-1 region owns the scroll instead. -->
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<!-- Scrollable region: everything above the pinned footer. min-h-0 lets
|
||||||
|
this flex child shrink below its content height so it can scroll. -->
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
||||||
<SheetDescription v-if="isAuthenticated">
|
<SheetDescription v-if="isAuthenticated">
|
||||||
|
|
@ -41,46 +121,157 @@ function goLogin() {
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<!-- Identity card (logged in) -->
|
<!-- Identity card (logged in) — summary with an inline edit (pencil)
|
||||||
<div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
|
button that opens the profile form. -->
|
||||||
<Avatar class="h-12 w-12">
|
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<Avatar class="h-12 w-12 shrink-0">
|
||||||
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
||||||
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
|
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
|
||||||
<p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p>
|
<p v-if="displayName && user?.username" class="text-xs text-muted-foreground truncate">
|
||||||
|
@{{ user.username }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 self-start text-muted-foreground"
|
||||||
|
:aria-label="t('common.nav.editProfile', 'Edit profile')"
|
||||||
|
@click="editProfileOpen = true"
|
||||||
|
>
|
||||||
|
<Pencil class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Identifier rows: full-width value with a corner-offset "legend"
|
||||||
|
badge straddling the top border (fieldset-legend pattern). The
|
||||||
|
value gets the entire row so long bech32 / username@domain
|
||||||
|
strings have room to render. -->
|
||||||
|
<div class="space-y-3 pt-1">
|
||||||
|
<!-- Lightning Address — this is also the NIP-05 in this stack,
|
||||||
|
but the @username above already signals the NIP-05. -->
|
||||||
|
<button
|
||||||
|
v-if="lightningAddress"
|
||||||
|
type="button"
|
||||||
|
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
|
||||||
|
:aria-label="t('common.nav.copyLightning', 'Copy Lightning address')"
|
||||||
|
@click="copyToClipboard(lightningAddress, 'lightning')"
|
||||||
|
>
|
||||||
|
<span class="absolute -top-2 left-2 inline-flex items-center gap-1 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
|
||||||
|
<Zap class="w-3 h-3 text-yellow-500 fill-yellow-500" />
|
||||||
|
{{ t('common.nav.lightning', 'Lightning') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate pr-6 text-xs font-mono">{{ lightningAddress }}</span>
|
||||||
|
<component
|
||||||
|
:is="copiedField === 'lightning' ? Check : Copy"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- npub — copy the full bech32 even though we display a preview. -->
|
||||||
|
<button
|
||||||
|
v-if="npub"
|
||||||
|
type="button"
|
||||||
|
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
|
||||||
|
:aria-label="t('common.nav.copyNpub', 'Copy npub')"
|
||||||
|
@click="copyToClipboard(npub, 'npub')"
|
||||||
|
>
|
||||||
|
<span class="absolute -top-2 left-2 inline-flex items-center rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
|
||||||
|
{{ t('common.nav.npub', 'npub') }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate pr-6 text-xs font-mono">{{ npubPreview }}</span>
|
||||||
|
<component
|
||||||
|
:is="copiedField === 'npub' ? Check : Copy"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- App-specific nav items (rendered by callers like StandaloneMenu) -->
|
||||||
|
<slot name="app-nav" />
|
||||||
|
|
||||||
<!-- Cross-app links + global preferences (always visible, auth or not) -->
|
<!-- Cross-app links + global preferences (always visible, auth or not) -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
<PreferencesRow layout="list" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pinned footer: stays stuck to the bottom of the sheet (shrink-0);
|
||||||
|
"Back to hub" sits directly above the log-in/out bar. -->
|
||||||
|
<div class="shrink-0 pt-1">
|
||||||
|
<!-- "Back to hub" shows the HUB's brand-kit logo (the brand's
|
||||||
|
primary/global logo via @brand-hub-logo) — NOT the per-standalone
|
||||||
|
@brand-app-logo, which resolves to this standalone's own logo. -->
|
||||||
<a
|
<a
|
||||||
:href="hubRootUrl"
|
:href="hubRootUrl"
|
||||||
class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors"
|
class="flex items-center justify-center gap-3 px-3 py-1.5 hover:bg-accent rounded-md transition-colors"
|
||||||
:aria-label="t('common.nav.backToHub')"
|
:aria-label="t('common.nav.backToHub')"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Home class="w-5 h-5 text-muted-foreground" />
|
<img src="@brand-hub-logo" :alt="t('common.nav.backToHub')" class="w-8 h-8 shrink-0" />
|
||||||
<span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
|
<span class="text-sm font-medium">{{ t('common.nav.backToHub') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<PreferencesRow layout="list" />
|
<!-- Logged-out: prominent log-in CTA -->
|
||||||
</div>
|
<div v-if="!isAuthenticated">
|
||||||
|
<Separator class="mb-2" />
|
||||||
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings -->
|
|
||||||
<div v-if="!isAuthenticated" class="mt-6">
|
|
||||||
<Separator class="mb-4" />
|
|
||||||
<Button class="w-full" @click="goLogin">
|
<Button class="w-full" @click="goLogin">
|
||||||
<LogIn class="mr-2 h-4 w-4" />
|
<LogIn class="mr-2 h-4 w-4" />
|
||||||
{{ t('common.nav.login') }}
|
{{ t('common.nav.login') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logged-in: full profile management form -->
|
<!-- Logged-in: log-out button stays visible without opening the edit popup. -->
|
||||||
<div v-else class="mt-6">
|
<div v-else>
|
||||||
<Separator class="mb-4" />
|
<Separator class="mb-2" />
|
||||||
<ProfileSettings />
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger as-child>
|
||||||
|
<Button variant="destructive" class="w-full">
|
||||||
|
<LogOut class="mr-2 h-4 w-4" />
|
||||||
|
{{ t('common.nav.logOut', 'Log out') }}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Log out of {{ user?.username || 'your account' }}?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{{ t('common.nav.logOutConfirmDescription', "You'll need to sign in again to access your wallet, post in the forum, place orders, or use any feature that needs your account.") }}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{{ t('common.nav.cancel', 'Cancel') }}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
@click="onLogout"
|
||||||
|
>
|
||||||
|
{{ t('common.nav.logOut', 'Log out') }}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit-profile popup (pencil button in the identity card) — the full
|
||||||
|
form lives here so the sheet stays scannable. Outside the flex root:
|
||||||
|
its content portals to <body>, so it's not part of the sheet flow. -->
|
||||||
|
<Dialog v-model:open="editProfileOpen">
|
||||||
|
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ t('common.nav.editProfile', 'Edit profile') }}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ t('common.nav.editProfileDescription', 'Update your display name and profile picture.') }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ProfileSettings />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const open = ref(false)
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
|
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto overflow-x-hidden">
|
||||||
<ProfileSheetContent />
|
<ProfileSheetContent />
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
|
||||||
93
src/components/layout/StandaloneMenu.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<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,10 +21,28 @@ export const PALETTES: Palette[] = [
|
||||||
'starrynight',
|
'starrynight',
|
||||||
]
|
]
|
||||||
|
|
||||||
const DEFAULT_PALETTE: Palette = 'catppuccin'
|
// 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 useTheme = () => {
|
const useTheme = () => {
|
||||||
const theme = ref<Theme>('dark')
|
const theme = ref<Theme>(DEFAULT_THEME)
|
||||||
const systemTheme = ref<'dark' | 'light'>('light')
|
const systemTheme = ref<'dark' | 'light'>('light')
|
||||||
const palette = ref<Palette>(DEFAULT_PALETTE)
|
const palette = ref<Palette>(DEFAULT_PALETTE)
|
||||||
|
|
||||||
|
|
@ -45,7 +63,7 @@ const useTheme = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyPalette = () => {
|
const applyPalette = () => {
|
||||||
if (palette.value === DEFAULT_PALETTE) {
|
if (palette.value === BASE_PALETTE) {
|
||||||
document.documentElement.removeAttribute('data-theme')
|
document.documentElement.removeAttribute('data-theme')
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.setAttribute('data-theme', palette.value)
|
document.documentElement.setAttribute('data-theme', palette.value)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export { default as AvatarFallback } from './AvatarFallback.vue'
|
||||||
export { default as AvatarImage } from './AvatarImage.vue'
|
export { default as AvatarImage } from './AvatarImage.vue'
|
||||||
|
|
||||||
export const avatarVariant = cva(
|
export const avatarVariant = cva(
|
||||||
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
|
'inline-flex items-center justify-center font-normal text-secondary-foreground select-none shrink-0 bg-secondary overflow-hidden',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|
|
||||||
58
src/components/ui/range-calendar/RangeCalendar.vue
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarRootEmits, RangeCalendarRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { RangeCalendarCell, RangeCalendarCellTrigger, RangeCalendarGrid, RangeCalendarGridBody, RangeCalendarGridHead, RangeCalendarGridRow, RangeCalendarHeadCell, RangeCalendarHeader, RangeCalendarHeading, RangeCalendarNextButton, RangeCalendarPrevButton } from "."
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const emits = defineEmits<RangeCalendarRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarRoot
|
||||||
|
v-slot="{ grid, weekDays }"
|
||||||
|
:class="cn('p-3', props.class)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<RangeCalendarHeader>
|
||||||
|
<RangeCalendarPrevButton />
|
||||||
|
<RangeCalendarHeading />
|
||||||
|
<RangeCalendarNextButton />
|
||||||
|
</RangeCalendarHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||||
|
<RangeCalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||||
|
<RangeCalendarGridHead>
|
||||||
|
<RangeCalendarGridRow>
|
||||||
|
<RangeCalendarHeadCell
|
||||||
|
v-for="day in weekDays" :key="day"
|
||||||
|
>
|
||||||
|
{{ day }}
|
||||||
|
</RangeCalendarHeadCell>
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</RangeCalendarGridHead>
|
||||||
|
<RangeCalendarGridBody>
|
||||||
|
<RangeCalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||||
|
<RangeCalendarCell
|
||||||
|
v-for="weekDate in weekDates"
|
||||||
|
:key="weekDate.toString()"
|
||||||
|
:date="weekDate"
|
||||||
|
>
|
||||||
|
<RangeCalendarCellTrigger
|
||||||
|
:day="weekDate"
|
||||||
|
:month="month.value"
|
||||||
|
/>
|
||||||
|
</RangeCalendarCell>
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</RangeCalendarGridBody>
|
||||||
|
</RangeCalendarGrid>
|
||||||
|
</div>
|
||||||
|
</RangeCalendarRoot>
|
||||||
|
</template>
|
||||||
22
src/components/ui/range-calendar/RangeCalendarCell.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarCellProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarCell, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarCellProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarCell
|
||||||
|
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarCell>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarCellTriggerProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarCellTrigger, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarCellTrigger
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'ghost' }),
|
||||||
|
'h-8 w-8 p-0 font-normal',
|
||||||
|
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||||
|
// Range interior: hover preview (highlighted) and committed selection
|
||||||
|
// share the accent tint; the two ends get the primary pill.
|
||||||
|
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[highlighted]:rounded-none',
|
||||||
|
'data-[selected]:bg-accent data-[selected]:text-accent-foreground data-[selected]:rounded-none data-[selected]:opacity-100',
|
||||||
|
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:rounded-md data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground',
|
||||||
|
'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:rounded-md data-[selection-end]:hover:bg-primary data-[selection-end]:hover:text-primary-foreground',
|
||||||
|
// Disabled (before today, past the next occupied night, under min stay)
|
||||||
|
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-40 data-[disabled]:pointer-events-none',
|
||||||
|
// Unavailable: an occupied night — struck through, muted, not a target
|
||||||
|
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through data-[unavailable]:opacity-50 data-[unavailable]:pointer-events-none',
|
||||||
|
// Outside months
|
||||||
|
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarCellTrigger>
|
||||||
|
</template>
|
||||||
22
src/components/ui/range-calendar/RangeCalendarGrid.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarGridProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarGrid, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarGridProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarGrid
|
||||||
|
:class="cn('w-full border-collapse space-y-1', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarGrid>
|
||||||
|
</template>
|
||||||
12
src/components/ui/range-calendar/RangeCalendarGridBody.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarGridBodyProps } from "reka-ui"
|
||||||
|
import { RangeCalendarGridBody } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarGridBodyProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarGridBody v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarGridBody>
|
||||||
|
</template>
|
||||||
13
src/components/ui/range-calendar/RangeCalendarGridHead.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarGridHeadProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { RangeCalendarGridHead } from "reka-ui"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarGridHeadProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarGridHead v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarGridHead>
|
||||||
|
</template>
|
||||||
19
src/components/ui/range-calendar/RangeCalendarGridRow.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarGridRowProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarGridRow, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarGridRow>
|
||||||
|
</template>
|
||||||
19
src/components/ui/range-calendar/RangeCalendarHeadCell.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarHeadCellProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarHeadCell, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarHeadCell :class="cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarHeadCell>
|
||||||
|
</template>
|
||||||
19
src/components/ui/range-calendar/RangeCalendarHeader.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarHeaderProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarHeader, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
|
||||||
|
<slot />
|
||||||
|
</RangeCalendarHeader>
|
||||||
|
</template>
|
||||||
29
src/components/ui/range-calendar/RangeCalendarHeading.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarHeadingProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { RangeCalendarHeading, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default: (props: { headingValue: string }) => any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarHeading
|
||||||
|
v-slot="{ headingValue }"
|
||||||
|
:class="cn('text-sm font-medium', props.class)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot :heading-value>
|
||||||
|
{{ headingValue }}
|
||||||
|
</slot>
|
||||||
|
</RangeCalendarHeading>
|
||||||
|
</template>
|
||||||
30
src/components/ui/range-calendar/RangeCalendarNextButton.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarNextProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ChevronRight } from "lucide-vue-next"
|
||||||
|
import { RangeCalendarNext, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarNextProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarNext
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</RangeCalendarNext>
|
||||||
|
</template>
|
||||||
30
src/components/ui/range-calendar/RangeCalendarPrevButton.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RangeCalendarPrevProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { ChevronLeft } from "lucide-vue-next"
|
||||||
|
import { RangeCalendarPrev, useForwardProps } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const props = defineProps<RangeCalendarPrevProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RangeCalendarPrev
|
||||||
|
:class="cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</slot>
|
||||||
|
</RangeCalendarPrev>
|
||||||
|
</template>
|
||||||
12
src/components/ui/range-calendar/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export { default as RangeCalendar } from "./RangeCalendar.vue"
|
||||||
|
export { default as RangeCalendarCell } from "./RangeCalendarCell.vue"
|
||||||
|
export { default as RangeCalendarCellTrigger } from "./RangeCalendarCellTrigger.vue"
|
||||||
|
export { default as RangeCalendarGrid } from "./RangeCalendarGrid.vue"
|
||||||
|
export { default as RangeCalendarGridBody } from "./RangeCalendarGridBody.vue"
|
||||||
|
export { default as RangeCalendarGridHead } from "./RangeCalendarGridHead.vue"
|
||||||
|
export { default as RangeCalendarGridRow } from "./RangeCalendarGridRow.vue"
|
||||||
|
export { default as RangeCalendarHeadCell } from "./RangeCalendarHeadCell.vue"
|
||||||
|
export { default as RangeCalendarHeader } from "./RangeCalendarHeader.vue"
|
||||||
|
export { default as RangeCalendarHeading } from "./RangeCalendarHeading.vue"
|
||||||
|
export { default as RangeCalendarNextButton } from "./RangeCalendarNextButton.vue"
|
||||||
|
export { default as RangeCalendarPrevButton } from "./RangeCalendarPrevButton.vue"
|
||||||
|
|
@ -175,6 +175,9 @@ export const SERVICE_TOKENS = {
|
||||||
// Restaurant services
|
// Restaurant services
|
||||||
RESTAURANT_API: Symbol('restaurantAPI'),
|
RESTAURANT_API: Symbol('restaurantAPI'),
|
||||||
RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'),
|
RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'),
|
||||||
|
|
||||||
|
// Chatelet (room rentals) services
|
||||||
|
CHATELET_API: Symbol('chateletAPI'),
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Type-safe injection helpers
|
// Type-safe injection helpers
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
|
import { Home, Map, Heart, Ticket, Megaphone } from 'lucide-vue-next'
|
||||||
import AppShell from '@/components/layout/AppShell.vue'
|
import AppShell from '@/components/layout/AppShell.vue'
|
||||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
|
@ -23,37 +23,72 @@ const eventsStore = useEventsStore()
|
||||||
const { isAdmin, autoApprove } = useApprovalState()
|
const { isAdmin, autoApprove } = useApprovalState()
|
||||||
// Used to merge own LNbits drafts into the events feed right after
|
// Used to merge own LNbits drafts into the events feed right after
|
||||||
// the user creates or edits an event — otherwise the new draft only
|
// the user creates or edits an event — otherwise the new draft only
|
||||||
// surfaces on the next EventsPage subscribe cycle.
|
// surfaces on the next EventsPage subscribe cycle. `onlyHosting`
|
||||||
const { loadOwnEvents } = useEvents()
|
// 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/')
|
||||||
|
}
|
||||||
|
|
||||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
|
||||||
// Create lives in the bottom nav: when logged out, tapping it shows an
|
|
||||||
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
|
|
||||||
// opening the dialog. Per-app placement deliberation tracked at #53.
|
|
||||||
const tabs = computed<BottomTab[]>(() => [
|
const tabs = computed<BottomTab[]>(() => [
|
||||||
{ name: t('events.nav.feed'), icon: Search, path: '/events' },
|
|
||||||
{ name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' },
|
|
||||||
{
|
{
|
||||||
name: t('events.createNew'),
|
name: t('events.nav.feed'),
|
||||||
icon: Plus,
|
icon: Home,
|
||||||
|
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')
|
||||||
|
},
|
||||||
|
isActive: () => inFeedRoute() && !onlyHosting.value,
|
||||||
|
},
|
||||||
|
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
|
||||||
|
{
|
||||||
|
name: t('events.filters.myTickets'),
|
||||||
|
icon: Ticket,
|
||||||
|
path: '/my-tickets',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
toast.info('Log in to create an event', {
|
toast.info(t('events.detail.loginToBuyTickets'), {
|
||||||
action: {
|
action: {
|
||||||
label: 'Log in',
|
label: t('events.detail.logIn'),
|
||||||
onClick: () => router.push('/login'),
|
onClick: () => router.push('/login'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Defensively clear any lingering edit selection so the Create
|
router.push('/my-tickets')
|
||||||
// tap always opens in Create mode regardless of a prior Edit.
|
|
||||||
eventsStore.editingEvent = null
|
|
||||||
eventsStore.showCreateDialog = true
|
|
||||||
},
|
},
|
||||||
disabled: !isAuthenticated.value,
|
disabled: !isAuthenticated.value,
|
||||||
},
|
},
|
||||||
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
|
{
|
||||||
|
name: t('events.filters.hosting'),
|
||||||
|
icon: Megaphone,
|
||||||
|
onClick: () => {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
toast.info(t('events.hosting.loginPrompt', 'Log in to manage your hosted events'), {
|
||||||
|
action: {
|
||||||
|
label: t('events.favorites.logIn'),
|
||||||
|
onClick: () => router.push('/login'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!onlyHosting.value) toggleHosting()
|
||||||
|
if (route.path !== '/events') router.push('/events')
|
||||||
|
},
|
||||||
|
isActive: () => inFeedRoute() && onlyHosting.value,
|
||||||
|
disabled: !isAuthenticated.value,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: t('events.nav.favorites'),
|
name: t('events.nav.favorites'),
|
||||||
icon: Heart,
|
icon: Heart,
|
||||||
|
|
@ -77,18 +112,8 @@ const tabs = computed<BottomTab[]>(() => [
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
// Feed tab is active for the bare /events route AND all sub-paths that
|
// Path-based fallback for tabs that don't carry their own `isActive`.
|
||||||
// aren't owned by another tab (e.g. /events/<id> detail pages).
|
|
||||||
function isActive(path: string): boolean {
|
function isActive(path: string): boolean {
|
||||||
if (path === '/events') {
|
|
||||||
return (
|
|
||||||
route.path === '/events' ||
|
|
||||||
(route.path.startsWith('/events/') &&
|
|
||||||
!route.path.startsWith('/events/calendar') &&
|
|
||||||
!route.path.startsWith('/events/map') &&
|
|
||||||
!route.path.startsWith('/events/favorites'))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const messages: LocaleMessages = {
|
||||||
profileDescription: 'Your Nostr identity and display name.',
|
profileDescription: 'Your Nostr identity and display name.',
|
||||||
profileLoggedOutDescription: 'Sign in or change your preferences.',
|
profileLoggedOutDescription: 'Sign in or change your preferences.',
|
||||||
login: 'Log in',
|
login: 'Log in',
|
||||||
|
menu: 'Menu',
|
||||||
backToHub: 'Back to hub',
|
backToHub: 'Back to hub',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
theme: 'Theme',
|
theme: 'Theme',
|
||||||
|
|
@ -57,6 +58,27 @@ const messages: LocaleMessages = {
|
||||||
events: {
|
events: {
|
||||||
title: 'Events',
|
title: 'Events',
|
||||||
createNew: 'Create Event',
|
createNew: 'Create Event',
|
||||||
|
create: {
|
||||||
|
promo: {
|
||||||
|
title: 'Promo codes',
|
||||||
|
hint: 'Percentage discounts buyers can enter at checkout. Codes are never shown publicly.',
|
||||||
|
empty: 'No promo codes yet.',
|
||||||
|
code: 'Code',
|
||||||
|
codePlaceholder: 'EARLYBIRD',
|
||||||
|
discount: 'Discount %',
|
||||||
|
maxUses: 'Max uses',
|
||||||
|
active: 'Active',
|
||||||
|
used: 'Used {count}',
|
||||||
|
add: 'Add code',
|
||||||
|
removeLabel: 'Remove code {code}',
|
||||||
|
errors: {
|
||||||
|
required: 'Enter a code',
|
||||||
|
duplicate: 'Duplicate code',
|
||||||
|
percentRange: 'Between 1 and 100',
|
||||||
|
maxUsesMin: 'Must be 1 or more',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
noEvents: 'No events found',
|
noEvents: 'No events found',
|
||||||
filters: {
|
filters: {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
|
|
@ -68,6 +90,10 @@ const messages: LocaleMessages = {
|
||||||
hosting: 'Hosting',
|
hosting: 'Hosting',
|
||||||
pastEvents: 'Past events',
|
pastEvents: 'Past events',
|
||||||
past: 'Past',
|
past: 'Past',
|
||||||
|
filters: 'Filters',
|
||||||
|
clearAll: 'Clear all',
|
||||||
|
filteringBy: 'Filtering by:',
|
||||||
|
removeCategory: 'Remove {category} filter',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -98,15 +124,13 @@ const messages: LocaleMessages = {
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
getTicket: 'Get Ticket',
|
getTicket: 'Get Ticket',
|
||||||
going: 'Going',
|
|
||||||
maybe: 'Maybe',
|
|
||||||
notGoing: 'Not Going',
|
|
||||||
contactOrganizer: 'Contact Organizer',
|
contactOrganizer: 'Contact Organizer',
|
||||||
organizer: 'Organizer',
|
organizer: 'Organizer',
|
||||||
location: 'Location',
|
location: 'Location',
|
||||||
when: 'When',
|
when: 'When',
|
||||||
tickets: 'Tickets',
|
tickets: 'Tickets',
|
||||||
ticketsAvailable: '{count} tickets available',
|
ticketsAvailable: '{count} tickets available',
|
||||||
|
ticketsRemainingOfTotal: '{count} of {total} tickets left',
|
||||||
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
||||||
unlimitedTickets: 'Unlimited tickets',
|
unlimitedTickets: 'Unlimited tickets',
|
||||||
buyTicket: 'Buy ticket',
|
buyTicket: 'Buy ticket',
|
||||||
|
|
@ -126,8 +150,67 @@ const messages: LocaleMessages = {
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
registered: 'Registered',
|
registered: 'Registered',
|
||||||
},
|
},
|
||||||
|
purchase: {
|
||||||
|
card: 'Card',
|
||||||
|
continueTo: 'Continue to {provider} checkout',
|
||||||
|
fiatRedirectHint: "You'll be taken to the provider's secure checkout and brought back here once paid.",
|
||||||
|
noCheckoutUrl: 'Fiat provider did not return a checkout URL.',
|
||||||
|
fiatFailed: 'Card checkout failed',
|
||||||
|
loginRequiredTitle: 'Login required',
|
||||||
|
loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.',
|
||||||
|
promo: {
|
||||||
|
label: 'Promo code',
|
||||||
|
placeholder: 'Enter code',
|
||||||
|
apply: 'Apply',
|
||||||
|
remove: 'Remove',
|
||||||
|
checking: 'Checking…',
|
||||||
|
applied: '{code} −{percent}%',
|
||||||
|
discountLine: 'Discount ({code})',
|
||||||
|
invalid: 'This code is not valid for this purchase.',
|
||||||
|
unverified: 'Could not verify the code — it will be applied at checkout if valid.',
|
||||||
|
loginRequiredDiscount: 'This code makes your ticket free. Free tickets need an account — log in to claim it.',
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: 'Your details',
|
||||||
|
name: 'Name',
|
||||||
|
namePlaceholder: 'Name on the ticket',
|
||||||
|
nameRequired: 'Name is required',
|
||||||
|
email: 'Email',
|
||||||
|
emailHint: 'Your ticket will be sent to this address.',
|
||||||
|
spamHint: 'If it has not arrived a few minutes after paying, check your spam folder.',
|
||||||
|
emailInvalid: 'Enter a valid email address',
|
||||||
|
confirmEmail: 'Confirm email',
|
||||||
|
emailMismatch: 'Email addresses do not match',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checkout: {
|
||||||
|
title: 'Confirming your payment…',
|
||||||
|
paidTitle: 'Payment received',
|
||||||
|
processing: 'Waiting for the payment to settle. This usually takes a few seconds.',
|
||||||
|
stillProcessing: 'Still processing. Your ticket will be emailed to {email} as soon as the payment settles.',
|
||||||
|
stillProcessingNoEmail: 'Still processing. Reload this page in a moment to check again.',
|
||||||
|
emailedTo: 'We emailed your ticket to {email}.',
|
||||||
|
cancelled: 'Payment cancelled — no seats were reserved.',
|
||||||
|
viewTicket: 'Open ticket page',
|
||||||
|
viewMyTickets: 'View My Tickets',
|
||||||
|
retry: 'Check again',
|
||||||
|
ticketN: 'Ticket {n} of {total}',
|
||||||
|
close: 'Close',
|
||||||
|
},
|
||||||
|
ticket: {
|
||||||
|
title: 'Your ticket',
|
||||||
|
loading: 'Loading ticket…',
|
||||||
|
notFound: 'Ticket not found',
|
||||||
|
event: 'Event',
|
||||||
|
holder: 'Name',
|
||||||
|
paid: 'Paid',
|
||||||
|
pending: 'Payment pending',
|
||||||
|
registered: 'Checked in',
|
||||||
|
showAtDoor: 'Show this QR at the door.',
|
||||||
|
backToEvent: 'Back to event',
|
||||||
|
},
|
||||||
nav: {
|
nav: {
|
||||||
feed: 'Feed',
|
feed: 'Home',
|
||||||
calendar: 'Calendar',
|
calendar: 'Calendar',
|
||||||
map: 'Map',
|
map: 'Map',
|
||||||
favorites: 'Favorites',
|
favorites: 'Favorites',
|
||||||
|
|
@ -199,7 +282,8 @@ const messages: LocaleMessages = {
|
||||||
income: {
|
income: {
|
||||||
title: 'Add Income',
|
title: 'Add Income',
|
||||||
description: 'Submit income for the organization',
|
description: 'Submit income for the organization',
|
||||||
selectAccount: 'Select the revenue account',
|
selectAccount: 'Select the revenue account.',
|
||||||
|
otherAccountHint: 'Use the "Other" account if you\'re not sure.',
|
||||||
submitIncome: 'Submit Income',
|
submitIncome: 'Submit Income',
|
||||||
notAvailable: 'Income submission is not yet available. This feature is coming soon.',
|
notAvailable: 'Income submission is not yet available. This feature is coming soon.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const messages: LocaleMessages = {
|
||||||
profileDescription: 'Tu identidad Nostr y nombre de visualización.',
|
profileDescription: 'Tu identidad Nostr y nombre de visualización.',
|
||||||
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
|
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
|
||||||
login: 'Iniciar sesión',
|
login: 'Iniciar sesión',
|
||||||
|
menu: 'Menú',
|
||||||
backToHub: 'Volver al hub',
|
backToHub: 'Volver al hub',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
theme: 'Tema',
|
theme: 'Tema',
|
||||||
|
|
@ -57,6 +58,27 @@ const messages: LocaleMessages = {
|
||||||
events: {
|
events: {
|
||||||
title: 'Eventos',
|
title: 'Eventos',
|
||||||
createNew: 'Crear evento',
|
createNew: 'Crear evento',
|
||||||
|
create: {
|
||||||
|
promo: {
|
||||||
|
title: 'Códigos promocionales',
|
||||||
|
hint: 'Descuentos porcentuales que los compradores pueden introducir al pagar. Los códigos nunca se muestran públicamente.',
|
||||||
|
empty: 'Aún no hay códigos promocionales.',
|
||||||
|
code: 'Código',
|
||||||
|
codePlaceholder: 'EARLYBIRD',
|
||||||
|
discount: 'Descuento %',
|
||||||
|
maxUses: 'Usos máx.',
|
||||||
|
active: 'Activo',
|
||||||
|
used: 'Usado {count}',
|
||||||
|
add: 'Añadir código',
|
||||||
|
removeLabel: 'Eliminar el código {code}',
|
||||||
|
errors: {
|
||||||
|
required: 'Introduce un código',
|
||||||
|
duplicate: 'Código duplicado',
|
||||||
|
percentRange: 'Entre 1 y 100',
|
||||||
|
maxUsesMin: 'Debe ser 1 o más',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
noEvents: 'No se encontraron eventos',
|
noEvents: 'No se encontraron eventos',
|
||||||
filters: {
|
filters: {
|
||||||
all: 'Todas',
|
all: 'Todas',
|
||||||
|
|
@ -68,6 +90,10 @@ const messages: LocaleMessages = {
|
||||||
hosting: 'Organizo',
|
hosting: 'Organizo',
|
||||||
pastEvents: 'Eventos pasados',
|
pastEvents: 'Eventos pasados',
|
||||||
past: 'Pasado',
|
past: 'Pasado',
|
||||||
|
filters: 'Filtros',
|
||||||
|
clearAll: 'Limpiar todo',
|
||||||
|
filteringBy: 'Filtrando por:',
|
||||||
|
removeCategory: 'Quitar el filtro {category}',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concierto',
|
concert: 'Concierto',
|
||||||
|
|
@ -98,15 +124,13 @@ const messages: LocaleMessages = {
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
getTicket: 'Obtener boleto',
|
getTicket: 'Obtener boleto',
|
||||||
going: 'Voy',
|
|
||||||
maybe: 'Tal vez',
|
|
||||||
notGoing: 'No voy',
|
|
||||||
contactOrganizer: 'Contactar organizador',
|
contactOrganizer: 'Contactar organizador',
|
||||||
organizer: 'Organizador',
|
organizer: 'Organizador',
|
||||||
location: 'Ubicación',
|
location: 'Ubicación',
|
||||||
when: 'Cuándo',
|
when: 'Cuándo',
|
||||||
tickets: 'Boletos',
|
tickets: 'Boletos',
|
||||||
ticketsAvailable: '{count} boletos disponibles',
|
ticketsAvailable: '{count} boletos disponibles',
|
||||||
|
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
|
||||||
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
||||||
unlimitedTickets: 'Boletos ilimitados',
|
unlimitedTickets: 'Boletos ilimitados',
|
||||||
buyTicket: 'Comprar boleto',
|
buyTicket: 'Comprar boleto',
|
||||||
|
|
@ -126,6 +150,65 @@ const messages: LocaleMessages = {
|
||||||
pending: 'Pendiente',
|
pending: 'Pendiente',
|
||||||
registered: 'Registrado',
|
registered: 'Registrado',
|
||||||
},
|
},
|
||||||
|
purchase: {
|
||||||
|
card: 'Tarjeta',
|
||||||
|
continueTo: 'Continuar al pago con {provider}',
|
||||||
|
fiatRedirectHint: 'Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.',
|
||||||
|
noCheckoutUrl: 'El proveedor no devolvió una URL de pago.',
|
||||||
|
fiatFailed: 'El pago con tarjeta falló',
|
||||||
|
loginRequiredTitle: 'Inicio de sesión requerido',
|
||||||
|
loginRequiredFree: 'Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.',
|
||||||
|
promo: {
|
||||||
|
label: 'Código promocional',
|
||||||
|
placeholder: 'Introduce el código',
|
||||||
|
apply: 'Aplicar',
|
||||||
|
remove: 'Quitar',
|
||||||
|
checking: 'Comprobando…',
|
||||||
|
applied: '{code} −{percent}%',
|
||||||
|
discountLine: 'Descuento ({code})',
|
||||||
|
invalid: 'Este código no es válido para esta compra.',
|
||||||
|
unverified: 'No se pudo verificar el código; se aplicará al pagar si es válido.',
|
||||||
|
loginRequiredDiscount: 'Este código hace que tu boleto sea gratis. Los boletos gratuitos requieren una cuenta: inicia sesión para reclamarlo.',
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: 'Tus datos',
|
||||||
|
name: 'Nombre',
|
||||||
|
namePlaceholder: 'Nombre en el boleto',
|
||||||
|
nameRequired: 'El nombre es obligatorio',
|
||||||
|
email: 'Correo electrónico',
|
||||||
|
emailHint: 'Tu boleto se enviará a esta dirección.',
|
||||||
|
spamHint: 'Si no llega unos minutos después de pagar, revisa tu carpeta de spam.',
|
||||||
|
emailInvalid: 'Introduce un correo electrónico válido',
|
||||||
|
confirmEmail: 'Confirmar correo',
|
||||||
|
emailMismatch: 'Los correos no coinciden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checkout: {
|
||||||
|
title: 'Confirmando tu pago…',
|
||||||
|
paidTitle: 'Pago recibido',
|
||||||
|
processing: 'Esperando a que se liquide el pago. Suele tardar unos segundos.',
|
||||||
|
stillProcessing: 'Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.',
|
||||||
|
stillProcessingNoEmail: 'Aún en proceso. Recarga esta página en un momento.',
|
||||||
|
emailedTo: 'Enviamos tu boleto a {email}.',
|
||||||
|
cancelled: 'Pago cancelado — no se reservó ningún asiento.',
|
||||||
|
viewTicket: 'Abrir página del boleto',
|
||||||
|
viewMyTickets: 'Ver mis boletos',
|
||||||
|
retry: 'Comprobar de nuevo',
|
||||||
|
ticketN: 'Boleto {n} de {total}',
|
||||||
|
close: 'Cerrar',
|
||||||
|
},
|
||||||
|
ticket: {
|
||||||
|
title: 'Tu boleto',
|
||||||
|
loading: 'Cargando boleto…',
|
||||||
|
notFound: 'Boleto no encontrado',
|
||||||
|
event: 'Evento',
|
||||||
|
holder: 'Nombre',
|
||||||
|
paid: 'Pagado',
|
||||||
|
pending: 'Pago pendiente',
|
||||||
|
registered: 'Registrado',
|
||||||
|
showAtDoor: 'Muestra este QR en la entrada.',
|
||||||
|
backToEvent: 'Volver al evento',
|
||||||
|
},
|
||||||
nav: {
|
nav: {
|
||||||
feed: 'Inicio',
|
feed: 'Inicio',
|
||||||
calendar: 'Calendario',
|
calendar: 'Calendario',
|
||||||
|
|
@ -199,7 +282,8 @@ const messages: LocaleMessages = {
|
||||||
income: {
|
income: {
|
||||||
title: 'A\u00f1adir ingreso',
|
title: 'A\u00f1adir ingreso',
|
||||||
description: 'Enviar un ingreso para la organizaci\u00f3n',
|
description: 'Enviar un ingreso para la organizaci\u00f3n',
|
||||||
selectAccount: 'Seleccionar la cuenta de ingresos',
|
selectAccount: 'Seleccionar la cuenta de ingresos.',
|
||||||
|
otherAccountHint: 'Use la cuenta "Other" si no está seguro.',
|
||||||
submitIncome: 'Enviar ingreso',
|
submitIncome: 'Enviar ingreso',
|
||||||
notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.',
|
notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const messages: LocaleMessages = {
|
||||||
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
|
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
|
||||||
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
|
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
|
||||||
login: 'Se connecter',
|
login: 'Se connecter',
|
||||||
|
menu: 'Menu',
|
||||||
backToHub: 'Retour au hub',
|
backToHub: 'Retour au hub',
|
||||||
hub: 'Hub',
|
hub: 'Hub',
|
||||||
theme: 'Thème',
|
theme: 'Thème',
|
||||||
|
|
@ -57,6 +58,27 @@ const messages: LocaleMessages = {
|
||||||
events: {
|
events: {
|
||||||
title: 'Événements',
|
title: 'Événements',
|
||||||
createNew: 'Créer un événement',
|
createNew: 'Créer un événement',
|
||||||
|
create: {
|
||||||
|
promo: {
|
||||||
|
title: 'Codes promo',
|
||||||
|
hint: 'Remises en pourcentage que les acheteurs peuvent saisir au paiement. Les codes ne sont jamais affichés publiquement.',
|
||||||
|
empty: 'Aucun code promo pour le moment.',
|
||||||
|
code: 'Code',
|
||||||
|
codePlaceholder: 'EARLYBIRD',
|
||||||
|
discount: 'Remise %',
|
||||||
|
maxUses: 'Utilisations max.',
|
||||||
|
active: 'Actif',
|
||||||
|
used: 'Utilisé {count}',
|
||||||
|
add: 'Ajouter un code',
|
||||||
|
removeLabel: 'Supprimer le code {code}',
|
||||||
|
errors: {
|
||||||
|
required: 'Saisissez un code',
|
||||||
|
duplicate: 'Code en double',
|
||||||
|
percentRange: 'Entre 1 et 100',
|
||||||
|
maxUsesMin: 'Doit être 1 ou plus',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
noEvents: 'Aucun événement trouvé',
|
noEvents: 'Aucun événement trouvé',
|
||||||
filters: {
|
filters: {
|
||||||
all: 'Tout',
|
all: 'Tout',
|
||||||
|
|
@ -68,6 +90,10 @@ const messages: LocaleMessages = {
|
||||||
hosting: 'J\'organise',
|
hosting: 'J\'organise',
|
||||||
pastEvents: 'Événements passés',
|
pastEvents: 'Événements passés',
|
||||||
past: 'Passé',
|
past: 'Passé',
|
||||||
|
filters: 'Filtres',
|
||||||
|
clearAll: 'Tout effacer',
|
||||||
|
filteringBy: 'Filtré par :',
|
||||||
|
removeCategory: 'Retirer le filtre {category}',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -98,15 +124,13 @@ const messages: LocaleMessages = {
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
getTicket: 'Obtenir un billet',
|
getTicket: 'Obtenir un billet',
|
||||||
going: 'Présent',
|
|
||||||
maybe: 'Peut-être',
|
|
||||||
notGoing: 'Absent',
|
|
||||||
contactOrganizer: "Contacter l'organisateur",
|
contactOrganizer: "Contacter l'organisateur",
|
||||||
organizer: 'Organisateur',
|
organizer: 'Organisateur',
|
||||||
location: 'Lieu',
|
location: 'Lieu',
|
||||||
when: 'Quand',
|
when: 'Quand',
|
||||||
tickets: 'Billets',
|
tickets: 'Billets',
|
||||||
ticketsAvailable: '{count} billets disponibles',
|
ticketsAvailable: '{count} billets disponibles',
|
||||||
|
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
|
||||||
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
||||||
unlimitedTickets: 'Billets illimités',
|
unlimitedTickets: 'Billets illimités',
|
||||||
buyTicket: 'Acheter un billet',
|
buyTicket: 'Acheter un billet',
|
||||||
|
|
@ -126,8 +150,67 @@ const messages: LocaleMessages = {
|
||||||
pending: 'En attente',
|
pending: 'En attente',
|
||||||
registered: 'Enregistré',
|
registered: 'Enregistré',
|
||||||
},
|
},
|
||||||
|
purchase: {
|
||||||
|
card: 'Carte',
|
||||||
|
continueTo: 'Continuer vers le paiement {provider}',
|
||||||
|
fiatRedirectHint: 'Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.',
|
||||||
|
noCheckoutUrl: "Le prestataire n'a pas renvoyé d'URL de paiement.",
|
||||||
|
fiatFailed: 'Le paiement par carte a échoué',
|
||||||
|
loginRequiredTitle: 'Connexion requise',
|
||||||
|
loginRequiredFree: 'Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.',
|
||||||
|
promo: {
|
||||||
|
label: 'Code promo',
|
||||||
|
placeholder: 'Saisir le code',
|
||||||
|
apply: 'Appliquer',
|
||||||
|
remove: 'Retirer',
|
||||||
|
checking: 'Vérification…',
|
||||||
|
applied: '{code} −{percent}%',
|
||||||
|
discountLine: 'Remise ({code})',
|
||||||
|
invalid: "Ce code n'est pas valable pour cet achat.",
|
||||||
|
unverified: "Impossible de vérifier le code — il sera appliqué au paiement s'il est valable.",
|
||||||
|
loginRequiredDiscount: 'Ce code rend votre billet gratuit. Les billets gratuits nécessitent un compte : connectez-vous pour le réclamer.',
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: 'Vos coordonnées',
|
||||||
|
name: 'Nom',
|
||||||
|
namePlaceholder: 'Nom sur le billet',
|
||||||
|
nameRequired: 'Le nom est obligatoire',
|
||||||
|
email: 'E-mail',
|
||||||
|
emailHint: 'Votre billet sera envoyé à cette adresse.',
|
||||||
|
spamHint: 'S’il n’est pas arrivé quelques minutes après le paiement, vérifiez votre dossier spam.',
|
||||||
|
emailInvalid: 'Saisissez une adresse e-mail valide',
|
||||||
|
confirmEmail: "Confirmer l'e-mail",
|
||||||
|
emailMismatch: 'Les adresses e-mail ne correspondent pas',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checkout: {
|
||||||
|
title: 'Confirmation de votre paiement…',
|
||||||
|
paidTitle: 'Paiement reçu',
|
||||||
|
processing: 'En attente du règlement du paiement. Cela prend généralement quelques secondes.',
|
||||||
|
stillProcessing: 'Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.',
|
||||||
|
stillProcessingNoEmail: 'Toujours en cours. Rechargez cette page dans un instant.',
|
||||||
|
emailedTo: 'Nous avons envoyé votre billet à {email}.',
|
||||||
|
cancelled: 'Paiement annulé — aucune place réservée.',
|
||||||
|
viewTicket: 'Ouvrir la page du billet',
|
||||||
|
viewMyTickets: 'Voir mes billets',
|
||||||
|
retry: 'Vérifier à nouveau',
|
||||||
|
ticketN: 'Billet {n} sur {total}',
|
||||||
|
close: 'Fermer',
|
||||||
|
},
|
||||||
|
ticket: {
|
||||||
|
title: 'Votre billet',
|
||||||
|
loading: 'Chargement du billet…',
|
||||||
|
notFound: 'Billet introuvable',
|
||||||
|
event: 'Événement',
|
||||||
|
holder: 'Nom',
|
||||||
|
paid: 'Payé',
|
||||||
|
pending: 'Paiement en attente',
|
||||||
|
registered: 'Enregistré',
|
||||||
|
showAtDoor: "Présentez ce QR à l'entrée.",
|
||||||
|
backToEvent: "Retour à l'événement",
|
||||||
|
},
|
||||||
nav: {
|
nav: {
|
||||||
feed: 'Fil',
|
feed: 'Accueil',
|
||||||
calendar: 'Calendrier',
|
calendar: 'Calendrier',
|
||||||
map: 'Carte',
|
map: 'Carte',
|
||||||
favorites: 'Favoris',
|
favorites: 'Favoris',
|
||||||
|
|
@ -199,7 +282,8 @@ const messages: LocaleMessages = {
|
||||||
income: {
|
income: {
|
||||||
title: 'Ajouter un revenu',
|
title: 'Ajouter un revenu',
|
||||||
description: 'Soumettre un revenu pour l\u2019organisation',
|
description: 'Soumettre un revenu pour l\u2019organisation',
|
||||||
selectAccount: 'S\u00e9lectionner le compte de revenus',
|
selectAccount: 'S\u00e9lectionner le compte de revenus.',
|
||||||
|
otherAccountHint: 'Utilisez le compte "Other" si vous n\'\u00eates pas s\u00fbr.',
|
||||||
submitIncome: 'Soumettre le revenu',
|
submitIncome: 'Soumettre le revenu',
|
||||||
notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.',
|
notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface LocaleMessages {
|
||||||
profileDescription: string
|
profileDescription: string
|
||||||
profileLoggedOutDescription: string
|
profileLoggedOutDescription: string
|
||||||
login: string
|
login: string
|
||||||
|
menu: string
|
||||||
backToHub: string
|
backToHub: string
|
||||||
hub: string
|
hub: string
|
||||||
theme: string
|
theme: string
|
||||||
|
|
@ -58,6 +59,27 @@ export interface LocaleMessages {
|
||||||
events?: {
|
events?: {
|
||||||
title: string
|
title: string
|
||||||
createNew: string
|
createNew: string
|
||||||
|
create: {
|
||||||
|
promo: {
|
||||||
|
title: string
|
||||||
|
hint: string
|
||||||
|
empty: string
|
||||||
|
code: string
|
||||||
|
codePlaceholder: string
|
||||||
|
discount: string
|
||||||
|
maxUses: string
|
||||||
|
active: string
|
||||||
|
used: string
|
||||||
|
add: string
|
||||||
|
removeLabel: string
|
||||||
|
errors: {
|
||||||
|
required: string
|
||||||
|
duplicate: string
|
||||||
|
percentRange: string
|
||||||
|
maxUsesMin: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
noEvents: string
|
noEvents: string
|
||||||
filters: {
|
filters: {
|
||||||
all: string
|
all: string
|
||||||
|
|
@ -69,19 +91,21 @@ export interface LocaleMessages {
|
||||||
hosting: string
|
hosting: string
|
||||||
pastEvents: string
|
pastEvents: string
|
||||||
past: string
|
past: string
|
||||||
|
filters: string
|
||||||
|
clearAll: string
|
||||||
|
filteringBy: string
|
||||||
|
removeCategory: string
|
||||||
}
|
}
|
||||||
categories: Record<string, string>
|
categories: Record<string, string>
|
||||||
detail: {
|
detail: {
|
||||||
getTicket: string
|
getTicket: string
|
||||||
going: string
|
|
||||||
maybe: string
|
|
||||||
notGoing: string
|
|
||||||
contactOrganizer: string
|
contactOrganizer: string
|
||||||
organizer: string
|
organizer: string
|
||||||
location: string
|
location: string
|
||||||
when: string
|
when: string
|
||||||
tickets: string
|
tickets: string
|
||||||
ticketsAvailable: string
|
ticketsAvailable: string
|
||||||
|
ticketsRemainingOfTotal: string
|
||||||
ticketsOwned: string
|
ticketsOwned: string
|
||||||
unlimitedTickets: string
|
unlimitedTickets: string
|
||||||
buyTicket: string
|
buyTicket: string
|
||||||
|
|
@ -101,6 +125,65 @@ export interface LocaleMessages {
|
||||||
pending: string
|
pending: string
|
||||||
registered: string
|
registered: string
|
||||||
}
|
}
|
||||||
|
purchase: {
|
||||||
|
card: string
|
||||||
|
continueTo: string
|
||||||
|
fiatRedirectHint: string
|
||||||
|
noCheckoutUrl: string
|
||||||
|
fiatFailed: string
|
||||||
|
loginRequiredTitle: string
|
||||||
|
loginRequiredFree: string
|
||||||
|
promo: {
|
||||||
|
label: string
|
||||||
|
placeholder: string
|
||||||
|
apply: string
|
||||||
|
remove: string
|
||||||
|
checking: string
|
||||||
|
applied: string
|
||||||
|
discountLine: string
|
||||||
|
invalid: string
|
||||||
|
unverified: string
|
||||||
|
loginRequiredDiscount: string
|
||||||
|
}
|
||||||
|
contact: {
|
||||||
|
title: string
|
||||||
|
name: string
|
||||||
|
namePlaceholder: string
|
||||||
|
nameRequired: string
|
||||||
|
email: string
|
||||||
|
emailHint: string
|
||||||
|
spamHint: string
|
||||||
|
emailInvalid: string
|
||||||
|
confirmEmail: string
|
||||||
|
emailMismatch: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkout: {
|
||||||
|
title: string
|
||||||
|
paidTitle: string
|
||||||
|
processing: string
|
||||||
|
stillProcessing: string
|
||||||
|
stillProcessingNoEmail: string
|
||||||
|
emailedTo: string
|
||||||
|
cancelled: string
|
||||||
|
viewTicket: string
|
||||||
|
viewMyTickets: string
|
||||||
|
retry: string
|
||||||
|
ticketN: string
|
||||||
|
close: string
|
||||||
|
}
|
||||||
|
ticket: {
|
||||||
|
title: string
|
||||||
|
loading: string
|
||||||
|
notFound: string
|
||||||
|
event: string
|
||||||
|
holder: string
|
||||||
|
paid: string
|
||||||
|
pending: string
|
||||||
|
registered: string
|
||||||
|
showAtDoor: string
|
||||||
|
backToEvent: string
|
||||||
|
}
|
||||||
nav: {
|
nav: {
|
||||||
feed: string
|
feed: string
|
||||||
calendar: string
|
calendar: string
|
||||||
|
|
@ -176,6 +259,7 @@ export interface LocaleMessages {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
selectAccount: string
|
selectAccount: string
|
||||||
|
otherAccountHint: string
|
||||||
submitIncome: string
|
submitIncome: string
|
||||||
notAvailable: string
|
notAvailable: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
src/lib/nostr/timestamp.spec.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
30
src/lib/nostr/timestamp.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
|
@ -77,3 +77,16 @@ export const catchAllRoute: RouteRecordRaw = {
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
redirect: '/',
|
redirect: '/',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to send the user after a successful login. Honours a `?redirect=`
|
||||||
|
* query param so a page can bounce an anonymous user through /login and
|
||||||
|
* back (e.g. chatelet's Book button). Only same-origin absolute paths are
|
||||||
|
* accepted — anything else (external URLs, protocol-relative `//host`)
|
||||||
|
* falls back to home.
|
||||||
|
*/
|
||||||
|
export function postLoginTarget(redirect: unknown): string {
|
||||||
|
if (typeof redirect !== 'string') return '/'
|
||||||
|
if (!redirect.startsWith('/') || redirect.startsWith('//')) return '/'
|
||||||
|
return redirect
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-medium">Profile Settings</h3>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
Manage your profile information and Nostr identity
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<form @submit="onSubmit" class="space-y-6">
|
<form @submit="onSubmit" class="space-y-6">
|
||||||
<!-- Profile Picture -->
|
<!-- Profile Picture -->
|
||||||
<FormField name="picture">
|
<FormField name="picture">
|
||||||
|
|
@ -17,22 +8,26 @@
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Upload a profile picture. This will be published to your Nostr profile.
|
Upload a profile picture. This will be published to your Nostr profile.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<div class="flex items-center gap-4">
|
<!-- 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">
|
||||||
<!-- Current picture preview -->
|
<!-- Current picture preview -->
|
||||||
<div v-if="currentPictureUrl" class="relative">
|
<div v-if="currentPictureUrl" class="relative shrink-0">
|
||||||
<img
|
<img
|
||||||
:src="currentPictureUrl"
|
:src="currentPictureUrl"
|
||||||
alt="Profile picture"
|
alt="Profile picture"
|
||||||
class="h-20 w-20 rounded-full object-cover border-2 border-border"
|
class="h-20 w-20 rounded-full object-cover border-2 border-border"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border">
|
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border shrink-0">
|
||||||
<User class="h-10 w-10 text-muted-foreground" />
|
<User class="h-10 w-10 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload component. Avatars are small; tighten the
|
<!-- Upload component. Avatars are small; tighten the
|
||||||
default compress knobs so a 4K phone photo lands as
|
default compress knobs so a 4K phone photo lands as
|
||||||
a ~200 KB 512px WebP. -->
|
a ~200 KB 512px WebP. -->
|
||||||
|
<div class="flex-1 min-w-0 w-full">
|
||||||
<ImageUpload
|
<ImageUpload
|
||||||
v-model="uploadedPicture"
|
v-model="uploadedPicture"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
|
|
@ -45,6 +40,7 @@
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -91,26 +87,6 @@
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<!-- Lightning Address / NIP-05 (read-only info) -->
|
|
||||||
<div class="rounded-lg border p-4 space-y-2 bg-muted/50">
|
|
||||||
<h4 class="text-sm font-medium">Nostr Identity</h4>
|
|
||||||
<div class="space-y-1 text-sm">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Zap class="h-4 w-4 text-yellow-500" />
|
|
||||||
<span class="text-muted-foreground">Lightning Address:</span>
|
|
||||||
<code class="text-xs bg-background px-2 py-1 rounded">{{ lightningAddress }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Hash class="h-4 w-4 text-purple-500" />
|
|
||||||
<span class="text-muted-foreground">NIP-05:</span>
|
|
||||||
<code class="text-xs bg-background px-2 py-1 rounded">{{ nip05Preview }}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground mt-2">
|
|
||||||
These identifiers are automatically derived from your username
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Display -->
|
<!-- Error Display -->
|
||||||
<div v-if="updateError" class="text-sm text-destructive">
|
<div v-if="updateError" class="text-sm text-destructive">
|
||||||
{{ updateError }}
|
{{ updateError }}
|
||||||
|
|
@ -135,34 +111,6 @@
|
||||||
Your profile is broadcast to Nostr automatically when you save changes.
|
Your profile is broadcast to Nostr automatically when you save changes.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger as-child>
|
|
||||||
<Button variant="destructive" class="w-full">
|
|
||||||
<LogOut class="mr-2 h-4 w-4" />
|
|
||||||
Log out
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Log out of {{ user?.username || 'your account' }}?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
You'll need to sign in again to access your wallet, post in the
|
|
||||||
forum, place orders, or use any feature that needs your account.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction @click="onLogout" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
||||||
Log out
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -173,8 +121,7 @@ import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { User } from 'lucide-vue-next'
|
||||||
import { User, Zap, Hash } from 'lucide-vue-next'
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
|
|
@ -184,27 +131,13 @@ import {
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import ImageUpload from './ImageUpload.vue'
|
import ImageUpload from './ImageUpload.vue'
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
import { LogOut } from 'lucide-vue-next'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { ImageUploadService } from '../services/ImageUploadService'
|
import type { ImageUploadService } from '../services/ImageUploadService'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const { user, updateProfile, logout } = useAuth()
|
const { user, updateProfile } = useAuth()
|
||||||
const router = useRouter()
|
|
||||||
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
|
@ -224,14 +157,14 @@ const lightningDomain = computed(() =>
|
||||||
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
|
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
|
||||||
)
|
)
|
||||||
|
|
||||||
// Computed previews
|
// 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.
|
||||||
const nip05Preview = computed(() => {
|
const nip05Preview = computed(() => {
|
||||||
const username = form.values.username || currentUsername.value || 'username'
|
const username = form.values.username || currentUsername.value || 'username'
|
||||||
return `${username}@${lightningDomain.value}`
|
return `${username}@${lightningDomain.value}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const lightningAddress = computed(() => nip05Preview.value)
|
|
||||||
|
|
||||||
// Form schema
|
// Form schema
|
||||||
const profileFormSchema = toTypedSchema(z.object({
|
const profileFormSchema = toTypedSchema(z.object({
|
||||||
username: z.string()
|
username: z.string()
|
||||||
|
|
@ -327,17 +260,4 @@ const updateUserProfile = async (formData: any) => {
|
||||||
isUpdating.value = false
|
isUpdating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log out + redirect to /login on this app's origin.
|
|
||||||
const onLogout = async () => {
|
|
||||||
try {
|
|
||||||
await logout()
|
|
||||||
toast.success('Logged out')
|
|
||||||
router.push('/login')
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to log out'
|
|
||||||
console.error('Error logging out:', error)
|
|
||||||
toast.error(`Logout failed: ${errorMessage}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
353
src/modules/chatelet/components/BookRoomDialog.vue
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import * as z from 'zod'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { Check, CheckCircle2, Copy, Loader2, TimerOff, Wallet, Zap } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { formatSats } from '@/lib/utils/formatting'
|
||||||
|
import { useBookingFlow } from '../composables/useBookingFlow'
|
||||||
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
room: Room
|
||||||
|
/** The availability quote the guest just saw — dates + nights + price. */
|
||||||
|
availability: AvailabilityResult
|
||||||
|
isOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:isOpen', value: boolean): void
|
||||||
|
/** Fired once when the invoice settles, before the dialog closes. */
|
||||||
|
(e: 'booked', bookingId: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const { currentUser } = useAuth()
|
||||||
|
const flow = useBookingFlow()
|
||||||
|
|
||||||
|
// --- Guest details form --------------------------------------------------
|
||||||
|
|
||||||
|
const formSchema = computed(() =>
|
||||||
|
toTypedSchema(
|
||||||
|
z.object({
|
||||||
|
num_guests: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, 'At least one guest')
|
||||||
|
.max(props.room.max_guests, `This room sleeps up to ${props.room.max_guests}`),
|
||||||
|
guest_contact: z.string().max(200, 'Too long').optional(),
|
||||||
|
message: z.string().max(1000, 'Too long').optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: formSchema,
|
||||||
|
initialValues: { num_guests: 1, guest_contact: '', message: '' },
|
||||||
|
})
|
||||||
|
const isFormValid = computed(() => form.meta.value.valid)
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
const pubkey = currentUser.value?.pubkey
|
||||||
|
if (!pubkey) {
|
||||||
|
toast.error('Sign in to book a room')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await flow.request({
|
||||||
|
room_id: props.room.id,
|
||||||
|
guest_pubkey: pubkey,
|
||||||
|
check_in: props.availability.check_in,
|
||||||
|
check_out: props.availability.check_out,
|
||||||
|
num_guests: values.num_guests,
|
||||||
|
guest_contact: values.guest_contact?.trim() || undefined,
|
||||||
|
message: values.message?.trim() || undefined,
|
||||||
|
})
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Could not request the booking')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Display helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
function longDate(ymd: string): string {
|
||||||
|
return format(new Date(ymd + 'T00:00:00'), 'EEE, MMM d')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stayLabel = computed(
|
||||||
|
() => `${longDate(props.availability.check_in)} → ${longDate(props.availability.check_out)}`,
|
||||||
|
)
|
||||||
|
const nightsLabel = computed(
|
||||||
|
() => `${props.availability.nights} night${props.availability.nights === 1 ? '' : 's'}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Hold expiry as a wall-clock time, so the guest knows how long the invoice lives. */
|
||||||
|
const holdExpiry = computed(() => {
|
||||||
|
const iso = flow.booking.value?.expires_at
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
return isNaN(d.getTime()) ? '' : format(d, 'p')
|
||||||
|
})
|
||||||
|
|
||||||
|
const step = computed<'form' | 'invoice' | 'confirmed' | 'lost'>(() => {
|
||||||
|
if (flow.isPaid.value) return 'confirmed'
|
||||||
|
if (flow.lostStatus.value) return 'lost'
|
||||||
|
if (flow.quote.value) return 'invoice'
|
||||||
|
return 'form'
|
||||||
|
})
|
||||||
|
|
||||||
|
const lostLabel = computed(() =>
|
||||||
|
flow.lostStatus.value === 'expired'
|
||||||
|
? 'The hold on your dates expired before the invoice was paid.'
|
||||||
|
: 'The host released these dates before the invoice was paid.',
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => flow.isPaid.value,
|
||||||
|
(paid) => {
|
||||||
|
if (paid && flow.booking.value) emit('booked', flow.booking.value.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Open / close --------------------------------------------------------
|
||||||
|
|
||||||
|
const confirmClose = ref(false)
|
||||||
|
|
||||||
|
function handleClose(open: boolean) {
|
||||||
|
if (open) return
|
||||||
|
// Closing mid-invoice abandons the hold; it expires on its own, but
|
||||||
|
// make the guest confirm so a stray tap doesn't lose the QR.
|
||||||
|
if (step.value === 'invoice' && !confirmClose.value) {
|
||||||
|
confirmClose.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirmClose.value = false
|
||||||
|
emit('update:isOpen', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.isOpen,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
flow.reset()
|
||||||
|
confirmClose.value = false
|
||||||
|
form.resetForm({ values: { num_guests: 1, guest_contact: '', message: '' } })
|
||||||
|
} else {
|
||||||
|
flow.reset()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="handleClose">
|
||||||
|
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ room.title }}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ stayLabel }} · {{ nightsLabel }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<!-- Step 1: who's coming -->
|
||||||
|
<form v-if="step === 'form'" class="space-y-4" @submit="onSubmit">
|
||||||
|
<div class="rounded-lg bg-muted/50 p-3 text-sm flex items-baseline justify-between">
|
||||||
|
<span class="text-muted-foreground">Total</span>
|
||||||
|
<span class="font-semibold text-foreground">
|
||||||
|
<template v-if="availability.quote_sat != null">
|
||||||
|
{{ formatSats(availability.quote_sat) }}
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="availability.quote_fiat != null"
|
||||||
|
class="text-muted-foreground font-normal"
|
||||||
|
>
|
||||||
|
(≈ {{ availability.quote_fiat }} {{ availability.currency }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="num_guests">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Guests</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" min="1" :max="room.max_guests" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Sleeps up to {{ room.max_guests }}</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="guest_contact">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Contact
|
||||||
|
<span class="text-muted-foreground font-normal">(optional)</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Email or phone, in case the host needs to reach you" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="message">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Note to the host
|
||||||
|
<span class="text-muted-foreground font-normal">(optional)</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea rows="3" placeholder="Arrival time, questions…" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button type="submit" class="w-full" :disabled="!isFormValid || flow.isRequesting.value">
|
||||||
|
<Loader2 v-if="flow.isRequesting.value" class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{{ flow.isRequesting.value ? 'Holding your dates…' : 'Continue to payment' }}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Step 2: pay the invoice (events / restaurant invoice-card pattern) -->
|
||||||
|
<div v-else-if="step === 'invoice'" class="py-2 space-y-4">
|
||||||
|
<div class="text-center space-y-1">
|
||||||
|
<h3 class="text-lg font-semibold">Pay to confirm</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
<template v-if="flow.hasWalletWithBalance.value">
|
||||||
|
Scan with any Lightning wallet, or pay from your LNbits wallet below.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Scan with any Lightning wallet, or open it in your wallet app.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
|
<!-- White background regardless of theme so phone cameras parse it. -->
|
||||||
|
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||||
|
<img
|
||||||
|
v-if="flow.qrCode.value"
|
||||||
|
:src="flow.qrCode.value"
|
||||||
|
alt="Lightning payment QR code"
|
||||||
|
class="block h-56 w-56 sm:h-64 sm:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-xs text-muted-foreground">Amount</span>
|
||||||
|
<span class="font-mono text-sm font-semibold text-primary">
|
||||||
|
{{ formatSats(flow.booking.value?.deposit_sat ?? 0) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" class="flex-1 font-mono text-xs" @click="flow.copyInvoice">
|
||||||
|
<Check v-if="flow.copiedInvoice.value" class="mr-2 h-3.5 w-3.5" />
|
||||||
|
<Copy v-else class="mr-2 h-3.5 w-3.5" />
|
||||||
|
{{ flow.copiedInvoice.value ? 'Copied' : 'Copy' }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" size="sm" class="flex-1 text-xs" @click="flow.openExternalWallet">
|
||||||
|
<Zap class="mr-2 h-3.5 w-3.5" />
|
||||||
|
Open in wallet
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="flow.hasWalletWithBalance.value"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="flow.isPayingWithWallet.value"
|
||||||
|
@click="flow.payWithWallet"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="flow.isPayingWithWallet.value" class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
<Wallet v-else class="mr-2 h-4 w-4" />
|
||||||
|
{{ flow.isPayingWithWallet.value ? 'Paying…' : 'Pay from my LNbits wallet' }}
|
||||||
|
</Button>
|
||||||
|
<p
|
||||||
|
v-else-if="flow.userWallets.value.length > 0"
|
||||||
|
class="text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Your LNbits wallet is empty — pay with an external wallet using the QR
|
||||||
|
or "Open in wallet" above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="flow.isPaymentPending.value" class="text-center space-y-1">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||||
|
<span class="text-sm text-muted-foreground">Waiting for payment…</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Your dates are held<template v-if="holdExpiry"> until {{ holdExpiry }}</template>.
|
||||||
|
Confirmation lands automatically — no need to refresh.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="confirmClose" class="text-center text-xs text-destructive">
|
||||||
|
Close without paying? The hold expires on its own. Tap outside again to close.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hold lost before payment: nothing to pay any more -->
|
||||||
|
<div v-else-if="step === 'lost'" class="py-4 space-y-4 text-center">
|
||||||
|
<TimerOff class="mx-auto h-12 w-12 text-muted-foreground" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-lg font-semibold">Dates released</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">{{ lostLabel }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
If you already paid, the host will see it — otherwise check the dates
|
||||||
|
again and start over.
|
||||||
|
</p>
|
||||||
|
<Button class="w-full" @click="emit('update:isOpen', false)">Close</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: paid -->
|
||||||
|
<div v-else class="py-4 space-y-4 text-center">
|
||||||
|
<CheckCircle2 class="mx-auto h-12 w-12 text-primary" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-lg font-semibold">You're booked</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ stayLabel }} · {{ nightsLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-muted/50 p-3 text-sm space-y-1 text-left">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Booking</span>
|
||||||
|
<span class="font-mono">{{ flow.booking.value?.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-muted-foreground">Paid</span>
|
||||||
|
<span class="font-semibold">{{ formatSats(flow.booking.value?.deposit_sat ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Check-in details arrive as a private Nostr message from the host.
|
||||||
|
</p>
|
||||||
|
<Button class="w-full" @click="emit('update:isOpen', false)">Done</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
260
src/modules/chatelet/components/StayDatePicker.vue
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, shallowRef, watch } from 'vue'
|
||||||
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
CalendarDate,
|
||||||
|
getLocalTimeZone,
|
||||||
|
parseDate,
|
||||||
|
today,
|
||||||
|
type DateValue,
|
||||||
|
} from '@internationalized/date'
|
||||||
|
import type { DateRange as RekaDateRange } from 'reka-ui'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { RangeCalendar } from '@/components/ui/range-calendar'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { addDays, nextUnavailableOnOrAfter, nightsBetween, stayIsFree } from '../lib/unavailable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Airbnb-style stay picker: one range calendar where occupied nights are
|
||||||
|
* struck through, check-out may land on the morning another guest arrives,
|
||||||
|
* and a stay can never straddle an occupied night.
|
||||||
|
*
|
||||||
|
* reka-ui only guards clicks with `isDateDisabled` and, with contiguous
|
||||||
|
* ranges, hides the selected styling on `isDateUnavailable` days — so the
|
||||||
|
* matchers below are selection-aware: once a check-in is chosen, the first
|
||||||
|
* occupied night after it stops being "unavailable" (it is a legal
|
||||||
|
* check-out) and every later day is disabled. Both matchers close over refs,
|
||||||
|
* because reka reads them once at setup.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
/** YYYY-MM-DD or '' */
|
||||||
|
checkIn: string
|
||||||
|
checkOut: string
|
||||||
|
/** Sorted occupied nights (YYYY-MM-DD) and the same as a Set. */
|
||||||
|
nights: string[]
|
||||||
|
nightSet: Set<string>
|
||||||
|
minNights: number
|
||||||
|
/** Last selectable day, YYYY-MM-DD (the loaded window end). */
|
||||||
|
maxDate?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:checkIn': [value: string]
|
||||||
|
'update:checkOut': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isWide = useMediaQuery('(min-width: 640px)')
|
||||||
|
const todayYmd = today(getLocalTimeZone()).toString()
|
||||||
|
|
||||||
|
function toYmd(d: DateValue | undefined): string {
|
||||||
|
return d ? new CalendarDate(d.year, d.month, d.day).toString() : ''
|
||||||
|
}
|
||||||
|
function fromYmd(ymd: string): CalendarDate | undefined {
|
||||||
|
if (!ymd) return undefined
|
||||||
|
try {
|
||||||
|
return parseDate(ymd)
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- selection state -------------------------------------------------------
|
||||||
|
|
||||||
|
/** The in-progress check-in while the guest is still picking a check-out. */
|
||||||
|
const pendingStart = ref<string>('')
|
||||||
|
const bothSet = computed(() => Boolean(props.checkIn && props.checkOut))
|
||||||
|
const activeStart = computed(() => (bothSet.value ? '' : pendingStart.value || props.checkIn))
|
||||||
|
|
||||||
|
/** First occupied night after the chosen check-in: the last legal check-out. */
|
||||||
|
const boundary = computed(() =>
|
||||||
|
activeStart.value
|
||||||
|
? nextUnavailableOnOrAfter(props.nights, addDays(activeStart.value, 1))
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
// shallowRef: DateValue instances must not be made deeply reactive (their
|
||||||
|
// private fields break reka's typings and identity checks).
|
||||||
|
const model = shallowRef<RekaDateRange>({ start: fromYmd(props.checkIn), end: fromYmd(props.checkOut) })
|
||||||
|
watch(
|
||||||
|
() => [props.checkIn, props.checkOut] as const,
|
||||||
|
([ci, co]) => {
|
||||||
|
model.value = { start: fromYmd(ci), end: fromYmd(co) }
|
||||||
|
if (ci && co) pendingStart.value = ''
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const placeholder = shallowRef<DateValue>(fromYmd(props.checkIn) ?? today(getLocalTimeZone()))
|
||||||
|
|
||||||
|
const minValue = today(getLocalTimeZone())
|
||||||
|
const maxValue = computed(() => (props.maxDate ? fromYmd(props.maxDate) : undefined))
|
||||||
|
|
||||||
|
// --- matchers (stable functions over reactive state) ------------------------
|
||||||
|
|
||||||
|
function isDateUnavailable(d: DateValue): boolean {
|
||||||
|
const y = toYmd(d)
|
||||||
|
if (!props.nightSet.has(y)) return false
|
||||||
|
// The boundary night is a legal check-out, and a committed check-out that
|
||||||
|
// sits on an occupied night must keep rendering as selected.
|
||||||
|
if (y === boundary.value || (bothSet.value && y === props.checkOut)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateDisabled(d: DateValue): boolean {
|
||||||
|
const y = toYmd(d)
|
||||||
|
if (y < todayYmd) return true
|
||||||
|
const start = activeStart.value
|
||||||
|
if (!start) {
|
||||||
|
// Picking a check-in: occupied nights are out; everything else is fair.
|
||||||
|
return props.nightSet.has(y) && !(bothSet.value && y === props.checkOut)
|
||||||
|
}
|
||||||
|
if (y === start) return false
|
||||||
|
if (y > start) {
|
||||||
|
if (y < addDays(start, props.minNights)) return true
|
||||||
|
if (boundary.value && y > boundary.value) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Clicking before the chosen check-in restarts the range there.
|
||||||
|
return props.nightSet.has(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- events -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function onStartValue(v: DateValue | undefined) {
|
||||||
|
pendingStart.value = toYmd(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModel(v: RekaDateRange | null) {
|
||||||
|
const ci = toYmd(v?.start)
|
||||||
|
const co = toYmd(v?.end)
|
||||||
|
if (ci && co) {
|
||||||
|
const ok =
|
||||||
|
stayIsFree(props.nightSet, ci, co) && nightsBetween(ci, co) >= props.minNights
|
||||||
|
if (!ok) {
|
||||||
|
// Defensive: reka let a bad range through — keep the check-in, redo the end.
|
||||||
|
pendingStart.value = ci
|
||||||
|
emit('update:checkIn', ci)
|
||||||
|
emit('update:checkOut', '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingStart.value = ''
|
||||||
|
emit('update:checkIn', ci)
|
||||||
|
emit('update:checkOut', co)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ci && !co) {
|
||||||
|
pendingStart.value = ci
|
||||||
|
emit('update:checkIn', ci)
|
||||||
|
emit('update:checkOut', '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingStart.value = ''
|
||||||
|
emit('update:checkIn', '')
|
||||||
|
emit('update:checkOut', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
onModel(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCheckOut() {
|
||||||
|
if (!props.checkIn) return
|
||||||
|
onModel({ start: fromYmd(props.checkIn), end: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- display ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function pretty(ymd: string): string {
|
||||||
|
return ymd ? format(new Date(ymd + 'T00:00:00'), 'MMM d, yyyy') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const nights = computed(() => (bothSet.value ? nightsBetween(props.checkIn, props.checkOut) : 0))
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (bothSet.value) return `${nights.value} night${nights.value === 1 ? '' : 's'}`
|
||||||
|
if (props.checkIn) return 'Select check-out date'
|
||||||
|
return 'Select check-in date'
|
||||||
|
})
|
||||||
|
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
if (bothSet.value) return `${pretty(props.checkIn)} – ${pretty(props.checkOut)}`
|
||||||
|
if (props.checkIn) return `Minimum stay ${props.minNights} night${props.minNights === 1 ? '' : 's'}`
|
||||||
|
return 'Add your travel dates for exact pricing'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Summary header + the two labelled date boxes -->
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-semibold text-foreground">{{ title }}</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 rounded-lg border border-input text-left">
|
||||||
|
<div
|
||||||
|
:class="cn('px-3 py-2', !checkIn && 'ring-2 ring-ring rounded-l-lg')"
|
||||||
|
>
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Check-in</p>
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<span :class="cn('text-sm', checkIn ? 'text-foreground' : 'text-muted-foreground')">
|
||||||
|
{{ checkIn ? pretty(checkIn) : 'Add date' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="checkIn"
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm opacity-60 hover:opacity-100"
|
||||||
|
aria-label="Clear check-in"
|
||||||
|
@click="clear"
|
||||||
|
>
|
||||||
|
<X class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="cn('border-l border-input px-3 py-2', checkIn && !checkOut && 'ring-2 ring-ring rounded-r-lg')"
|
||||||
|
>
|
||||||
|
<p class="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Check-out</p>
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<span :class="cn('text-sm', checkOut ? 'text-foreground' : 'text-muted-foreground')">
|
||||||
|
{{ checkOut ? pretty(checkOut) : 'Add date' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="checkOut"
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm opacity-60 hover:opacity-100"
|
||||||
|
aria-label="Clear check-out"
|
||||||
|
@click="clearCheckOut"
|
||||||
|
>
|
||||||
|
<X class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RangeCalendar
|
||||||
|
:model-value="model"
|
||||||
|
v-model:placeholder="placeholder"
|
||||||
|
:number-of-months="isWide ? 2 : 1"
|
||||||
|
paged-navigation
|
||||||
|
:week-starts-on="1"
|
||||||
|
:min-value="minValue"
|
||||||
|
:max-value="maxValue"
|
||||||
|
:is-date-unavailable="isDateUnavailable"
|
||||||
|
:is-date-disabled="isDateDisabled"
|
||||||
|
class="mx-auto w-fit p-0"
|
||||||
|
@update:model-value="onModel"
|
||||||
|
@update:start-value="onStartValue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="inline-block h-3 w-3 rounded-sm bg-muted line-through opacity-70"></span>
|
||||||
|
Unavailable
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" :disabled="!checkIn" @click="clear">Clear dates</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
150
src/modules/chatelet/composables/useBookingFlow.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
|
||||||
|
import type { PaymentService } from '@/core/services/PaymentService'
|
||||||
|
import type { ChateletApiService } from '../services/ChateletApiService'
|
||||||
|
import { LOST_BOOKING_STATUSES, type BookingQuote, type BookingRequest, type BookingStatus } from '../types/booking'
|
||||||
|
|
||||||
|
const POLL_MS = 2000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives one booking from request → invoice → paid. Modeled on events'
|
||||||
|
* useTicketPurchase: the backend holds the dates and returns a bolt11;
|
||||||
|
* this composable renders it (QR / copy / open-in-wallet / pay from the
|
||||||
|
* LNbits wallet) and polls the booking until the extension's paid-invoice
|
||||||
|
* listener flips it to `confirmed` — or the hold lapses (`expired`).
|
||||||
|
*/
|
||||||
|
export function useBookingFlow() {
|
||||||
|
// DI, never a direct import (workspace rule).
|
||||||
|
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
|
||||||
|
const paymentService = tryInjectService<PaymentService>(SERVICE_TOKENS.PAYMENT_SERVICE)
|
||||||
|
|
||||||
|
const quote = ref<BookingQuote | null>(null)
|
||||||
|
const qrCode = ref<string | null>(null)
|
||||||
|
const isRequesting = ref(false)
|
||||||
|
const isPaymentPending = ref(false)
|
||||||
|
const isPaid = ref(false)
|
||||||
|
/** Set when the hold is gone (expired / declined / cancelled) before payment. */
|
||||||
|
const lostStatus = ref<BookingStatus | null>(null)
|
||||||
|
const copiedInvoice = ref(false)
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const paymentRequest = computed(() => quote.value?.payment_request ?? '')
|
||||||
|
const booking = computed(() => quote.value?.booking ?? null)
|
||||||
|
|
||||||
|
const userWallets = computed(() => paymentService?.userWallets ?? [])
|
||||||
|
const hasWalletWithBalance = computed(() => paymentService?.hasWalletWithBalance ?? false)
|
||||||
|
const isPayingWithWallet = computed(() => paymentService?.isProcessingPayment.value ?? false)
|
||||||
|
|
||||||
|
/** Hold the dates and get the invoice; starts the settlement poll. */
|
||||||
|
async function request(data: BookingRequest): Promise<BookingQuote | null> {
|
||||||
|
if (!api) return null
|
||||||
|
isRequesting.value = true
|
||||||
|
try {
|
||||||
|
const q = await api.requestBooking(data)
|
||||||
|
quote.value = q
|
||||||
|
try {
|
||||||
|
qrCode.value = paymentService ? await paymentService.generateQRCode(q.payment_request) : null
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error generating QR code:', err)
|
||||||
|
}
|
||||||
|
startPolling(q.booking.id)
|
||||||
|
return q
|
||||||
|
} finally {
|
||||||
|
isRequesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling(bookingId: string) {
|
||||||
|
stopPolling()
|
||||||
|
isPaymentPending.value = true
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
const b = await api?.getPublicBooking(bookingId)
|
||||||
|
if (!b) return
|
||||||
|
if (quote.value) quote.value = { ...quote.value, booking: { ...quote.value.booking, ...b } }
|
||||||
|
if (b.status === 'confirmed' || b.status === 'checked_in' || b.status === 'completed') {
|
||||||
|
isPaid.value = true
|
||||||
|
stopPolling()
|
||||||
|
} else if (LOST_BOOKING_STATUSES.has(b.status)) {
|
||||||
|
lostStatus.value = b.status
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Transient network errors just mean "try again next tick".
|
||||||
|
console.error('Error checking booking status:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void check()
|
||||||
|
pollTimer = setInterval(check, POLL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
isPaymentPending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInvoice() {
|
||||||
|
if (!paymentRequest.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(paymentRequest.value)
|
||||||
|
copiedInvoice.value = true
|
||||||
|
setTimeout(() => (copiedInvoice.value = false), 1500)
|
||||||
|
} catch {
|
||||||
|
// Insecure context / old browser — "Open in wallet" still works.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternalWallet() {
|
||||||
|
if (paymentRequest.value) paymentService?.openExternalWallet(paymentRequest.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pay from the logged-in user's LNbits wallet; the poll picks up settlement. */
|
||||||
|
async function payWithWallet() {
|
||||||
|
if (!paymentService || !paymentRequest.value) return
|
||||||
|
try {
|
||||||
|
await paymentService.payWithWallet(paymentRequest.value, booking.value?.deposit_sat, {
|
||||||
|
showToast: false,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Wallet payment failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
stopPolling()
|
||||||
|
quote.value = null
|
||||||
|
qrCode.value = null
|
||||||
|
isPaid.value = false
|
||||||
|
lostStatus.value = null
|
||||||
|
copiedInvoice.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
|
||||||
|
return {
|
||||||
|
quote,
|
||||||
|
booking,
|
||||||
|
paymentRequest,
|
||||||
|
qrCode,
|
||||||
|
isRequesting,
|
||||||
|
isPaymentPending,
|
||||||
|
isPaid,
|
||||||
|
lostStatus,
|
||||||
|
copiedInvoice,
|
||||||
|
userWallets,
|
||||||
|
hasWalletWithBalance,
|
||||||
|
isPayingWithWallet,
|
||||||
|
request,
|
||||||
|
copyInvoice,
|
||||||
|
openExternalWallet,
|
||||||
|
payWithWallet,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/modules/chatelet/composables/useChatelet.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
|
||||||
|
import type { ChateletApiService } from '../services/ChateletApiService'
|
||||||
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
import { useChateletStore } from '../stores/chatelet'
|
||||||
|
|
||||||
|
export function useChatelet() {
|
||||||
|
const store = useChateletStore()
|
||||||
|
const { rooms, isLoading } = storeToRefs(store)
|
||||||
|
// DI, never a direct import (workspace rule).
|
||||||
|
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
|
||||||
|
|
||||||
|
async function loadRooms(): Promise<void> {
|
||||||
|
if (!api) return
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
store.rooms = await api.getPublicRooms()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRoom(id: string): Promise<Room | null> {
|
||||||
|
if (!api) return null
|
||||||
|
return api.getPublicRoom(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAvailability(
|
||||||
|
roomId: string,
|
||||||
|
checkIn: string,
|
||||||
|
checkOut: string,
|
||||||
|
): Promise<AvailabilityResult | null> {
|
||||||
|
if (!api) return null
|
||||||
|
return api.checkAvailability(roomId, checkIn, checkOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rooms, isLoading, loadRooms, getRoom, checkAvailability }
|
||||||
|
}
|
||||||
48
src/modules/chatelet/composables/useRoomUnavailability.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { computed, ref, watch, type Ref } from 'vue'
|
||||||
|
import { getLocalTimeZone, today } from '@internationalized/date'
|
||||||
|
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
|
||||||
|
import type { ChateletApiService } from '../services/ChateletApiService'
|
||||||
|
import { addDays, expandRanges } from '../lib/unavailable'
|
||||||
|
|
||||||
|
const WINDOW_DAYS = 365
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The occupied nights of one room for the calendar. One fetch of a 12-month
|
||||||
|
* window per room — a few spans, cheap — and the calendar's `maxValue` is
|
||||||
|
* pinned to the window end, so no refetch on month navigation. `refresh()`
|
||||||
|
* after a booking lands, or when a quote comes back "not available" for a
|
||||||
|
* stay the local set thought free (someone else's hold appeared).
|
||||||
|
*/
|
||||||
|
export function useRoomUnavailability(roomId: Ref<string | undefined>) {
|
||||||
|
// DI, never a direct import (workspace rule).
|
||||||
|
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
|
||||||
|
|
||||||
|
const nights = ref<string[]>([])
|
||||||
|
const windowStart = ref(today(getLocalTimeZone()).toString())
|
||||||
|
const windowEnd = ref(addDays(windowStart.value, WINDOW_DAYS))
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const nightSet = computed(() => new Set(nights.value))
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
if (!api || !roomId.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const start = today(getLocalTimeZone()).toString()
|
||||||
|
const res = await api.getUnavailableRanges(roomId.value, start, addDays(start, WINDOW_DAYS))
|
||||||
|
windowStart.value = res.start
|
||||||
|
windowEnd.value = res.end
|
||||||
|
nights.value = expandRanges(res.ranges)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Could not load availability'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(roomId, () => void refresh(), { immediate: true })
|
||||||
|
|
||||||
|
return { nights, nightSet, windowStart, windowEnd, isLoading, error, refresh }
|
||||||
|
}
|
||||||
55
src/modules/chatelet/index.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
|
||||||
|
import { SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { ChateletApiService, type ChateletApiConfig } from './services/ChateletApiService'
|
||||||
|
|
||||||
|
export interface ChateletModuleConfig {
|
||||||
|
apiConfig: ChateletApiConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chatelet module — guest booking UI for the LNbits `chatelet` room-rentals
|
||||||
|
* extension: discovery, availability and Lightning-paid booking over HTTP.
|
||||||
|
*/
|
||||||
|
export const chateletModule = createModulePlugin({
|
||||||
|
name: 'chatelet',
|
||||||
|
version: '1.0.0',
|
||||||
|
dependencies: ['base'],
|
||||||
|
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/chatelet',
|
||||||
|
name: 'chatelet',
|
||||||
|
component: () => import('./views/ChateletPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: (import.meta.env.VITE_APP_NAME as string) || 'Chatelet',
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/chatelet/:id',
|
||||||
|
name: 'chatelet-detail',
|
||||||
|
component: () => import('./views/ChateletDetailPage.vue'),
|
||||||
|
meta: { title: 'Room', requiresAuth: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
onInstall: async (_app, options) => {
|
||||||
|
const config = options?.config as ChateletModuleConfig | undefined
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Chatelet module requires configuration')
|
||||||
|
}
|
||||||
|
const { container } = await import('@/core/di-container')
|
||||||
|
const api = new ChateletApiService(config.apiConfig)
|
||||||
|
container.provide(SERVICE_TOKENS.CHATELET_API, api)
|
||||||
|
},
|
||||||
|
|
||||||
|
onUninstall: async () => {
|
||||||
|
const { container } = await import('@/core/di-container')
|
||||||
|
container.remove(SERVICE_TOKENS.CHATELET_API)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default chateletModule
|
||||||
|
|
||||||
|
export type { Room, AvailabilityResult } from './types/room'
|
||||||
|
export type { Booking, BookingQuote, BookingRequest, BookingStatus } from './types/booking'
|
||||||
50
src/modules/chatelet/lib/unavailable.spec.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
expandRanges,
|
||||||
|
nextUnavailableOnOrAfter,
|
||||||
|
nightsBetween,
|
||||||
|
stayIsFree,
|
||||||
|
} from './unavailable'
|
||||||
|
|
||||||
|
describe('unavailable-night helpers', () => {
|
||||||
|
it('addDays crosses month and year boundaries', () => {
|
||||||
|
expect(addDays('2026-10-31', 1)).toBe('2026-11-01')
|
||||||
|
expect(addDays('2026-12-31', 1)).toBe('2027-01-01')
|
||||||
|
expect(addDays('2026-03-01', -1)).toBe('2026-02-28')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nightsBetween is check_out-exclusive', () => {
|
||||||
|
expect(nightsBetween('2026-10-05', '2026-10-07')).toBe(2)
|
||||||
|
expect(nightsBetween('2026-10-05', '2026-10-05')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expandRanges yields sorted, de-duplicated occupied nights (end exclusive)', () => {
|
||||||
|
const nights = expandRanges([
|
||||||
|
{ start: '2026-10-10', end: '2026-10-12' },
|
||||||
|
{ start: '2026-10-05', end: '2026-10-07' },
|
||||||
|
{ start: '2026-10-11', end: '2026-10-13' },
|
||||||
|
])
|
||||||
|
expect(nights).toEqual(['2026-10-05', '2026-10-06', '2026-10-10', '2026-10-11', '2026-10-12'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nextUnavailableOnOrAfter finds the boundary night', () => {
|
||||||
|
const nights = ['2026-10-05', '2026-10-06', '2026-10-10']
|
||||||
|
expect(nextUnavailableOnOrAfter(nights, '2026-10-01')).toBe('2026-10-05')
|
||||||
|
expect(nextUnavailableOnOrAfter(nights, '2026-10-05')).toBe('2026-10-05')
|
||||||
|
expect(nextUnavailableOnOrAfter(nights, '2026-10-07')).toBe('2026-10-10')
|
||||||
|
expect(nextUnavailableOnOrAfter(nights, '2026-10-11')).toBeUndefined()
|
||||||
|
expect(nextUnavailableOnOrAfter([], '2026-10-11')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stayIsFree honours half-open semantics', () => {
|
||||||
|
const set = new Set(['2026-10-05', '2026-10-06'])
|
||||||
|
// checking out the morning the other guest checks in is fine
|
||||||
|
expect(stayIsFree(set, '2026-10-03', '2026-10-05')).toBe(true)
|
||||||
|
// checking in the morning they leave is fine too
|
||||||
|
expect(stayIsFree(set, '2026-10-07', '2026-10-09')).toBe(true)
|
||||||
|
// spanning an occupied night is not
|
||||||
|
expect(stayIsFree(set, '2026-10-04', '2026-10-06')).toBe(false)
|
||||||
|
expect(stayIsFree(set, '2026-10-06', '2026-10-06')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
49
src/modules/chatelet/lib/unavailable.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import type { DateRange } from '../types/room'
|
||||||
|
|
||||||
|
/** YYYY-MM-DD → YYYY-MM-DD, `days` later. Date-only math, no timezone. */
|
||||||
|
export function addDays(ymd: string, days: number): string {
|
||||||
|
const d = new Date(ymd + 'T00:00:00Z')
|
||||||
|
d.setUTCDate(d.getUTCDate() + days)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nights between two YYYY-MM-DD dates (check_out exclusive). */
|
||||||
|
export function nightsBetween(checkIn: string, checkOut: string): number {
|
||||||
|
const a = Date.UTC(+checkIn.slice(0, 4), +checkIn.slice(5, 7) - 1, +checkIn.slice(8, 10))
|
||||||
|
const b = Date.UTC(+checkOut.slice(0, 4), +checkOut.slice(5, 7) - 1, +checkOut.slice(8, 10))
|
||||||
|
return Math.round((b - a) / 86_400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand half-open [start, end) spans into the sorted, de-duplicated list of
|
||||||
|
* occupied nights. A year of a busy room is a few hundred strings — cheap to
|
||||||
|
* hold and to binary-search.
|
||||||
|
*/
|
||||||
|
export function expandRanges(ranges: DateRange[]): string[] {
|
||||||
|
const out = new Set<string>()
|
||||||
|
for (const r of ranges) {
|
||||||
|
for (let d = r.start; d < r.end; d = addDays(d, 1)) out.add(d)
|
||||||
|
}
|
||||||
|
return [...out].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First occupied night ≥ `ymd`, or undefined if none in the loaded window. */
|
||||||
|
export function nextUnavailableOnOrAfter(nights: string[], ymd: string): string | undefined {
|
||||||
|
let lo = 0
|
||||||
|
let hi = nights.length
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1
|
||||||
|
if (nights[mid] < ymd) lo = mid + 1
|
||||||
|
else hi = mid
|
||||||
|
}
|
||||||
|
return nights[lo]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when every night of [checkIn, checkOut) is free. */
|
||||||
|
export function stayIsFree(nightSet: Set<string>, checkIn: string, checkOut: string): boolean {
|
||||||
|
if (!(checkIn < checkOut)) return false
|
||||||
|
for (let d = checkIn; d < checkOut; d = addDays(d, 1)) {
|
||||||
|
if (nightSet.has(d)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
112
src/modules/chatelet/services/ChateletApiService.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import type { AvailabilityResult, Room, UnavailableRanges } from '../types/room'
|
||||||
|
import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking'
|
||||||
|
|
||||||
|
export interface ChateletApiConfig {
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP client for the LNbits `chatelet` extension's guest surface.
|
||||||
|
*
|
||||||
|
* Plain config-wrapper (same pattern as events' TicketApiService) — not a
|
||||||
|
* BaseService. Guest browsing, availability, the booking request and the
|
||||||
|
* booking read-back are all keyless public endpoints.
|
||||||
|
*
|
||||||
|
* Transport note: this is HTTP-for-now. The extension also exposes a
|
||||||
|
* kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but
|
||||||
|
* the webapp's RPC client is disabled post-lnbits#9 (see aiolabs/lnbits#64).
|
||||||
|
* When that's revived, only this class swaps HTTP→RPC — no view changes.
|
||||||
|
*/
|
||||||
|
export class ChateletApiService {
|
||||||
|
constructor(private config: ChateletApiConfig) {}
|
||||||
|
|
||||||
|
/** Active rooms for guest browsing (operator-private fields stripped server-side). */
|
||||||
|
async getPublicRooms(): Promise<Room[]> {
|
||||||
|
return this.request('/chatelet/api/v1/public/rooms', { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicRoom(id: string): Promise<Room> {
|
||||||
|
return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nights a guest cannot book over a window (default today → +365 d): live
|
||||||
|
* holds, confirmed stays and manual blocks merged into anonymous spans.
|
||||||
|
* Keyless, like the room read it hangs off.
|
||||||
|
*/
|
||||||
|
async getUnavailableRanges(
|
||||||
|
roomId: string,
|
||||||
|
start?: string,
|
||||||
|
end?: string,
|
||||||
|
): Promise<UnavailableRanges> {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (start) q.set('start', start)
|
||||||
|
if (end) q.set('end', end)
|
||||||
|
const qs = q.toString()
|
||||||
|
return this.request(
|
||||||
|
`/chatelet/api/v1/public/rooms/${roomId}/unavailable${qs ? `?${qs}` : ''}`,
|
||||||
|
{ method: 'GET' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAvailability(
|
||||||
|
roomId: string,
|
||||||
|
checkIn: string,
|
||||||
|
checkOut: string,
|
||||||
|
): Promise<AvailabilityResult> {
|
||||||
|
return this.request('/chatelet/api/v1/availability', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ room_id: roomId, check_in: checkIn, check_out: checkOut }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold the dates and get the invoice that confirms them. The backend
|
||||||
|
* quotes the amount (canonical `booking.deposit_sat`) — the guest never
|
||||||
|
* recomputes what they owe. 502 = invoice creation failed server-side.
|
||||||
|
*/
|
||||||
|
async requestBooking(data: BookingRequest): Promise<BookingQuote> {
|
||||||
|
return this.request('/chatelet/api/v1/bookings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyless read-back of the guest's own booking — what the invoice screen
|
||||||
|
* polls. `confirmed` means the extension's paid-invoice listener has
|
||||||
|
* run; `expired` / `declined` mean the hold is gone.
|
||||||
|
*/
|
||||||
|
async getPublicBooking(bookingId: string): Promise<PublicBooking> {
|
||||||
|
return this.request(`/chatelet/api/v1/public/bookings/${bookingId}`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(path: string, init: RequestInit = {}): Promise<any> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
accept: 'application/json',
|
||||||
|
// Guest endpoints are unauthenticated; only send a key if one is set.
|
||||||
|
...(this.config.apiKey ? { 'X-API-KEY': this.config.apiKey } : {}),
|
||||||
|
...((init.headers as Record<string, string>) ?? {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.baseUrl}${path}`, { ...init, headers })
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: `Request failed: ${path}` }))
|
||||||
|
const message =
|
||||||
|
typeof error.detail === 'string'
|
||||||
|
? error.detail
|
||||||
|
: Array.isArray(error.detail)
|
||||||
|
? (error.detail[0]?.msg ?? 'Request failed')
|
||||||
|
: 'Request failed'
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/chatelet/stores/chatelet.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Room } from '../types/room'
|
||||||
|
|
||||||
|
export const useChateletStore = defineStore('chatelet', () => {
|
||||||
|
const rooms = ref<Room[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
return { rooms, isLoading }
|
||||||
|
})
|
||||||
78
src/modules/chatelet/types/booking.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* Booking wire types — mirror `models.py` in the LNbits `chatelet`
|
||||||
|
* extension (BookingRequestData / Booking / BookingQuote). Field names are
|
||||||
|
* kept as the backend spells them so the JSON maps 1:1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BookingStatus =
|
||||||
|
| 'held'
|
||||||
|
| 'awaiting_payment'
|
||||||
|
| 'confirmed'
|
||||||
|
| 'declined'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'expired'
|
||||||
|
| (string & {})
|
||||||
|
|
||||||
|
/** What a guest sends to `POST /chatelet/api/v1/bookings`. */
|
||||||
|
export interface BookingRequest {
|
||||||
|
room_id: string
|
||||||
|
/** Hex pubkey of the guest — the identity the check-in DM goes to. */
|
||||||
|
guest_pubkey: string
|
||||||
|
/** YYYY-MM-DD, inclusive. */
|
||||||
|
check_in: string
|
||||||
|
/** YYYY-MM-DD, exclusive. */
|
||||||
|
check_out: string
|
||||||
|
num_guests: number
|
||||||
|
guest_contact?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Booking {
|
||||||
|
id: string
|
||||||
|
room_id: string
|
||||||
|
guest_pubkey: string
|
||||||
|
guest_contact: string | null
|
||||||
|
check_in: string
|
||||||
|
check_out: string
|
||||||
|
nights: number
|
||||||
|
num_guests: number
|
||||||
|
/** Snapshot of the room's price currency. */
|
||||||
|
currency: string
|
||||||
|
/** nights × room price, display only. */
|
||||||
|
price_fiat: number
|
||||||
|
/** Canonical total due, in sats. */
|
||||||
|
amount_sat: number
|
||||||
|
/** Portion invoiced now (== amount_sat at 100 % prepay). */
|
||||||
|
deposit_sat: number
|
||||||
|
status: BookingStatus
|
||||||
|
payment_hash: string | null
|
||||||
|
/** Hold expiry while held / awaiting_payment; null once confirmed. */
|
||||||
|
expires_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response to a booking request: the held booking + the bolt11 that confirms it. */
|
||||||
|
export interface BookingQuote {
|
||||||
|
booking: Booking
|
||||||
|
payment_request: string
|
||||||
|
payment_hash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyless read-back of the guest's own booking
|
||||||
|
* (`GET /chatelet/api/v1/public/bookings/{id}`, chatelet ≥ v0.4.0): the
|
||||||
|
* Booking minus the guest's pubkey/contact and the Lightning/Nostr plumbing.
|
||||||
|
* The booking id from the quote is the capability.
|
||||||
|
*/
|
||||||
|
export type PublicBooking = Omit<
|
||||||
|
Booking,
|
||||||
|
'guest_pubkey' | 'guest_contact' | 'payment_hash'
|
||||||
|
>
|
||||||
|
|
||||||
|
/** Statuses that mean the hold is gone and the invoice will never confirm. */
|
||||||
|
export const LOST_BOOKING_STATUSES: ReadonlySet<string> = new Set([
|
||||||
|
'expired',
|
||||||
|
'declined',
|
||||||
|
'cancelled',
|
||||||
|
])
|
||||||
58
src/modules/chatelet/types/room.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// Public shapes returned by the chatelet extension's guest endpoints.
|
||||||
|
// Note: operator-private fields (wallet, checkin_instructions) are stripped
|
||||||
|
// server-side by public_room_dict and never reach the client.
|
||||||
|
|
||||||
|
/** The room owner's rules, from their operator settings (chatelet ≥ v0.5.0). */
|
||||||
|
export interface HouseRules {
|
||||||
|
checkin_time: string
|
||||||
|
checkout_time: string
|
||||||
|
cancellation_policy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
price_amount: number
|
||||||
|
price_currency: string
|
||||||
|
price_frequency: string
|
||||||
|
max_guests: number
|
||||||
|
min_nights: number
|
||||||
|
amenities: string[]
|
||||||
|
location: string
|
||||||
|
geohash: string
|
||||||
|
images: string[]
|
||||||
|
status: string
|
||||||
|
listing_event_id: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
/** Absent on chatelet < v0.5.0. */
|
||||||
|
house_rules?: HouseRules
|
||||||
|
/** Rails the owner accepts for this room: 'lightning' and maybe 'fiat'. Absent on < v0.5.0. */
|
||||||
|
payment_methods?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailabilityResult {
|
||||||
|
room_id: string
|
||||||
|
check_in: string
|
||||||
|
check_out: string
|
||||||
|
available: boolean
|
||||||
|
nights: number
|
||||||
|
quote_sat: number | null
|
||||||
|
quote_fiat: number | null
|
||||||
|
currency: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half-open [start, end) span of occupied nights; `end` is the morning the room frees up. */
|
||||||
|
export interface DateRange {
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /chatelet/api/v1/public/rooms/{id}/unavailable` — the guest calendar feed. */
|
||||||
|
export interface UnavailableRanges {
|
||||||
|
room_id: string
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
ranges: DateRange[]
|
||||||
|
}
|
||||||
250
src/modules/chatelet/views/ChateletDetailPage.vue
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
import { Clock, MapPin, Moon, Users } from 'lucide-vue-next'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ImageViewer } from '@/components/ui/image'
|
||||||
|
import { formatSats } from '@/lib/utils/formatting'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { useChatelet } from '../composables/useChatelet'
|
||||||
|
import { useRoomUnavailability } from '../composables/useRoomUnavailability'
|
||||||
|
import { stayIsFree } from '../lib/unavailable'
|
||||||
|
import BookRoomDialog from '../components/BookRoomDialog.vue'
|
||||||
|
import StayDatePicker from '../components/StayDatePicker.vue'
|
||||||
|
import type { AvailabilityResult, Room } from '../types/room'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { getRoom, checkAvailability } = useChatelet()
|
||||||
|
|
||||||
|
const room = ref<Room | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const checkIn = ref('')
|
||||||
|
const checkOut = ref('')
|
||||||
|
const checking = ref(false)
|
||||||
|
const result = ref<AvailabilityResult | null>(null)
|
||||||
|
const showBooking = ref(false)
|
||||||
|
/** Quote snapshot the dialog books against — survives the page's own
|
||||||
|
* `result` being cleared once the dates are taken. */
|
||||||
|
const bookingQuote = ref<AvailabilityResult | null>(null)
|
||||||
|
|
||||||
|
// Occupied nights for the calendar; refreshed after a booking and when a
|
||||||
|
// quote contradicts the local set (someone else's hold appeared).
|
||||||
|
const roomIdRef = computed(() => route.params.id as string | undefined)
|
||||||
|
const unavailability = useRoomUnavailability(roomIdRef)
|
||||||
|
|
||||||
|
// Any date change invalidates the last quote; a complete stay re-quotes
|
||||||
|
// itself (the availability call is the price preview, so no button).
|
||||||
|
watch([checkIn, checkOut], () => {
|
||||||
|
result.value = null
|
||||||
|
})
|
||||||
|
watchDebounced(
|
||||||
|
[checkIn, checkOut],
|
||||||
|
([ci, co]) => {
|
||||||
|
if (ci && co && room.value) void onCheck()
|
||||||
|
},
|
||||||
|
{ debounce: 150 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const YMD = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
room.value = await getRoom(route.params.id as string)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Could not load room')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
// Dates survive a trip through /login (see onBook) as query params.
|
||||||
|
const qi = route.query.check_in
|
||||||
|
const qo = route.query.check_out
|
||||||
|
if (typeof qi === 'string' && YMD.test(qi) && typeof qo === 'string' && YMD.test(qo)) {
|
||||||
|
checkIn.value = qi
|
||||||
|
checkOut.value = qo
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onCheck() {
|
||||||
|
if (!room.value || !checkIn.value || !checkOut.value) return
|
||||||
|
checking.value = true
|
||||||
|
result.value = null
|
||||||
|
try {
|
||||||
|
const ci = checkIn.value
|
||||||
|
const co = checkOut.value
|
||||||
|
const r = await checkAvailability(room.value.id, ci, co)
|
||||||
|
// Only the quote for the dates still selected counts (debounce race).
|
||||||
|
if (ci !== checkIn.value || co !== checkOut.value) return
|
||||||
|
result.value = r
|
||||||
|
if (r && !r.available && stayIsFree(unavailability.nightSet.value, ci, co)) {
|
||||||
|
// The calendar thought these nights were free — it is stale.
|
||||||
|
void unavailability.refresh()
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Availability check failed')
|
||||||
|
} finally {
|
||||||
|
checking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking needs a Nostr identity (the extension DMs check-in details to
|
||||||
|
* the guest pubkey), so anonymous guests bounce through /login and come
|
||||||
|
* back here with their dates intact. Guest checkout is a later slice.
|
||||||
|
*/
|
||||||
|
function onBook() {
|
||||||
|
if (!result.value?.available) return
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
const back = router.resolve({
|
||||||
|
path: route.path,
|
||||||
|
query: { check_in: checkIn.value, check_out: checkOut.value },
|
||||||
|
}).fullPath
|
||||||
|
router.push({ path: '/login', query: { redirect: back } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bookingQuote.value = result.value
|
||||||
|
showBooking.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dates are taken now — drop the stale quote and grey them out. */
|
||||||
|
function onBooked() {
|
||||||
|
result.value = null
|
||||||
|
checkIn.value = ''
|
||||||
|
checkOut.value = ''
|
||||||
|
void unavailability.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showBooking, (open) => {
|
||||||
|
if (!open) bookingQuote.value = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-2xl">
|
||||||
|
<div v-if="loading" class="text-muted-foreground py-12">Loading…</div>
|
||||||
|
<div v-else-if="!room" class="text-muted-foreground py-12">Room not found.</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<!-- Gallery: thumbnails + lightbox when there is more than one photo
|
||||||
|
(same ImageViewer the marketplace product page uses). -->
|
||||||
|
<ImageViewer
|
||||||
|
v-if="room.images?.length"
|
||||||
|
:images="room.images"
|
||||||
|
:alt="room.title"
|
||||||
|
container-class="aspect-[4/3] sm:aspect-video rounded-lg overflow-hidden bg-muted"
|
||||||
|
image-class="w-full h-full object-cover"
|
||||||
|
:show-thumbnails="room.images.length > 1"
|
||||||
|
:show-lightbox="true"
|
||||||
|
:show-badge="room.images.length > 1"
|
||||||
|
:show-cycle-controls="room.images.length > 1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">{{ room.title }}</h1>
|
||||||
|
<p
|
||||||
|
v-if="room.location"
|
||||||
|
class="text-muted-foreground flex items-center gap-1 mt-1"
|
||||||
|
>
|
||||||
|
<MapPin class="h-4 w-4" /> {{ room.location }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground flex items-center gap-1">
|
||||||
|
<Users class="h-4 w-4" /> up to {{ room.max_guests }} guests · min
|
||||||
|
{{ room.min_nights }} night(s)
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-semibold text-foreground mt-2">
|
||||||
|
{{ room.price_amount }} {{ room.price_currency }} / {{ room.price_frequency }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1">
|
||||||
|
<Badge v-for="a in room.amenities" :key="a" variant="outline">{{ a }}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="room.description" class="text-foreground whitespace-pre-line">
|
||||||
|
{{ room.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- House rules come from the room owner's operator settings; older
|
||||||
|
extensions don't send them, so the card is conditional. -->
|
||||||
|
<Card v-if="room.house_rules">
|
||||||
|
<CardContent class="p-4 space-y-2">
|
||||||
|
<h2 class="font-semibold text-foreground">Things to know</h2>
|
||||||
|
<ul class="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<Clock class="h-4 w-4 shrink-0" />
|
||||||
|
Check-in from {{ room.house_rules.checkin_time }} · check-out by
|
||||||
|
{{ room.house_rules.checkout_time }}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<Users class="h-4 w-4 shrink-0" />
|
||||||
|
Up to {{ room.max_guests }} guest{{ room.max_guests === 1 ? '' : 's' }}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<Moon class="h-4 w-4 shrink-0" />
|
||||||
|
Minimum stay {{ room.min_nights }} night{{ room.min_nights === 1 ? '' : 's' }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="room.house_rules.cancellation_policy" class="pt-1">
|
||||||
|
<p class="text-sm font-medium text-foreground">Cancellation policy</p>
|
||||||
|
<p class="text-sm text-muted-foreground whitespace-pre-line">
|
||||||
|
{{ room.house_rules.cancellation_policy }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent class="p-4 space-y-4">
|
||||||
|
<StayDatePicker
|
||||||
|
v-model:check-in="checkIn"
|
||||||
|
v-model:check-out="checkOut"
|
||||||
|
:nights="unavailability.nights.value"
|
||||||
|
:night-set="unavailability.nightSet.value"
|
||||||
|
:min-nights="room.min_nights"
|
||||||
|
:max-date="unavailability.windowEnd.value"
|
||||||
|
/>
|
||||||
|
<p v-if="unavailability.error.value" class="text-xs text-muted-foreground">
|
||||||
|
Could not load the availability calendar — you can still pick dates and check them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="checking" class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||||
|
Checking availability…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="result && !checking" class="text-sm">
|
||||||
|
<template v-if="result.available">
|
||||||
|
<div class="rounded-lg bg-muted/50 p-3 space-y-1">
|
||||||
|
<div class="flex justify-between text-muted-foreground">
|
||||||
|
<span>{{ room.price_amount }} {{ room.price_currency }} × {{ result.nights }} night{{ result.nights === 1 ? '' : 's' }}</span>
|
||||||
|
<span v-if="result.quote_fiat != null">{{ result.quote_fiat }} {{ result.currency }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between font-semibold text-foreground">
|
||||||
|
<span>Total</span>
|
||||||
|
<span v-if="result.quote_sat != null">{{ formatSats(result.quote_sat) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button class="w-full mt-3" @click="onBook">
|
||||||
|
{{ isAuthenticated ? 'Book' : 'Sign in to book' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<p v-else class="text-destructive">Not available for those dates.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BookRoomDialog
|
||||||
|
v-if="room && bookingQuote"
|
||||||
|
:room="room"
|
||||||
|
:availability="bookingQuote"
|
||||||
|
v-model:is-open="showBooking"
|
||||||
|
@booked="onBooked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
80
src/modules/chatelet/views/ChateletPage.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { MapPin, Users } from 'lucide-vue-next'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useChatelet } from '../composables/useChatelet'
|
||||||
|
import type { Room } from '../types/room'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { rooms, isLoading, loadRooms } = useChatelet()
|
||||||
|
|
||||||
|
onMounted(loadRooms)
|
||||||
|
|
||||||
|
function open(id: string) {
|
||||||
|
router.push(`/chatelet/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceLabel(r: Room): string {
|
||||||
|
return `${r.price_amount} ${r.price_currency} / ${r.price_frequency}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground mb-6">Rooms</h1>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Skeleton v-for="n in 4" :key="n" class="h-64 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="rooms.length === 0" class="text-muted-foreground text-center py-16">
|
||||||
|
No rooms available right now.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Card
|
||||||
|
v-for="room in rooms"
|
||||||
|
:key="room.id"
|
||||||
|
class="cursor-pointer overflow-hidden hover:border-ring transition-colors"
|
||||||
|
@click="open(room.id)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="room.images?.length"
|
||||||
|
:src="room.images[0]"
|
||||||
|
:alt="room.title"
|
||||||
|
class="h-40 w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="h-40 w-full bg-muted flex items-center justify-center text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
<CardContent class="p-4">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h3 class="font-semibold text-foreground">{{ room.title }}</h3>
|
||||||
|
<Badge variant="secondary" class="shrink-0">{{ priceLabel(room) }}</Badge>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="room.location"
|
||||||
|
class="text-sm text-muted-foreground mt-1 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<MapPin class="h-3.5 w-3.5" /> {{ room.location }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted-foreground mt-1 flex items-center gap-1">
|
||||||
|
<Users class="h-3.5 w-3.5" /> up to {{ room.max_guests }} guests
|
||||||
|
</p>
|
||||||
|
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1 mt-2">
|
||||||
|
<Badge v-for="a in room.amenities.slice(0, 4)" :key="a" variant="outline">
|
||||||
|
{{ a }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Heart } from 'lucide-vue-next'
|
import { Heart } from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useBookmarks } from '../composables/useBookmarks'
|
import { useBookmarks } from '../composables/useBookmarks'
|
||||||
|
import { useEventLikes } from '../composables/useEventLikes'
|
||||||
import { NIP52_KINDS } from '../types/nip52'
|
import { NIP52_KINDS } from '../types/nip52'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -15,13 +16,56 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated, user } = useAuth()
|
||||||
const { isBookmarked, toggleBookmark } = useBookmarks()
|
const { isBookmarked, toggleBookmark } = useBookmarks()
|
||||||
|
const { track, likeCount, setSelf } = useEventLikes()
|
||||||
|
|
||||||
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
|
||||||
|
const coord = computed(() => `${eventKind.value}:${props.pubkey}:${props.dTag}`)
|
||||||
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
|
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
|
||||||
|
|
||||||
function handleToggle() {
|
// 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() {
|
||||||
if (!isAuthenticated.value) {
|
if (!isAuthenticated.value) {
|
||||||
toast.info('Log in to save favorites', {
|
toast.info('Log in to save favorites', {
|
||||||
action: {
|
action: {
|
||||||
|
|
@ -31,18 +75,26 @@ function handleToggle() {
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toggleBookmark(eventKind.value, props.pubkey, props.dTag)
|
const ok = await toggleBookmark(eventKind.value, props.pubkey, props.dTag)
|
||||||
|
if (!ok) {
|
||||||
|
toast.error("Couldn't save favorite — please try again")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
class="h-8 w-8"
|
class="h-8 gap-1 px-2"
|
||||||
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
|
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
|
||||||
|
:aria-label="bookmarked ? 'Remove favorite' : 'Add favorite'"
|
||||||
@click.stop="handleToggle"
|
@click.stop="handleToggle"
|
||||||
>
|
>
|
||||||
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
|
<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>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
182
src/modules/events/components/CheckoutReturnDialog.vue
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Ticket, Loader2, Clock } from 'lucide-vue-next'
|
||||||
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
|
import { renderTicketQr } from '../lib/ticketQr'
|
||||||
|
import { clearPendingCheckout } from '../lib/pendingCheckout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the buyer lands back on the event page from the fiat
|
||||||
|
* provider with `?checkout=success&tickets=…`. Polls the anonymous
|
||||||
|
* ticket endpoint until every row is paid (Stripe waits for our
|
||||||
|
* webhook before redirecting, so the first poll usually already
|
||||||
|
* says paid), then renders one scannable QR per ticket.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
eventId: string
|
||||||
|
ticketIds: string[]
|
||||||
|
email?: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:isOpen', value: boolean): void }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { refresh: refreshOwnedTickets } = useOwnedTickets()
|
||||||
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
|
||||||
|
const POLL_MS = 2000
|
||||||
|
const TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
type Phase = 'checking' | 'paid' | 'timeout'
|
||||||
|
const phase = ref<Phase>('checking')
|
||||||
|
const qrCodes = ref<Record<string, string>>({})
|
||||||
|
let timer: number | null = null
|
||||||
|
let startedAt = 0
|
||||||
|
|
||||||
|
const paidCount = ref(0)
|
||||||
|
|
||||||
|
async function checkOnce(): Promise<boolean> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
props.ticketIds.map((id) => ticketApi.getTicket(id).then((t) => t.paid).catch(() => false)),
|
||||||
|
)
|
||||||
|
paidCount.value = results.filter(Boolean).length
|
||||||
|
return results.length > 0 && results.every(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPaid() {
|
||||||
|
phase.value = 'paid'
|
||||||
|
stop()
|
||||||
|
clearPendingCheckout()
|
||||||
|
if (isAuthenticated.value) void refreshOwnedTickets()
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const id of props.ticketIds) {
|
||||||
|
try {
|
||||||
|
map[id] = await renderTicketQr(id, { width: 200 })
|
||||||
|
} catch {
|
||||||
|
// leave the slot empty; the ticket page link still works
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qrCodes.value = map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (phase.value !== 'checking') return
|
||||||
|
try {
|
||||||
|
if (await checkOnce()) {
|
||||||
|
await onPaid()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// transient network error — keep polling until the timeout
|
||||||
|
}
|
||||||
|
if (Date.now() - startedAt > TIMEOUT_MS) {
|
||||||
|
phase.value = 'timeout'
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
stop()
|
||||||
|
phase.value = 'checking'
|
||||||
|
qrCodes.value = {}
|
||||||
|
startedAt = Date.now()
|
||||||
|
void tick()
|
||||||
|
timer = window.setInterval(() => void tick(), POLL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.isOpen, props.ticketIds.join(',')] as const,
|
||||||
|
([open]) => {
|
||||||
|
if (open && props.ticketIds.length > 0) start()
|
||||||
|
else stop()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(stop)
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
stop()
|
||||||
|
emit('update:isOpen', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
phase.value === 'paid' ? t('events.checkout.paidTitle') : t('events.checkout.title'),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="close">
|
||||||
|
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<Ticket class="w-5 h-5" />
|
||||||
|
{{ title }}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription v-if="phase === 'paid' && email">
|
||||||
|
{{ t('events.checkout.emailedTo', { email }) }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div v-if="phase === 'checking'" class="py-8 flex flex-col items-center gap-3 text-center">
|
||||||
|
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||||
|
<p class="text-sm text-muted-foreground">{{ t('events.checkout.processing') }}</p>
|
||||||
|
<p v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
|
||||||
|
{{ paidCount }} / {{ ticketIds.length }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="phase === 'timeout'" class="py-6 flex flex-col items-center gap-3 text-center">
|
||||||
|
<Clock class="w-10 h-10 text-muted-foreground" />
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ email
|
||||||
|
? t('events.checkout.stillProcessing', { email })
|
||||||
|
: t('events.checkout.stillProcessingNoEmail') }}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" @click="start">{{ t('events.checkout.retry') }}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="py-4 space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(id, idx) in ticketIds"
|
||||||
|
:key="id"
|
||||||
|
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<span v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
|
||||||
|
{{ t('events.checkout.ticketN', { n: idx + 1, total: ticketIds.length }) }}
|
||||||
|
</span>
|
||||||
|
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||||
|
<img v-if="qrCodes[id]" :src="qrCodes[id]" :alt="`Ticket ${id}`" class="block h-48 w-48" />
|
||||||
|
<div v-else class="h-48 w-48 flex items-center justify-center">
|
||||||
|
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
|
||||||
|
{{ t('events.checkout.viewTicket') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground text-center">{{ t('events.ticket.showAtDoor') }}</p>
|
||||||
|
<div class="space-y-2 pt-1">
|
||||||
|
<Button v-if="isAuthenticated" class="w-full" @click="() => { close(); $router.push('/my-tickets') }">
|
||||||
|
{{ t('events.checkout.viewMyTickets') }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" class="w-full" @click="close">{{ t('events.checkout.close') }}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
@ -26,7 +26,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||||
import { Bell, ChevronDown } from 'lucide-vue-next'
|
import { Bell, ChevronDown, Tag } from 'lucide-vue-next'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -41,12 +41,28 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
||||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||||
import TimePicker from '@/modules/base/components/TimePicker.vue'
|
import TimePicker from '@/modules/base/components/TimePicker.vue'
|
||||||
import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
|
||||||
|
import { CreditCard } from 'lucide-vue-next'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
||||||
import { ALL_CATEGORIES } from '../types/category'
|
import { ALL_CATEGORIES } from '../types/category'
|
||||||
|
import PromoCodesEditor from './PromoCodesEditor.vue'
|
||||||
|
import {
|
||||||
|
fromWirePromoCodes,
|
||||||
|
hasPromoErrors,
|
||||||
|
toWirePromoCodes,
|
||||||
|
validatePromoRows,
|
||||||
|
type PromoCodeRow,
|
||||||
|
} from '../lib/promoCodes'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -124,7 +140,11 @@ const formSchema = toTypedSchema(
|
||||||
event_end_time: z.string().optional().default(''),
|
event_end_time: z.string().optional().default(''),
|
||||||
location: z.string().max(500).optional().default(''),
|
location: z.string().max(500).optional().default(''),
|
||||||
currency: z.string().default("sat"),
|
currency: z.string().default("sat"),
|
||||||
allow_fiat: z.boolean().default(false),
|
// Rails buyers may use. `allow_fiat` on the wire is derived from
|
||||||
|
// this list (fiat ∈ list) so older extensions / clients keep working.
|
||||||
|
payment_methods: z
|
||||||
|
.array(z.enum(['lightning', 'fiat']))
|
||||||
|
.min(1, 'Enable at least one payment method'),
|
||||||
fiat_currency: z.string().default("USD"),
|
fiat_currency: z.string().default("USD"),
|
||||||
amount_tickets: z.number().min(0).max(100000).default(0),
|
amount_tickets: z.number().min(0).max(100000).default(0),
|
||||||
price_per_ticket: z.number().min(0).default(0),
|
price_per_ticket: z.number().min(0).default(0),
|
||||||
|
|
@ -152,7 +172,7 @@ const formSchema = toTypedSchema(
|
||||||
// mirror themselves into fiat_currency automatically. The events
|
// mirror themselves into fiat_currency automatically. The events
|
||||||
// extension uses 'sat' and 'sats' interchangeably — accept both.
|
// extension uses 'sat' and 'sats' interchangeably — accept both.
|
||||||
const isSat = v.currency === 'sat' || v.currency === 'sats'
|
const isSat = v.currency === 'sat' || v.currency === 'sats'
|
||||||
if (v.allow_fiat && isSat && !v.fiat_currency) {
|
if (v.payment_methods.includes('fiat') && isSat && !v.fiat_currency) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['fiat_currency'],
|
path: ['fiat_currency'],
|
||||||
|
|
@ -173,7 +193,7 @@ const form = useForm({
|
||||||
event_end_time: '',
|
event_end_time: '',
|
||||||
location: '',
|
location: '',
|
||||||
currency: 'sat',
|
currency: 'sat',
|
||||||
allow_fiat: false,
|
payment_methods: ['lightning'],
|
||||||
fiat_currency: 'USD',
|
fiat_currency: 'USD',
|
||||||
amount_tickets: 0,
|
amount_tickets: 0,
|
||||||
price_per_ticket: 0,
|
price_per_ticket: 0,
|
||||||
|
|
@ -184,6 +204,42 @@ const form = useForm({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Rails an existing event has on; legacy events (no list) derive
|
||||||
|
* from allow_fiat. */
|
||||||
|
function paymentMethodsOf(event: TicketedEvent): Array<'lightning' | 'fiat'> {
|
||||||
|
const explicit = (event.extra?.payment_methods ?? []).filter(
|
||||||
|
(m): m is 'lightning' | 'fiat' => m === 'lightning' || m === 'fiat',
|
||||||
|
)
|
||||||
|
if (explicit.length > 0) return explicit
|
||||||
|
return event.allow_fiat ? ['lightning', 'fiat'] : ['lightning']
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hasAnyProvider, refresh: refreshFiatProviders } = useFiatProviders()
|
||||||
|
// Refresh once so the Card checkbox reflects providers the organizer may
|
||||||
|
// have just configured in LNbits.
|
||||||
|
refreshFiatProviders()
|
||||||
|
|
||||||
|
const PAYMENT_METHOD_OPTIONS = [
|
||||||
|
{ id: 'lightning' as const, label: 'Lightning', icon: Zap, hint: 'Pay with any Lightning wallet' },
|
||||||
|
{ id: 'fiat' as const, label: 'Card', icon: CreditCard, hint: 'Card or bank through your configured fiat provider' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function isSatDenomination(d: string | undefined): boolean {
|
||||||
|
return d === 'sat' || d === 'sats'
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the price is denominated in a fiat currency, the rail currency
|
||||||
|
// MUST match it — silently mirror so the backend payload stays consistent.
|
||||||
|
watch(
|
||||||
|
() => form.values.currency,
|
||||||
|
(d) => {
|
||||||
|
if (d && !isSatDenomination(d) && form.values.fiat_currency !== d) {
|
||||||
|
form.setFieldValue('fiat_currency', d)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
interface BannerImage extends UploadedImage {
|
interface BannerImage extends UploadedImage {
|
||||||
isPrimary: boolean
|
isPrimary: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +260,7 @@ function splitDateTime(value: string | null | undefined): { date: string; time:
|
||||||
// edit-mode population with start-date side effects mid-setValues.
|
// edit-mode population with start-date side effects mid-setValues.
|
||||||
const isPopulating = ref(false)
|
const isPopulating = ref(false)
|
||||||
const notificationsOpen = ref(false)
|
const notificationsOpen = ref(false)
|
||||||
|
const promoOpen = ref(false)
|
||||||
|
|
||||||
// Auto-mirror end date to start: when the user picks a start date,
|
// Auto-mirror end date to start: when the user picks a start date,
|
||||||
// surface that same date in the end-date picker so a one-day event
|
// surface that same date in the end-date picker so a one-day event
|
||||||
|
|
@ -229,6 +286,10 @@ const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLO
|
||||||
const availableCurrencies = ref<string[]>(['sat'])
|
const availableCurrencies = ref<string[]>(['sat'])
|
||||||
const loadingCurrencies = ref(false)
|
const loadingCurrencies = ref(false)
|
||||||
const selectedCategories = ref<string[]>([])
|
const selectedCategories = ref<string[]>([])
|
||||||
|
// Promo codes live outside vee-validate like categories: a plain list
|
||||||
|
// with its own pure validator (`validatePromoRows`), gating submit below.
|
||||||
|
const promoRows = ref<PromoCodeRow[]>([])
|
||||||
|
const promoErrors = computed(() => validatePromoRows(promoRows.value))
|
||||||
|
|
||||||
async function populateFromEvent(event: TicketedEvent) {
|
async function populateFromEvent(event: TicketedEvent) {
|
||||||
isPopulating.value = true
|
isPopulating.value = true
|
||||||
|
|
@ -243,7 +304,7 @@ async function populateFromEvent(event: TicketedEvent) {
|
||||||
event_end_time: end.time,
|
event_end_time: end.time,
|
||||||
location: event.location ?? '',
|
location: event.location ?? '',
|
||||||
currency: event.currency ?? 'sat',
|
currency: event.currency ?? 'sat',
|
||||||
allow_fiat: event.allow_fiat ?? false,
|
payment_methods: paymentMethodsOf(event),
|
||||||
fiat_currency: event.fiat_currency ?? 'USD',
|
fiat_currency: event.fiat_currency ?? 'USD',
|
||||||
amount_tickets: event.amount_tickets ?? 0,
|
amount_tickets: event.amount_tickets ?? 0,
|
||||||
price_per_ticket: event.price_per_ticket ?? 0,
|
price_per_ticket: event.price_per_ticket ?? 0,
|
||||||
|
|
@ -253,6 +314,10 @@ async function populateFromEvent(event: TicketedEvent) {
|
||||||
notification_body: event.extra?.notification_body ?? '',
|
notification_body: event.extra?.notification_body ?? '',
|
||||||
})
|
})
|
||||||
selectedCategories.value = [...(event.categories ?? [])]
|
selectedCategories.value = [...(event.categories ?? [])]
|
||||||
|
// Codes come only on the organizer-authenticated record (public
|
||||||
|
// responses omit them) — useMyEvents / EventDetailPage hand us that one.
|
||||||
|
promoRows.value = fromWirePromoCodes(event.extra?.promo_codes)
|
||||||
|
promoOpen.value = promoRows.value.length > 0
|
||||||
if (event.banner) {
|
if (event.banner) {
|
||||||
// Re-render the stored banner via its pict-rs file ID. delete_token
|
// Re-render the stored banner via its pict-rs file ID. delete_token
|
||||||
// is intentionally empty: we don't own the original upload's token
|
// is intentionally empty: we don't own the original upload's token
|
||||||
|
|
@ -291,11 +356,13 @@ watch(() => props.open, async (isOpen) => {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
|
promoRows.value = []
|
||||||
|
promoOpen.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { resetForm, meta } = form
|
const { resetForm, meta } = form
|
||||||
const isFormValid = computed(() => meta.value.valid)
|
const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value))
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
|
const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
|
||||||
|
|
@ -354,27 +421,31 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
eventData.banner = null
|
eventData.banner = null
|
||||||
}
|
}
|
||||||
if (formValues.currency) eventData.currency = formValues.currency
|
if (formValues.currency) eventData.currency = formValues.currency
|
||||||
// allow_fiat always sends so a true→false flip propagates on edit;
|
// allow_fiat always sends (derived from the rail list) so a
|
||||||
// fiat_currency only sends when fiat is on (no point persisting a
|
// true→false flip propagates on edit; fiat_currency only sends when
|
||||||
// rail-currency the backend won't use).
|
// fiat is on (no point persisting a rail-currency the backend won't use).
|
||||||
eventData.allow_fiat = formValues.allow_fiat
|
const acceptsFiat = formValues.payment_methods.includes('fiat')
|
||||||
if (formValues.allow_fiat && formValues.fiat_currency) {
|
eventData.allow_fiat = acceptsFiat
|
||||||
|
if (acceptsFiat && formValues.fiat_currency) {
|
||||||
eventData.fiat_currency = formValues.fiat_currency
|
eventData.fiat_currency = formValues.fiat_currency
|
||||||
}
|
}
|
||||||
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
|
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
|
||||||
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
||||||
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
||||||
|
|
||||||
// Notification config goes inside the `extra` envelope. On edit
|
// Notification config + promo codes go inside the `extra` envelope.
|
||||||
// overlay onto the existing event.extra so unrelated fields the
|
// On edit overlay onto the existing event.extra so fields only the
|
||||||
// LNbits admin UI sets (promo_codes, conditional, min_tickets)
|
// LNbits admin UI sets (conditional, min_tickets) survive the
|
||||||
// survive the round-trip.
|
// round-trip. promo_codes is always sent explicitly: what the
|
||||||
|
// organizer sees in the editor is the truth, an empty list clears.
|
||||||
eventData.extra = {
|
eventData.extra = {
|
||||||
...(props.event?.extra ?? {}),
|
...(props.event?.extra ?? {}),
|
||||||
|
payment_methods: formValues.payment_methods,
|
||||||
email_notifications: formValues.email_notifications,
|
email_notifications: formValues.email_notifications,
|
||||||
nostr_notifications: formValues.nostr_notifications,
|
nostr_notifications: formValues.nostr_notifications,
|
||||||
notification_subject: formValues.notification_subject,
|
notification_subject: formValues.notification_subject,
|
||||||
notification_body: formValues.notification_body,
|
notification_body: formValues.notification_body,
|
||||||
|
promo_codes: toWirePromoCodes(promoRows.value),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
|
|
@ -405,6 +476,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
|
|
||||||
resetForm()
|
resetForm()
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
|
promoRows.value = []
|
||||||
bannerImages.value = []
|
bannerImages.value = []
|
||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -424,6 +496,7 @@ const handleOpenChange = (open: boolean) => {
|
||||||
if (!open && !isLoading.value) {
|
if (!open && !isLoading.value) {
|
||||||
resetForm()
|
resetForm()
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
|
promoRows.value = []
|
||||||
bannerImages.value = []
|
bannerImages.value = []
|
||||||
}
|
}
|
||||||
emit('update:open', open)
|
emit('update:open', open)
|
||||||
|
|
@ -432,7 +505,7 @@ const handleOpenChange = (open: boolean) => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="open" @update:open="handleOpenChange">
|
<Dialog :open="open" @update:open="handleOpenChange">
|
||||||
<DialogContent class="max-w-lg max-h-[90vh] p-0">
|
<DialogContent class="max-w-lg max-h-[90vh] p-0 overflow-x-hidden">
|
||||||
<DialogHeader class="px-6 pt-6 pb-2">
|
<DialogHeader class="px-6 pt-6 pb-2">
|
||||||
<DialogTitle class="flex items-center gap-2">
|
<DialogTitle class="flex items-center gap-2">
|
||||||
<Calendar class="w-5 h-5" />
|
<Calendar class="w-5 h-5" />
|
||||||
|
|
@ -611,9 +684,13 @@ const handleOpenChange = (open: boolean) => {
|
||||||
fiat amounts convert at checkout using current rates.
|
fiat amounts convert at checkout using current rates.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<!-- 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">
|
||||||
<FormField v-slot="{ componentField }" name="amount_tickets">
|
<FormField v-slot="{ componentField }" name="amount_tickets">
|
||||||
<FormItem>
|
<FormItem class="min-w-0">
|
||||||
<FormLabel>Tickets</FormLabel>
|
<FormLabel>Tickets</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
|
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
|
||||||
|
|
@ -624,7 +701,7 @@ const handleOpenChange = (open: boolean) => {
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="price_per_ticket">
|
<FormField v-slot="{ componentField }" name="price_per_ticket">
|
||||||
<FormItem>
|
<FormItem class="min-w-0">
|
||||||
<FormLabel>Price</FormLabel>
|
<FormLabel>Price</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
|
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
|
||||||
|
|
@ -635,11 +712,11 @@ const handleOpenChange = (open: boolean) => {
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="currency">
|
<FormField v-slot="{ componentField }" name="currency">
|
||||||
<FormItem>
|
<FormItem class="col-span-2 sm:col-span-1 min-w-0">
|
||||||
<FormLabel>Price currency</FormLabel>
|
<FormLabel>Price currency</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select v-bind="componentField">
|
<Select v-bind="componentField">
|
||||||
<SelectTrigger>
|
<SelectTrigger class="w-full">
|
||||||
<SelectValue placeholder="sat" />
|
<SelectValue placeholder="sat" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -656,26 +733,87 @@ const handleOpenChange = (open: boolean) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Payment methods ──────────────────────────────────── -->
|
<!-- ── Payment methods ──────────────────────────────────── -->
|
||||||
<div class="space-y-3">
|
<FormField name="payment_methods">
|
||||||
|
<FormItem class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium">Payment methods</p>
|
<FormLabel class="text-sm font-medium">Payment methods</FormLabel>
|
||||||
<p class="text-xs text-muted-foreground">
|
<FormDescription class="text-xs">
|
||||||
Lightning is always available. Enable fiat to also accept
|
Pick the rails buyers can pay with. Turn Lightning off for a
|
||||||
card and bank payments through your configured provider.
|
card-only sale.
|
||||||
</p>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
<div class="space-y-2">
|
||||||
<Zap class="w-4 h-4" />
|
<FormField
|
||||||
<span>Lightning — always on</span>
|
v-for="opt in PAYMENT_METHOD_OPTIONS"
|
||||||
</div>
|
:key="opt.id"
|
||||||
<FiatToggleField
|
v-slot="{ value, handleChange }"
|
||||||
allow-fiat-field="allow_fiat"
|
type="checkbox"
|
||||||
fiat-currency-field="fiat_currency"
|
:value="opt.id"
|
||||||
:denomination="form.values.currency ?? 'sat'"
|
:unchecked-value="false"
|
||||||
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
|
name="payment_methods"
|
||||||
|
>
|
||||||
|
<FormItem class="flex flex-row items-center gap-3 space-y-0 rounded-md border p-3">
|
||||||
|
<FormControl>
|
||||||
|
<TooltipProvider v-if="opt.id === 'fiat' && !hasAnyProvider" :delay-duration="200">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<span class="inline-flex">
|
||||||
|
<Checkbox :model-value="false" disabled />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent class="max-w-xs">
|
||||||
|
Your LNbits user has no fiat provider configured. Open
|
||||||
|
LNbits → Account → Fiat providers and add Stripe, PayPal,
|
||||||
|
or Square to enable card payments.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<Checkbox
|
||||||
|
v-else
|
||||||
|
:model-value="Array.isArray(value) && value.includes(opt.id)"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
@update:model-value="handleChange"
|
||||||
/>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<component :is="opt.icon" class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<FormLabel class="font-normal">{{ opt.label }}</FormLabel>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ opt.hint }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="fiat_currency">
|
||||||
|
<FormItem
|
||||||
|
v-show="(form.values.payment_methods ?? []).includes('fiat') && isSatDenomination(form.values.currency)"
|
||||||
|
>
|
||||||
|
<FormLabel>Fiat currency</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select v-bind="componentField" :disabled="isLoading">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="USD" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="c in availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
|
||||||
|
:key="c"
|
||||||
|
:value="c"
|
||||||
|
>
|
||||||
|
{{ c }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription class="text-xs">Currency card buyers are charged in.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<!-- Ticket buyer notifications (collapsible). The backend
|
<!-- Ticket buyer notifications (collapsible). The backend
|
||||||
sends email + NIP-04 Nostr DM confirmations on
|
sends email + NIP-04 Nostr DM confirmations on
|
||||||
|
|
@ -739,12 +877,47 @@ const handleOpenChange = (open: boolean) => {
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Promo codes (collapsible). Percent discounts with an
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
optional redemption cap; the backend hides the list from
|
||||||
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
|
public responses and enforces active/max_uses at purchase. -->
|
||||||
|
<Collapsible v-model:open="promoOpen">
|
||||||
|
<CollapsibleTrigger as-child>
|
||||||
|
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<Tag class="w-4 h-4" />
|
||||||
|
{{ t('events.create.promo.title') }}
|
||||||
|
<Badge v-if="promoRows.length > 0" variant="secondary" class="text-[10px] px-1.5">
|
||||||
|
{{ promoRows.length }}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': promoOpen }" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent class="pt-2">
|
||||||
|
<PromoCodesEditor v-model="promoRows" :disabled="isLoading" />
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<!-- Actions. Stack full-width on narrow viewports so a wide
|
||||||
|
localized "Submit Event" label can't push the Cancel
|
||||||
|
button out of the dialog. Submit reads top-down on
|
||||||
|
mobile (flex-col-reverse) to keep the primary action
|
||||||
|
under the thumb. -->
|
||||||
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
@click="handleOpenChange(false)"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" :disabled="isLoading || !isFormValid">
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
:disabled="isLoading || !isFormValid"
|
||||||
|
>
|
||||||
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
|
||||||
{{
|
{{
|
||||||
isLoading
|
isLoading
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
<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>
|
|
||||||
118
src/modules/events/components/EventCalendarPopup.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<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,6 +12,10 @@ import type { Event } from '../types/event'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
events: Event[]
|
events: Event[]
|
||||||
|
/** When true, render only the month grid for date-picking — no
|
||||||
|
* selected-day events panel — and emit selectDate on every day tap
|
||||||
|
* (used inside the calendar popup). */
|
||||||
|
pickerMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -68,13 +72,21 @@ function getDotCount(date: Date): number {
|
||||||
return Math.min(getEventsForDay(date).length, 3)
|
return Math.min(getEventsForDay(date).length, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedDay = ref<Date | null>(null)
|
// 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 selectedDayEvents = computed(() => {
|
const selectedDayEvents = computed(() => {
|
||||||
if (!selectedDay.value) return []
|
if (!selectedDay.value) return []
|
||||||
return getEventsForDay(selectedDay.value)
|
return getEventsForDay(selectedDay.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
function selectDay(date: Date) {
|
function selectDay(date: Date) {
|
||||||
|
// Picker mode: every tap selects + emits (parent closes the popup).
|
||||||
|
if (props.pickerMode) {
|
||||||
|
selectedDay.value = date
|
||||||
|
emit('selectDate', date)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (selectedDay.value && isSameDay(selectedDay.value, date)) {
|
if (selectedDay.value && isSameDay(selectedDay.value, date)) {
|
||||||
selectedDay.value = null
|
selectedDay.value = null
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -95,7 +107,7 @@ function nextMonth() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-2">
|
||||||
<!-- Month navigation -->
|
<!-- Month navigation -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
|
||||||
|
|
@ -123,7 +135,7 @@ function nextMonth() {
|
||||||
<button
|
<button
|
||||||
v-for="date in calendarDays"
|
v-for="date in calendarDays"
|
||||||
:key="date.toISOString()"
|
:key="date.toISOString()"
|
||||||
class="aspect-square flex flex-col items-center justify-center relative p-1 rounded-lg transition-colors"
|
class="h-12 flex flex-col items-center justify-center relative p-0.5 rounded-lg transition-colors"
|
||||||
:class="{
|
:class="{
|
||||||
'text-muted-foreground/40': !isSameMonth(date, currentMonth),
|
'text-muted-foreground/40': !isSameMonth(date, currentMonth),
|
||||||
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
|
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
|
||||||
|
|
@ -145,8 +157,9 @@ function nextMonth() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selected day events -->
|
<!-- Selected day events (hidden in picker mode — the popup just
|
||||||
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
|
picks a day and closes). -->
|
||||||
|
<div v-if="selectedDay && !pickerMode" class="border-t pt-4 space-y-2">
|
||||||
<h3 class="text-sm font-medium text-muted-foreground">
|
<h3 class="text-sm font-medium text-muted-foreground">
|
||||||
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
|
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
|
||||||
<span v-if="selectedDayEvents.length > 0" class="ml-1">
|
<span v-if="selectedDayEvents.length > 0" class="ml-1">
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,17 @@ import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
|
import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
|
||||||
import BookmarkButton from './BookmarkButton.vue'
|
import BookmarkButton from './BookmarkButton.vue'
|
||||||
|
import OrganizerCard from './OrganizerCard.vue'
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
import type { Event } from '../types/event'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
event: Event
|
event: Event
|
||||||
|
/** Render a compact row: no hero image, no summary, single-line
|
||||||
|
* title, tighter padding. Used by the Hosting view where the
|
||||||
|
* host already knows what their events look like. */
|
||||||
|
compact?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -52,42 +57,64 @@ const priceDisplay = computed(() => {
|
||||||
return `${info.price} ${info.currency}`
|
return `${info.price} ${info.currency}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const placeholderBg = computed(() => {
|
|
||||||
// Generate a consistent hue from the event title
|
|
||||||
const hash = props.event.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
|
||||||
const hue = hash % 360
|
|
||||||
return `hsl(${hue}, 40%, 85%)`
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPast = computed(() => {
|
const isPast = computed(() => {
|
||||||
const a = props.event
|
const a = props.event
|
||||||
const end = a.endDate ?? a.startDate
|
const end = a.endDate ?? a.startDate
|
||||||
if (!end || isNaN(end.getTime())) return false
|
if (!end || isNaN(end.getTime())) return false
|
||||||
return end.getTime() < Date.now()
|
return end.getTime() < Date.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pending / rejected events get a washed-out look so the user
|
||||||
|
// sees at a glance the event isn't live, not just the small badge.
|
||||||
|
const isNonApproved = computed(
|
||||||
|
() => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved',
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- `min-w-0`: the card is a grid item, and a grid item's default
|
||||||
|
`min-width: auto` resolves to its min-content width. The nowrap
|
||||||
|
`truncate` rows below (location, organizer) contribute their
|
||||||
|
full text width to that, so a long address would widen the
|
||||||
|
whole column past the viewport on phones. Letting the item
|
||||||
|
shrink is what makes `truncate` actually truncate. -->
|
||||||
<Card
|
<Card
|
||||||
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
|
class="relative min-w-0 cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||||
@click="emit('click', event)"
|
@click="emit('click', event)"
|
||||||
>
|
>
|
||||||
<!-- Image / Placeholder -->
|
<!-- Wash-out wrapper. The pending/rejected status badge below sits
|
||||||
<div class="relative aspect-[16/9] overflow-hidden">
|
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">
|
||||||
<img
|
<img
|
||||||
v-if="event.image"
|
|
||||||
:src="event.image"
|
:src="event.image"
|
||||||
:alt="event.title"
|
:alt="event.title"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="w-full h-full flex items-center justify-center"
|
|
||||||
:style="{ backgroundColor: placeholderBg }"
|
|
||||||
>
|
|
||||||
<Calendar class="w-12 h-12 text-foreground/20" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category badge -->
|
<!-- Category badge -->
|
||||||
<Badge
|
<Badge
|
||||||
|
|
@ -117,27 +144,13 @@ const isPast = computed(() => {
|
||||||
{{ priceDisplay }}
|
{{ priceDisplay }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<!-- Pending/rejected overlay for the creator's own non-approved
|
<!-- Past badge — shown when the event has already ended. The
|
||||||
drafts. Only present when the event originated from a
|
pending/rejected status badge that used to share this slot
|
||||||
local LNbits event (Nostr-sourced events have no
|
is now an absolute overlay on Card root, above the wash-out,
|
||||||
lnbitsStatus). -->
|
so we still suppress Past when isNonApproved (the status
|
||||||
|
badge is more actionable in that case). -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
v-if="isPast && !isNonApproved"
|
||||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
|
||||||
class="absolute bottom-2 left-2 text-xs capitalize"
|
|
||||||
>
|
|
||||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<!-- Past badge — shown when the event has already ended.
|
|
||||||
Only relevant on the feed when the "Past events" filter
|
|
||||||
chip is toggled on (otherwise these cards aren't rendered);
|
|
||||||
on the detail page the card view isn't used. Suppressed
|
|
||||||
when a pending/rejected status badge is taking the same
|
|
||||||
slot — that case is the creator's own past draft, which is
|
|
||||||
vanishingly rare and the status hint is more actionable. -->
|
|
||||||
<Badge
|
|
||||||
v-if="isPast && !(event.lnbitsStatus && event.lnbitsStatus !== 'approved')"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
||||||
>
|
>
|
||||||
|
|
@ -146,27 +159,71 @@ const isPast = computed(() => {
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
<CardContent
|
||||||
<!-- Title + Bookmark -->
|
: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. -->
|
||||||
<div class="flex items-start gap-1">
|
<div class="flex items-start gap-1">
|
||||||
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
|
<h3
|
||||||
|
:class="[
|
||||||
|
'font-semibold text-foreground leading-tight flex-1',
|
||||||
|
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
|
||||||
|
]"
|
||||||
|
>
|
||||||
{{ event.title }}
|
{{ event.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<BookmarkButton
|
<BookmarkButton
|
||||||
|
v-if="!compact"
|
||||||
:pubkey="event.organizer.pubkey"
|
:pubkey="event.organizer.pubkey"
|
||||||
:d-tag="event.id"
|
:d-tag="event.id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary -->
|
<!-- Summary (hidden in compact mode) -->
|
||||||
<p
|
<p
|
||||||
v-if="event.summary"
|
v-if="event.summary && !compact"
|
||||||
class="text-sm text-muted-foreground line-clamp-2"
|
class="text-sm text-muted-foreground line-clamp-2"
|
||||||
>
|
>
|
||||||
{{ event.summary }}
|
{{ event.summary }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-auto space-y-1.5 pt-2">
|
<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
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Date/Time -->
|
<!-- Date/Time -->
|
||||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Calendar class="w-3.5 h-3.5 shrink-0" />
|
<Calendar class="w-3.5 h-3.5 shrink-0" />
|
||||||
|
|
@ -194,7 +251,7 @@ const isPast = computed(() => {
|
||||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="event.ticketInfo.available > 0">
|
<span v-else-if="event.ticketInfo.available > 0">
|
||||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-destructive font-medium">
|
<span v-else class="text-destructive font-medium">
|
||||||
{{ t('events.detail.soldOut') }}
|
{{ t('events.detail.soldOut') }}
|
||||||
|
|
@ -216,5 +273,22 @@ const isPast = computed(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badge — absolutely positioned on Card root so it sits
|
||||||
|
ABOVE the wash-out wrapper and keeps its full color.
|
||||||
|
Pending + rejected both lean on the destructive token so the
|
||||||
|
non-approved state reads as "needs attention" in every theme;
|
||||||
|
the label text differentiates the two specific states.
|
||||||
|
Bottom-right with a slight downward spill so it anchors
|
||||||
|
visually without competing with the category chip in the
|
||||||
|
badge row (full cards) or the thumbnail (compact cards). -->
|
||||||
|
<Badge
|
||||||
|
v-if="isNonApproved"
|
||||||
|
variant="destructive"
|
||||||
|
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
|
||||||
|
>
|
||||||
|
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
|
</Badge>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import type { Event } from '../types/event'
|
||||||
defineProps<{
|
defineProps<{
|
||||||
events: Event[]
|
events: Event[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
/** Render compact rows instead of full-image cards. Used by the
|
||||||
|
* Hosting view so an operator can scan their roster of events
|
||||||
|
* without the visual weight of hero images they already recognize. */
|
||||||
|
compact?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -39,20 +43,24 @@ const { t } = useI18n()
|
||||||
class="flex flex-col items-center justify-center py-16 text-center"
|
class="flex flex-col items-center justify-center py-16 text-center"
|
||||||
>
|
>
|
||||||
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||||
<h3 class="text-lg font-medium text-foreground mb-1">
|
<h3 class="text-lg font-medium text-foreground">
|
||||||
{{ t('events.noEvents') }}
|
{{ t('events.noEvents') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{{ t('events.search.noResults') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Event grid -->
|
<!-- Event grid — compact mode collapses to a single column of
|
||||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
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'"
|
||||||
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
v-for="event in events"
|
v-for="event in events"
|
||||||
:key="event.nostrEventId"
|
:key="event.nostrEventId"
|
||||||
:event="event"
|
:event="event"
|
||||||
|
:compact="compact"
|
||||||
@click="emit('select', event)"
|
@click="emit('select', event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,13 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||||
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { ProfileService } from '@/modules/base/nostr/ProfileService'
|
||||||
import type { Event } from '../types/event'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
|
/** Event enriched with its resolved organizer display name for search. */
|
||||||
|
type SearchableEvent = Event & { organizerName: string }
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
events: Event[]
|
events: Event[]
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -22,12 +27,13 @@ const { dateLocale } = useDateLocale()
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const searchOptions: FuzzySearchOptions<Event> = {
|
const searchOptions: FuzzySearchOptions<SearchableEvent> = {
|
||||||
fuseOptions: {
|
fuseOptions: {
|
||||||
keys: [
|
keys: [
|
||||||
{ name: 'title', weight: 0.5 },
|
{ name: 'title', weight: 0.5 },
|
||||||
{ name: 'summary', weight: 0.2 },
|
{ name: 'summary', weight: 0.2 },
|
||||||
{ name: 'description', weight: 0.15 },
|
{ name: 'description', weight: 0.15 },
|
||||||
|
{ name: 'organizerName', weight: 0.1 },
|
||||||
{ name: 'location', weight: 0.1 },
|
{ name: 'location', weight: 0.1 },
|
||||||
{ name: 'tags', weight: 0.05 },
|
{ name: 'tags', weight: 0.05 },
|
||||||
],
|
],
|
||||||
|
|
@ -39,7 +45,20 @@ const searchOptions: FuzzySearchOptions<Event> = {
|
||||||
resultLimit: 8,
|
resultLimit: 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsRef = computed(() => props.events)
|
// 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 {
|
const {
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
|
@ -47,7 +66,7 @@ const {
|
||||||
isSearching,
|
isSearching,
|
||||||
clearSearch,
|
clearSearch,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
} = useFuzzySearch(eventsRef, searchOptions)
|
} = useFuzzySearch(searchCorpus, searchOptions)
|
||||||
|
|
||||||
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
||||||
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
||||||
|
|
@ -94,6 +113,18 @@ function handleClickOutside(e: MouseEvent) {
|
||||||
watch(isOpen, (open) => {
|
watch(isOpen, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
// Warm the shared profile cache for every organizer in the current
|
||||||
|
// set so their names become searchable (fetches dedupe in the
|
||||||
|
// service; the corpus reacts as kind-0 metadata arrives).
|
||||||
|
if (profileService) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const e of props.events) {
|
||||||
|
const pk = e.organizer.pubkey
|
||||||
|
if (seen.has(pk) || profileService.profiles.get(pk)) continue
|
||||||
|
seen.add(pk)
|
||||||
|
void profileService.getProfile(pk)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,37 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { User } from 'lucide-vue-next'
|
import { User } from 'lucide-vue-next'
|
||||||
import { useOrganizerProfile } from '../composables/useOrganizerProfile'
|
import { useOrganizerProfile } from '../composables/useOrganizerProfile'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
pubkey: string
|
pubkey: string
|
||||||
}>()
|
/** Compact row variant — small avatar, single-line "By <name>".
|
||||||
|
* Used on the events feed card where the organizer is a hint, not
|
||||||
|
* the focus. Default (full) is used on the detail page. */
|
||||||
|
compact?: boolean
|
||||||
|
}>(),
|
||||||
|
{ compact: false },
|
||||||
|
)
|
||||||
|
|
||||||
const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-3">
|
<!-- 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">
|
||||||
<Avatar class="h-10 w-10">
|
<Avatar class="h-10 w-10">
|
||||||
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
|
<AvatarImage v-if="profile?.picture" :src="profile.picture" :alt="displayName" />
|
||||||
<AvatarFallback class="bg-primary/10">
|
<AvatarFallback class="bg-primary/10">
|
||||||
|
|
@ -20,14 +42,14 @@ const { profile, displayName, isLoading } = useOrganizerProfile(props.pubkey)
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-foreground truncate">
|
<p class="text-sm font-medium text-foreground truncate">
|
||||||
<template v-if="isLoading">Loading...</template>
|
<template v-if="isLoading">Loading…</template>
|
||||||
<template v-else>{{ displayName }}</template>
|
<template v-else>{{ displayName }}</template>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate">
|
<p v-if="profile?.nip05" class="text-xs text-muted-foreground truncate">
|
||||||
{{ profile.nip05 }}
|
{{ profile.nip05 }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-xs text-muted-foreground font-mono truncate">
|
<p v-else class="text-xs text-muted-foreground font-mono truncate">
|
||||||
{{ pubkey.slice(0, 16) }}...
|
{{ pubkey.slice(0, 16) }}…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
153
src/modules/events/components/PromoCodesEditor.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { normalizePromoCode, validatePromoRows, type PromoCodeRow } from '../lib/promoCodes'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organizer-side promo code editor: one row per code with percentage,
|
||||||
|
* optional redemption cap and an active switch. Plain `v-model` over a
|
||||||
|
* `PromoCodeRow[]` (the parent keeps it as a ref next to its vee-validate
|
||||||
|
* form, the same way categories are handled); validation is the pure
|
||||||
|
* `validatePromoRows` so the parent can gate submit on the same rules.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: PromoCodeRow[]
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [rows: PromoCodeRow[]] }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const errors = computed(() => validatePromoRows(props.modelValue))
|
||||||
|
|
||||||
|
function update(index: number, patch: Partial<PromoCodeRow>) {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
props.modelValue.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
emit('update:modelValue', [
|
||||||
|
...props.modelValue,
|
||||||
|
{ code: '', discount_percent: null, max_uses: null, active: true },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty input → null (so "unset" never becomes 0); otherwise a number. */
|
||||||
|
function toNumberOrNull(value: string | number): number | null {
|
||||||
|
const str = String(value).trim()
|
||||||
|
if (str === '') return null
|
||||||
|
const n = Number(str)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('events.create.promo.hint') }}</p>
|
||||||
|
|
||||||
|
<p v-if="modelValue.length === 0" class="text-sm text-muted-foreground">
|
||||||
|
{{ t('events.create.promo.empty') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(row, index) in modelValue"
|
||||||
|
:key="index"
|
||||||
|
class="rounded-md border border-border p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-[minmax(0,1fr)_5.5rem_5.5rem] gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label :for="`promo-code-${index}`" class="text-xs">{{ t('events.create.promo.code') }}</Label>
|
||||||
|
<Input
|
||||||
|
:id="`promo-code-${index}`"
|
||||||
|
:model-value="row.code"
|
||||||
|
:placeholder="t('events.create.promo.codePlaceholder')"
|
||||||
|
class="font-mono uppercase"
|
||||||
|
autocapitalize="characters"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="update(index, { code: normalizePromoCode(String($event)) })"
|
||||||
|
/>
|
||||||
|
<p v-if="errors[index]?.code" class="text-xs text-destructive">
|
||||||
|
{{ t(errors[index].code!) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label :for="`promo-pct-${index}`" class="text-xs">{{ t('events.create.promo.discount') }}</Label>
|
||||||
|
<Input
|
||||||
|
:id="`promo-pct-${index}`"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
:model-value="row.discount_percent ?? ''"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="update(index, { discount_percent: toNumberOrNull($event) })"
|
||||||
|
/>
|
||||||
|
<p v-if="errors[index]?.discount_percent" class="text-xs text-destructive">
|
||||||
|
{{ t(errors[index].discount_percent!) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label :for="`promo-max-${index}`" class="text-xs">{{ t('events.create.promo.maxUses') }}</Label>
|
||||||
|
<Input
|
||||||
|
:id="`promo-max-${index}`"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
placeholder="∞"
|
||||||
|
:model-value="row.max_uses ?? ''"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="update(index, { max_uses: toNumberOrNull($event) })"
|
||||||
|
/>
|
||||||
|
<p v-if="errors[index]?.max_uses" class="text-xs text-destructive">
|
||||||
|
{{ t(errors[index].max_uses!) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
:id="`promo-active-${index}`"
|
||||||
|
:model-value="row.active"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="update(index, { active: Boolean($event) })"
|
||||||
|
/>
|
||||||
|
<Label :for="`promo-active-${index}`" class="text-xs">{{ t('events.create.promo.active') }}</Label>
|
||||||
|
<span v-if="(row.used_count ?? 0) > 0" class="text-xs text-muted-foreground">
|
||||||
|
· {{ t('events.create.promo.used', { count: row.used_count }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="t('events.create.promo.removeLabel', { code: row.code || '…' })"
|
||||||
|
@click="remove(index)"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" size="sm" class="gap-1.5" :disabled="disabled" @click="add">
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
{{ t('events.create.promo.add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,13 +1,29 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import * as z from 'zod'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
||||||
|
import { usePromoCode } from '../composables/usePromoCode'
|
||||||
|
import { normalizePromoCode } from '../lib/promoCodes'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next'
|
import { User, Wallet, CreditCard, Zap, Ticket, Landmark, Minus, Plus, Copy, Check, Loader2, Mail, Tag, X } from 'lucide-vue-next'
|
||||||
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
|
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
|
||||||
import PaymentMethodSelector, {
|
import PaymentMethodSelector, {
|
||||||
type PaymentMethod as PaymentMethodEntry,
|
type PaymentMethod as PaymentMethodEntry,
|
||||||
|
|
@ -15,6 +31,9 @@ import PaymentMethodSelector, {
|
||||||
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
|
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
|
||||||
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
|
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
|
||||||
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
|
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
|
||||||
|
import { effectivePaymentMethods } from '../lib/paymentMethods'
|
||||||
|
import { frontendRoot } from '../lib/frontendRoot'
|
||||||
|
import { PENDING_CHECKOUT_KEY, type PendingCheckout } from '../lib/pendingCheckout'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: {
|
event: {
|
||||||
|
|
@ -25,6 +44,9 @@ interface Props {
|
||||||
/** Whether the event accepts fiat payments. From v1.4.0+ */
|
/** Whether the event accepts fiat payments. From v1.4.0+ */
|
||||||
allow_fiat?: boolean
|
allow_fiat?: boolean
|
||||||
fiat_currency?: string
|
fiat_currency?: string
|
||||||
|
/** Rails the organizer enabled (`tickets_payment_methods`). Absent
|
||||||
|
* on legacy events — falls back to the allow_fiat rule. */
|
||||||
|
payment_methods?: string[]
|
||||||
}
|
}
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -35,8 +57,9 @@ interface Emits {
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { isAuthenticated, userDisplay } = useAuth()
|
const { isAuthenticated, userDisplay, currentUser } = useAuth()
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|
@ -45,7 +68,6 @@ const {
|
||||||
qrCode,
|
qrCode,
|
||||||
isPaymentPending,
|
isPaymentPending,
|
||||||
isPayingWithWallet,
|
isPayingWithWallet,
|
||||||
canPurchase,
|
|
||||||
userWallets,
|
userWallets,
|
||||||
hasWalletWithBalance,
|
hasWalletWithBalance,
|
||||||
purchaseTicketForEvent,
|
purchaseTicketForEvent,
|
||||||
|
|
@ -54,7 +76,9 @@ const {
|
||||||
resetPaymentState,
|
resetPaymentState,
|
||||||
cleanup,
|
cleanup,
|
||||||
purchasedTicketIds,
|
purchasedTicketIds,
|
||||||
showTicketQR
|
ticketQRCodes,
|
||||||
|
showTicketQR,
|
||||||
|
finalizePurchasedTickets
|
||||||
} = useTicketPurchase()
|
} = useTicketPurchase()
|
||||||
|
|
||||||
const MAX_QUANTITY = 10
|
const MAX_QUANTITY = 10
|
||||||
|
|
@ -70,6 +94,57 @@ function increaseQuantity() {
|
||||||
|
|
||||||
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
|
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
|
||||||
|
|
||||||
|
// Free events (price 0): no invoice is minted — the backend issues the
|
||||||
|
// tickets already-paid. Drop the payment-method selector and price line
|
||||||
|
// and label the CTA "Get ticket" instead of "Proceed".
|
||||||
|
const isFree = computed(() => props.event.price_per_ticket <= 0)
|
||||||
|
|
||||||
|
// Guests may buy paid tickets (name + email identity); free claims stay
|
||||||
|
// login-gated so an anonymous script can't drain a free event's
|
||||||
|
// capacity (events#29).
|
||||||
|
const guestNeedsLogin = computed(() => isFree.value && !isAuthenticated.value)
|
||||||
|
|
||||||
|
// --- Promo code -----------------------------------------------------
|
||||||
|
// Server-validated preview (`POST /promo/validate`); the purchase
|
||||||
|
// endpoint re-checks the code anyway, so the client never trusts its
|
||||||
|
// own arithmetic for what gets charged.
|
||||||
|
const promo = usePromoCode(() => props.event.id, quantity)
|
||||||
|
const {
|
||||||
|
input: promoInput,
|
||||||
|
state: promoState,
|
||||||
|
isChecking: promoChecking,
|
||||||
|
codeToSend: promoCodeToSend,
|
||||||
|
} = promo
|
||||||
|
|
||||||
|
const hasDiscount = computed(
|
||||||
|
() => promoState.value.status === 'applied' && promoState.value.total < promoState.value.subtotal,
|
||||||
|
)
|
||||||
|
const promoDiscountAmount = computed(() =>
|
||||||
|
promoState.value.status === 'applied' ? promoState.value.discount : 0,
|
||||||
|
)
|
||||||
|
/** What the buyer will actually be charged for the current quantity. */
|
||||||
|
const effectiveTotal = computed(() =>
|
||||||
|
promoState.value.status === 'applied' ? promoState.value.total : totalPrice.value,
|
||||||
|
)
|
||||||
|
const isZeroTotal = computed(() => effectiveTotal.value <= 0)
|
||||||
|
// A 100 %-off code turns a paid ticket into a free claim, which stays
|
||||||
|
// login-gated (events#29). Prompt inline — swapping the whole form would
|
||||||
|
// hide the Remove button the guest needs to back out of the code.
|
||||||
|
const guestNeedsLoginForDiscount = computed(
|
||||||
|
() => !isFree.value && isZeroTotal.value && !isAuthenticated.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
function onPromoInput(event: Event) {
|
||||||
|
const el = event.target as HTMLInputElement
|
||||||
|
const value = normalizePromoCode(el.value)
|
||||||
|
el.value = value
|
||||||
|
promoInput.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromoError(err: unknown): boolean {
|
||||||
|
return err instanceof Error && /promo code/i.test(err.message)
|
||||||
|
}
|
||||||
|
|
||||||
async function copyInvoice() {
|
async function copyInvoice() {
|
||||||
if (!paymentRequest.value) return
|
if (!paymentRequest.value) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -82,16 +157,69 @@ async function copyInvoice() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contact (delivery email) form ---------------------------------
|
||||||
|
// Every ticket carries an email so the extension can deliver it (and
|
||||||
|
// so Stripe's checkout is prefilled + locked to the same address).
|
||||||
|
// Guests additionally need a name — that pair is their identity on
|
||||||
|
// the backend. Confirm-email guards against typos on the one field a
|
||||||
|
// guest cannot fix afterwards.
|
||||||
|
const contactSchema = computed(() =>
|
||||||
|
toTypedSchema(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
name: isAuthenticated.value
|
||||||
|
? z.string().optional().default('')
|
||||||
|
: z.string().trim().min(1, t('events.purchase.contact.nameRequired')).max(100),
|
||||||
|
email: z.string().trim().email(t('events.purchase.contact.emailInvalid')),
|
||||||
|
confirmEmail: z.string().trim(),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
if (v.email.toLowerCase() !== v.confirmEmail.toLowerCase()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['confirmEmail'],
|
||||||
|
message: t('events.purchase.contact.emailMismatch'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: contactSchema,
|
||||||
|
initialValues: { name: '', email: '', confirmEmail: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// The dialog stays mounted across opens — reseed on every open so a
|
||||||
|
// login/logout between purchases is reflected.
|
||||||
|
watch(
|
||||||
|
() => props.isOpen,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return
|
||||||
|
form.resetForm({
|
||||||
|
values: { name: '', email: currentUser.value?.email ?? '', confirmEmail: '' },
|
||||||
|
})
|
||||||
|
promo.clear()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
const { providers, providerMeta } = useFiatProviders()
|
const { providers, providerMeta } = useFiatProviders()
|
||||||
const { convert } = usePriceConversion()
|
const { convert } = usePriceConversion()
|
||||||
|
|
||||||
const selectedMethodId = ref<string>('lightning')
|
// Empty until the dialog opens: the watcher below picks the default per
|
||||||
const fiatRedirectUrl = ref<string | null>(null)
|
// rail availability + auth state, so a guest lands on Card, not Lightning.
|
||||||
const fiatProviderLabel = ref<string | null>(null)
|
const selectedMethodId = ref<string>('')
|
||||||
const isFiatPending = ref(false)
|
const isFiatPending = ref(false)
|
||||||
const fiatError = ref<string | null>(null)
|
const fiatError = ref<string | null>(null)
|
||||||
|
|
||||||
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
|
const enabledRails = computed(() =>
|
||||||
|
effectivePaymentMethods({
|
||||||
|
allowFiat: props.event.allow_fiat,
|
||||||
|
paymentMethods: props.event.payment_methods,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const canChooseFiat = computed(() => enabledRails.value.includes('fiat'))
|
||||||
const isPriceInSats = computed(
|
const isPriceInSats = computed(
|
||||||
() => props.event.currency === 'sat' || props.event.currency === 'sats',
|
() => props.event.currency === 'sat' || props.event.currency === 'sats',
|
||||||
)
|
)
|
||||||
|
|
@ -119,7 +247,9 @@ function iconFor(hint: 'card' | 'bank' | 'wallet') {
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
|
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
|
||||||
const lightning: PaymentMethodEntry = {
|
const entries: PaymentMethodEntry[] = []
|
||||||
|
if (enabledRails.value.includes('lightning')) {
|
||||||
|
entries.push({
|
||||||
id: 'lightning',
|
id: 'lightning',
|
||||||
rail: 'lightning',
|
rail: 'lightning',
|
||||||
label: 'Lightning',
|
label: 'Lightning',
|
||||||
|
|
@ -129,116 +259,169 @@ const paymentMethods = computed<PaymentMethodEntry[]>(() => {
|
||||||
!isPriceInSats.value && lightningSats.value
|
!isPriceInSats.value && lightningSats.value
|
||||||
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
|
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (!props.event.allow_fiat) return [lightning]
|
if (canChooseFiat.value) {
|
||||||
|
|
||||||
if (providers.value.length > 0) {
|
if (providers.value.length > 0) {
|
||||||
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
|
for (const id of providers.value) {
|
||||||
const meta = providerMeta(id)
|
const meta = providerMeta(id)
|
||||||
return {
|
entries.push({
|
||||||
id: `fiat:${id}`,
|
id: `fiat:${id}`,
|
||||||
rail: 'fiat',
|
rail: 'fiat',
|
||||||
provider: id,
|
provider: id,
|
||||||
label: meta.label,
|
label: meta.label,
|
||||||
icon: iconFor(meta.icon),
|
icon: iconFor(meta.icon),
|
||||||
available: true,
|
available: true,
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return [lightning, ...fiatRails]
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Degenerate fallback — allow_fiat is on but the buyer's session
|
// Guests (and sessions without provider info) can't enumerate the
|
||||||
// can't enumerate the organizer's providers. Show a generic Card
|
// organizer's providers. Show a generic Card button and let the
|
||||||
// button and let the backend pick a default at request time.
|
// backend pick the organizer's default at request time.
|
||||||
return [
|
entries.push({
|
||||||
lightning,
|
|
||||||
{
|
|
||||||
id: 'fiat',
|
id: 'fiat',
|
||||||
rail: 'fiat',
|
rail: 'fiat',
|
||||||
label: 'Card',
|
label: t('events.purchase.card'),
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
available: true,
|
available: true,
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
})
|
||||||
|
|
||||||
|
// Default rail: guests lean to card when it's on offer (no wallet to
|
||||||
|
// pay a bolt11 with); everyone else takes the first enabled rail.
|
||||||
|
watch(
|
||||||
|
[paymentMethods, () => props.isOpen, isAuthenticated],
|
||||||
|
([methods, open, authed]) => {
|
||||||
|
if (!open || methods.length === 0) return
|
||||||
|
const current = methods.find((m) => m.id === selectedMethodId.value)
|
||||||
|
if (current) return
|
||||||
|
const preferred = !authed ? methods.find((m) => m.rail === 'fiat') : undefined
|
||||||
|
selectedMethodId.value = (preferred ?? methods[0]).id
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
const selectedMethod = computed(() =>
|
const selectedMethod = computed(() =>
|
||||||
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
|
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
async function handlePurchase() {
|
const onSubmit = form.handleSubmit(async (contact) => {
|
||||||
if (!canPurchase.value) return
|
await handlePurchase(contact)
|
||||||
fiatError.value = null
|
})
|
||||||
|
|
||||||
|
async function handlePurchase(contact: { name?: string; email: string }) {
|
||||||
|
fiatError.value = null
|
||||||
const method = selectedMethod.value
|
const method = selectedMethod.value
|
||||||
if (!method) return
|
if (!method) return
|
||||||
|
|
||||||
// Lightning path: the composable just creates the invoice + starts
|
const isGuest = !isAuthenticated.value
|
||||||
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in
|
const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined
|
||||||
// external wallet" on the invoice screen (restaurant pattern), so
|
const promoCode = promoCodeToSend.value
|
||||||
// no auto-pay here.
|
|
||||||
if (method.rail === 'lightning') {
|
if (guestNeedsLoginForDiscount.value) return
|
||||||
|
|
||||||
|
// Nothing to charge (100 %-off code on a paid event): skip the rail
|
||||||
|
// choice entirely — the backend issues the tickets already-paid and
|
||||||
|
// the composable lands on the success screen from `invoice.paid`.
|
||||||
|
if (isZeroTotal.value && !isFree.value) {
|
||||||
try {
|
try {
|
||||||
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
|
await purchaseTicketForEvent(props.event.id, {
|
||||||
|
quantity: quantity.value,
|
||||||
|
contact: guestContact,
|
||||||
|
email: contact.email,
|
||||||
|
promoCode,
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error purchasing ticket:', err)
|
console.error('Error claiming discounted ticket:', err)
|
||||||
|
if (isPromoError(err)) void promo.revalidate()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
|
// Lightning path: the composable creates the invoice + starts polling.
|
||||||
// API directly with the chosen provider, then redirect the buyer to
|
// The buyer pays from their LNbits wallet or an external one on the
|
||||||
// the provider's checkout URL. Payment confirmation happens via
|
// invoice screen (restaurant pattern), so no auto-pay here.
|
||||||
// webhook on the backend and shows up next time the buyer reloads
|
if (method.rail === 'lightning') {
|
||||||
// MyTickets.
|
try {
|
||||||
|
await purchaseTicketForEvent(props.event.id, {
|
||||||
|
quantity: quantity.value,
|
||||||
|
contact: guestContact,
|
||||||
|
email: contact.email,
|
||||||
|
promoCode,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error purchasing ticket:', err)
|
||||||
|
if (isPromoError(err)) void promo.revalidate()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fiat path: POST with the chosen provider, remember what we're
|
||||||
|
// waiting for, then hand the tab to the provider's checkout. Stripe
|
||||||
|
// brings the buyer back to the event page with ?checkout=success&
|
||||||
|
// tickets=… (or ?checkout=cancelled) — see EventDetailPage.
|
||||||
try {
|
try {
|
||||||
isFiatPending.value = true
|
isFiatPending.value = true
|
||||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
||||||
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
|
const accessToken = isGuest ? undefined : (lnbitsAPI?.getAccessToken?.() || undefined)
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
const currentUser = (lnbitsAPI?.currentUser?.value) || null
|
const invoice = await ticketApi.requestTicket(props.event.id, {
|
||||||
const userId = currentUser?.id
|
userId: isGuest ? undefined : currentUser.value?.id,
|
||||||
if (!userId) {
|
name: guestContact?.name,
|
||||||
fiatError.value = 'Missing user id'
|
email: contact.email,
|
||||||
return
|
|
||||||
}
|
|
||||||
const invoice = await ticketApi.requestTicket(
|
|
||||||
props.event.id,
|
|
||||||
userId,
|
|
||||||
accessToken,
|
accessToken,
|
||||||
{
|
frontendUrl: frontendRoot(),
|
||||||
paymentMethod: 'fiat',
|
paymentMethod: 'fiat',
|
||||||
fiatProvider: method.provider,
|
fiatProvider: method.provider,
|
||||||
quantity: quantity.value,
|
quantity: quantity.value,
|
||||||
},
|
promoCode,
|
||||||
)
|
})
|
||||||
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
// The backend may still short-circuit to the free path (e.g. a code
|
||||||
fiatError.value = 'Fiat provider did not return a checkout URL.'
|
// that became 100 % between preview and pay): no checkout URL, the
|
||||||
|
// tickets are already issued — show them instead of an error.
|
||||||
|
if (invoice.paid) {
|
||||||
|
paymentHash.value = invoice.paymentHash
|
||||||
|
await finalizePurchasedTickets(invoice.ticketIds ?? [])
|
||||||
|
isFiatPending.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fiatRedirectUrl.value = invoice.fiatPaymentRequest
|
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
||||||
fiatProviderLabel.value = invoice.fiatProvider
|
fiatError.value = t('events.purchase.noCheckoutUrl')
|
||||||
? providerMeta(invoice.fiatProvider).label
|
|
||||||
: method.label
|
|
||||||
} catch (err) {
|
|
||||||
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
|
|
||||||
} finally {
|
|
||||||
isFiatPending.value = false
|
isFiatPending.value = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
const pending: PendingCheckout = {
|
||||||
|
eventId: props.event.id,
|
||||||
|
paymentHash: invoice.paymentHash,
|
||||||
|
ticketIds: invoice.ticketIds ?? [],
|
||||||
|
email: contact.email,
|
||||||
|
ts: Date.now(),
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PENDING_CHECKOUT_KEY, JSON.stringify(pending))
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable (private mode) — the return URL still
|
||||||
|
// carries the ticket ids, so the flow survives without it.
|
||||||
|
}
|
||||||
|
window.location.assign(invoice.fiatPaymentRequest)
|
||||||
|
// Keep the spinner up until the navigation lands.
|
||||||
|
} catch (err) {
|
||||||
|
fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed')
|
||||||
|
isFiatPending.value = false
|
||||||
|
if (isPromoError(err)) void promo.revalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFiatCheckout() {
|
|
||||||
if (!fiatRedirectUrl.value) return
|
|
||||||
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
emit('update:isOpen', false)
|
emit('update:isOpen', false)
|
||||||
resetPaymentState()
|
resetPaymentState()
|
||||||
selectedMethodId.value = 'lightning'
|
promo.clear()
|
||||||
fiatRedirectUrl.value = null
|
selectedMethodId.value = ''
|
||||||
fiatProviderLabel.value = null
|
|
||||||
fiatError.value = null
|
fiatError.value = null
|
||||||
|
isFiatPending.value = false
|
||||||
quantity.value = 1
|
quantity.value = 1
|
||||||
copiedInvoice.value = false
|
copiedInvoice.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -257,35 +440,43 @@ onUnmounted(() => {
|
||||||
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
|
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<span v-if="quantity > 1">
|
<span v-if="isFree">
|
||||||
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
|
<template v-if="quantity > 1">{{ quantity }} free tickets for</template>
|
||||||
{{ formatEventPrice(totalPrice, event.currency) }}
|
<template v-else>Get a free ticket for</template>
|
||||||
|
<strong>{{ event.name }}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
|
<template v-if="quantity > 1">{{ quantity }} tickets for</template>
|
||||||
|
<template v-else>Purchase a ticket for</template>
|
||||||
|
<strong>{{ event.name }}</strong> ·
|
||||||
|
<s v-if="hasDiscount" class="text-muted-foreground">{{ formatEventPrice(totalPrice, event.currency) }}</s>
|
||||||
|
<span :class="{ 'text-primary font-medium': hasDiscount }">
|
||||||
|
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<!-- Authentication Check -->
|
<!-- Free tickets need an account (anti-abuse). Paid tickets don't. -->
|
||||||
<div v-if="!isAuthenticated" class="py-4 text-center space-y-4">
|
<div v-if="guestNeedsLogin" class="py-4 text-center space-y-4">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<User class="w-12 h-12 text-muted-foreground" />
|
<User class="w-12 h-12 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-lg font-semibold">Login Required</h3>
|
<h3 class="text-lg font-semibold">{{ t('events.purchase.loginRequiredTitle') }}</h3>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Please log in to your account to purchase tickets using your wallet.
|
{{ t('events.purchase.loginRequiredFree') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button @click="handleClose" variant="outline">
|
<div class="flex gap-2 justify-center">
|
||||||
Close
|
<Button @click="() => $router.push('/login')">{{ t('events.detail.logIn') }}</Button>
|
||||||
</Button>
|
<Button @click="handleClose" variant="outline">Close</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Info and Purchase -->
|
<!-- Contact + payment details, then submit -->
|
||||||
<div v-else-if="!paymentHash" class="py-4 space-y-4">
|
<form v-else-if="!paymentHash" class="py-4 space-y-4" @submit="onSubmit">
|
||||||
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<User class="w-4 h-4 text-muted-foreground" />
|
<User class="w-4 h-4 text-muted-foreground" />
|
||||||
<span class="text-sm font-medium">Purchasing as:</span>
|
<span class="text-sm font-medium">Purchasing as:</span>
|
||||||
|
|
@ -302,8 +493,108 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Wallet Information -->
|
<!-- Contact: who the ticket is for + where to send it -->
|
||||||
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Mail class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">{{ t('events.purchase.contact.title') }}</span>
|
||||||
|
</div>
|
||||||
|
<FormField v-if="!isAuthenticated" v-slot="{ componentField }" name="name">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{{ t('events.purchase.contact.name') }} *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input v-bind="componentField" autocomplete="name" :placeholder="t('events.purchase.contact.namePlaceholder')" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-slot="{ componentField }" name="email">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{{ t('events.purchase.contact.email') }} *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="email" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription class="text-xs">{{ t('events.purchase.contact.emailHint') }}</FormDescription>
|
||||||
|
<p class="text-xs font-medium text-destructive">{{ t('events.purchase.contact.spamHint') }}</p>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-slot="{ componentField }" name="confirmEmail">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{{ t('events.purchase.contact.confirmEmail') }} *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Promo code (paid events only). Apply asks the backend for
|
||||||
|
the discounted total; the badge + struck-through price
|
||||||
|
reflect the server's numbers, never a client guess. -->
|
||||||
|
<div v-if="!isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Tag class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">{{ t('events.purchase.promo.label') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="promoState.status === 'applied' || promoState.status === 'unverified'" class="flex items-center justify-between gap-2">
|
||||||
|
<Badge :variant="promoState.status === 'applied' ? 'default' : 'secondary'" class="font-mono">
|
||||||
|
<template v-if="promoState.status === 'applied'">
|
||||||
|
{{ t('events.purchase.promo.applied', { code: promoState.code, percent: promoState.discountPercent }) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ promoState.code }}</template>
|
||||||
|
</Badge>
|
||||||
|
<Button type="button" variant="ghost" size="sm" class="gap-1 h-7" @click="promo.clear()">
|
||||||
|
<X class="w-3.5 h-3.5" />
|
||||||
|
{{ t('events.purchase.promo.remove') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
:model-value="promoInput"
|
||||||
|
:placeholder="t('events.purchase.promo.placeholder')"
|
||||||
|
class="font-mono uppercase"
|
||||||
|
autocapitalize="characters"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:disabled="promoChecking"
|
||||||
|
@input="onPromoInput"
|
||||||
|
@keydown.enter.prevent="promo.apply()"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="shrink-0"
|
||||||
|
:disabled="!promoInput || promoChecking"
|
||||||
|
@click="promo.apply()"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="promoChecking" class="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
{{ promoChecking ? t('events.purchase.promo.checking') : t('events.purchase.promo.apply') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="promoState.status === 'invalid'" class="text-xs text-destructive">
|
||||||
|
{{ t('events.purchase.promo.invalid') }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="promoState.status === 'unverified'" class="text-xs text-muted-foreground">
|
||||||
|
{{ t('events.purchase.promo.unverified') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Alert v-if="guestNeedsLoginForDiscount" class="mt-2">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
<AlertDescription class="flex flex-col gap-2">
|
||||||
|
<span>{{ t('events.purchase.promo.loginRequiredDiscount') }}</span>
|
||||||
|
<Button type="button" size="sm" class="w-fit" @click="() => $router.push('/login')">
|
||||||
|
{{ t('events.detail.logIn') }}
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wallet Information (logged-in Lightning buyers) -->
|
||||||
|
<div v-if="isAuthenticated && selectedMethod?.rail === 'lightning' && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Wallet class="w-4 h-4 text-muted-foreground" />
|
<Wallet class="w-4 h-4 text-muted-foreground" />
|
||||||
<span class="text-sm font-medium">Wallet Status:</span>
|
<span class="text-sm font-medium">Wallet Status:</span>
|
||||||
|
|
@ -322,7 +613,7 @@ onUnmounted(() => {
|
||||||
<Badge v-else variant="secondary" class="text-xs">Empty</Badge>
|
<Badge v-else variant="secondary" class="text-xs">Empty</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600">
|
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-primary">
|
||||||
<Zap class="w-4 h-4" />
|
<Zap class="w-4 h-4" />
|
||||||
<span>Auto-payment available</span>
|
<span>Auto-payment available</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -340,7 +631,7 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quantity selector — backend caps at 10. One invoice for
|
<!-- Quantity selector — backend caps at 10. One invoice for
|
||||||
the whole purchase, one ticket row representing N seats. -->
|
the whole purchase, N ticket rows (one per attendee). -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-muted-foreground">Tickets:</span>
|
<span class="text-sm text-muted-foreground">Tickets:</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -375,28 +666,36 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
|
<span class="text-sm font-medium">
|
||||||
|
<template v-if="isFree">Free</template>
|
||||||
|
<template v-else-if="hasDiscount">
|
||||||
|
<s class="text-muted-foreground font-normal mr-1">{{ formatEventPrice(totalPrice, event.currency) }}</s>
|
||||||
|
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ formatEventPrice(totalPrice, event.currency) }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasDiscount && promoState.status === 'applied'" class="flex justify-between text-primary">
|
||||||
|
<span class="text-sm">{{ t('events.purchase.promo.discountLine', { code: promoState.code }) }}</span>
|
||||||
|
<span class="text-sm font-medium">−{{ formatEventPrice(promoDiscountAmount, event.currency) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<PriceConversionPreview
|
<PriceConversionPreview
|
||||||
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
||||||
:amount="totalPrice"
|
:amount="effectiveTotal"
|
||||||
from="sat"
|
from="sat"
|
||||||
:to="event.fiat_currency"
|
:to="event.fiat_currency"
|
||||||
prefix="Equivalent ~"
|
prefix="Equivalent ~"
|
||||||
suffix=" if paid in fiat"
|
suffix=" if paid by card"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment method selector (only shown when fiat is enabled
|
<!-- Payment method selector — one button per enabled rail /
|
||||||
on the event). Buttons surface one per configured fiat
|
configured fiat provider. Hidden when only one rail is on
|
||||||
provider so "Stripe" / "PayPal" / "Square" stand alongside
|
(nothing to choose) and for free tickets. -->
|
||||||
Lightning rather than collapsing into a single "Fiat"
|
<div v-if="paymentMethods.length > 1 && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
catch-all. Hidden entirely for Lightning-only events to
|
|
||||||
keep the dialog uncluttered. -->
|
|
||||||
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
|
||||||
<div class="text-sm font-medium">Payment method</div>
|
<div class="text-sm font-medium">Payment method</div>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Both methods charge the same amount via different rails.
|
Both methods charge the same amount via different rails.
|
||||||
|
|
@ -414,39 +713,29 @@ onUnmounted(() => {
|
||||||
{{ error || fiatError }}
|
{{ error || fiatError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fiat checkout panel — shown after a successful fiat
|
|
||||||
POST when we have a provider URL to redirect to. -->
|
|
||||||
<div v-if="fiatRedirectUrl" class="space-y-3">
|
|
||||||
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
|
|
||||||
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Opens the provider's checkout in a new tab. Your ticket
|
|
||||||
appears in My Tickets once the payment settles.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button @click="openFiatCheckout" class="w-full">
|
|
||||||
<ExternalLink class="w-4 h-4 mr-2" />
|
|
||||||
Open {{ fiatProviderLabel }} checkout
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
v-else
|
type="submit"
|
||||||
@click="handlePurchase"
|
:disabled="isLoading || isFiatPending || promoChecking || guestNeedsLoginForDiscount || (paymentMethods.length === 0 && !isZeroTotal)"
|
||||||
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
<template v-else-if="isZeroTotal">
|
||||||
|
<Ticket class="w-4 h-4 mr-2" />
|
||||||
|
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
|
||||||
|
</template>
|
||||||
<template v-else-if="selectedMethod?.rail === 'fiat'">
|
<template v-else-if="selectedMethod?.rail === 'fiat'">
|
||||||
<CreditCard class="w-4 h-4 mr-2" />
|
<CreditCard class="w-4 h-4 mr-2" />
|
||||||
Continue to {{ selectedMethod.label }} checkout
|
{{ t('events.purchase.continueTo', { provider: selectedMethod.label }) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Zap class="w-4 h-4 mr-2" />
|
<Zap class="w-4 h-4 mr-2" />
|
||||||
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
|
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<p v-if="selectedMethod?.rail === 'fiat' && !isZeroTotal" class="text-xs text-muted-foreground text-center">
|
||||||
|
{{ t('events.purchase.fiatRedirectHint') }}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- Lightning invoice — restaurant-style. Shows QR + amount,
|
<!-- Lightning invoice — restaurant-style. Shows QR + amount,
|
||||||
with both pay paths visible at once: tap-to-pay from the
|
with both pay paths visible at once: tap-to-pay from the
|
||||||
|
|
@ -457,8 +746,13 @@ onUnmounted(() => {
|
||||||
<div class="text-center space-y-1">
|
<div class="text-center space-y-1">
|
||||||
<h3 class="text-lg font-semibold">Pay the invoice</h3>
|
<h3 class="text-lg font-semibold">Pay the invoice</h3>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
|
<template v-if="hasWalletWithBalance">
|
||||||
Scan with any Lightning wallet, or tap the button below to
|
Scan with any Lightning wallet, or tap the button below to
|
||||||
pay from your LNbits wallet.
|
pay from your LNbits wallet.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Scan with any Lightning wallet, or open it in your wallet app.
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -477,7 +771,7 @@ onUnmounted(() => {
|
||||||
<div class="flex items-baseline justify-between">
|
<div class="flex items-baseline justify-between">
|
||||||
<span class="text-xs text-muted-foreground">Amount</span>
|
<span class="text-xs text-muted-foreground">Amount</span>
|
||||||
<span class="font-mono text-sm font-semibold text-primary">
|
<span class="font-mono text-sm font-semibold text-primary">
|
||||||
{{ formatEventPrice(totalPrice, event.currency) }}
|
{{ formatEventPrice(effectiveTotal, event.currency) }}
|
||||||
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
|
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
|
||||||
({{ quantity }} tickets)
|
({{ quantity }} tickets)
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -542,21 +836,20 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Success state. QRs live in My Tickets — no need to
|
<!-- Success state. Logged-in buyers get routed to My Tickets
|
||||||
pre-render them here; this view's job is to confirm the
|
(QRs live there); guests have no My Tickets, so their QRs
|
||||||
purchase landed and route the buyer to where they actually
|
render right here alongside the "emailed to" note. -->
|
||||||
interact with their tickets. -->
|
|
||||||
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
|
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<Ticket class="w-12 h-12 text-green-600" />
|
<Ticket class="w-12 h-12 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-2">
|
<div class="text-center space-y-2">
|
||||||
<h3 class="text-lg font-semibold text-green-600">
|
<h3 class="text-lg font-semibold text-primary">
|
||||||
{{ purchasedTicketIds.length > 1
|
{{ purchasedTicketIds.length > 1
|
||||||
? `${purchasedTicketIds.length} tickets purchased!`
|
? `${purchasedTicketIds.length} tickets purchased!`
|
||||||
: 'Ticket purchased!' }}
|
: 'Ticket purchased!' }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p v-if="isAuthenticated" class="text-sm text-muted-foreground">
|
||||||
<span v-if="purchasedTicketIds.length > 1">
|
<span v-if="purchasedTicketIds.length > 1">
|
||||||
Each attendee gets their own scannable QR in My Tickets —
|
Each attendee gets their own scannable QR in My Tickets —
|
||||||
hand them out independently for the door scan.
|
hand them out independently for the door scan.
|
||||||
|
|
@ -565,10 +858,31 @@ onUnmounted(() => {
|
||||||
Your ticket is now in My Tickets.
|
Your ticket is now in My Tickets.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="form.values.email" class="text-sm text-muted-foreground">
|
||||||
|
{{ t('events.checkout.emailedTo', { email: form.values.email }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isAuthenticated" class="w-full space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(id, idx) in purchasedTicketIds"
|
||||||
|
:key="id"
|
||||||
|
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<span v-if="purchasedTicketIds.length > 1" class="text-xs text-muted-foreground">
|
||||||
|
{{ t('events.checkout.ticketN', { n: idx + 1, total: purchasedTicketIds.length }) }}
|
||||||
|
</span>
|
||||||
|
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
|
||||||
|
<img v-if="ticketQRCodes[id]" :src="ticketQRCodes[id]" :alt="`Ticket ${id}`" class="block h-40 w-40" />
|
||||||
|
</div>
|
||||||
|
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
|
||||||
|
{{ t('events.checkout.viewTicket') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 w-full">
|
<div class="space-y-3 w-full">
|
||||||
<Button @click="() => $router.push('/my-tickets')" class="w-full">
|
<Button v-if="isAuthenticated" @click="() => $router.push('/my-tickets')" class="w-full">
|
||||||
View My Tickets
|
View My Tickets
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" @click="handleClose" class="w-full">
|
<Button variant="outline" @click="handleClose" class="w-full">
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
<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,14 +1,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { History } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import type { TemporalFilter } from '../types/filters'
|
import type { TemporalFilter } from '../types/filters'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: TemporalFilter
|
modelValue: TemporalFilter
|
||||||
|
showPast: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: TemporalFilter]
|
'update:modelValue': [value: TemporalFilter]
|
||||||
|
'toggle-past': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
@ -23,16 +26,33 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
|
<!-- 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;">
|
||||||
<Button
|
<Button
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="rounded-full text-xs shrink-0"
|
class="text-xs shrink-0"
|
||||||
@click="emit('update:modelValue', option.value)"
|
@click="emit('update:modelValue', option.value)"
|
||||||
>
|
>
|
||||||
{{ t(option.labelKey) }}
|
{{ t(option.labelKey) }}
|
||||||
</Button>
|
</Button>
|
||||||
|
<!-- Past pill sits at the end of the same scrollable strip so it's
|
||||||
|
discoverable alongside the time-window pills, without claiming
|
||||||
|
a dropdown row of its own. Composes orthogonally with the
|
||||||
|
temporal pills: e.g. "This Week" + Past narrows to days
|
||||||
|
already past this week. -->
|
||||||
|
<Button
|
||||||
|
:variant="props.showPast ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
class="text-xs shrink-0 gap-1.5"
|
||||||
|
@click="emit('toggle-past')"
|
||||||
|
>
|
||||||
|
<History class="w-3 h-3" />
|
||||||
|
{{ t('events.filters.past', 'Past') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
import { signEventViaLnbits } from '@/lib/nostr/signing'
|
||||||
|
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
|
* NIP-51 Bookmarks (kind 10003) for saving favorite events.
|
||||||
|
|
@ -21,12 +22,16 @@ interface BookmarkState {
|
||||||
bookmarkedCoords: Set<string>
|
bookmarkedCoords: Set<string>
|
||||||
/** The latest bookmark event we've seen */
|
/** The latest bookmark event we've seen */
|
||||||
lastEventId: string | null
|
lastEventId: string | null
|
||||||
|
/** `created_at` of the latest bookmark event — used to publish a
|
||||||
|
* strictly-newer timestamp so relays push the update to open subs. */
|
||||||
|
lastCreatedAt: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared state across all component instances
|
// Shared state across all component instances
|
||||||
const state = ref<BookmarkState>({
|
const state = ref<BookmarkState>({
|
||||||
bookmarkedCoords: new Set(),
|
bookmarkedCoords: new Set(),
|
||||||
lastEventId: null,
|
lastEventId: null,
|
||||||
|
lastCreatedAt: null,
|
||||||
})
|
})
|
||||||
const isLoaded = ref(false)
|
const isLoaded = ref(false)
|
||||||
|
|
||||||
|
|
@ -65,7 +70,7 @@ export function useBookmarks() {
|
||||||
}],
|
}],
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
// Only process if newer than what we have
|
// Only process if newer than what we have
|
||||||
if (state.value.lastEventId && event.created_at <= (state.value as any).lastCreatedAt) return
|
if (state.value.lastCreatedAt != null && event.created_at <= state.value.lastCreatedAt) return
|
||||||
|
|
||||||
const coords = new Set<string>()
|
const coords = new Set<string>()
|
||||||
for (const tag of event.tags) {
|
for (const tag of event.tags) {
|
||||||
|
|
@ -76,8 +81,8 @@ export function useBookmarks() {
|
||||||
state.value = {
|
state.value = {
|
||||||
bookmarkedCoords: coords,
|
bookmarkedCoords: coords,
|
||||||
lastEventId: event.id,
|
lastEventId: event.id,
|
||||||
|
lastCreatedAt: event.created_at,
|
||||||
}
|
}
|
||||||
;(state.value as any).lastCreatedAt = event.created_at
|
|
||||||
isLoaded.value = true
|
isLoaded.value = true
|
||||||
},
|
},
|
||||||
onEose: () => {
|
onEose: () => {
|
||||||
|
|
@ -88,9 +93,20 @@ export function useBookmarks() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
|
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list.
|
||||||
|
*
|
||||||
|
* Updates local state OPTIMISTICALLY so the UI (heart fill) responds
|
||||||
|
* instantly, then signs + publishes in the background. Signing routes
|
||||||
|
* through the remote LNbits signer and publishing hits relays, so
|
||||||
|
* awaiting both before flipping state made the heart lag ~1s. On
|
||||||
|
* failure the optimistic change is rolled back. Resolves to whether
|
||||||
|
* the change was persisted.
|
||||||
*/
|
*/
|
||||||
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
|
async function toggleBookmark(
|
||||||
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
eventKind: number,
|
||||||
|
pubkey: string,
|
||||||
|
dTag: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!isAuthenticated.value || !currentUser.value?.pubkey) return false
|
||||||
|
|
||||||
const coord = `${eventKind}:${pubkey}:${dTag}`
|
const coord = `${eventKind}:${pubkey}:${dTag}`
|
||||||
const newCoords = new Set(state.value.bookmarkedCoords)
|
const newCoords = new Set(state.value.bookmarkedCoords)
|
||||||
|
|
@ -101,12 +117,29 @@ export function useBookmarks() {
|
||||||
newCoords.add(coord)
|
newCoords.add(coord)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and publish updated bookmark list
|
// 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).
|
||||||
const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
|
const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
|
||||||
|
|
||||||
const template: EventTemplate = {
|
const template: EventTemplate = {
|
||||||
kind: BOOKMARK_KIND,
|
kind: BOOKMARK_KIND,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: monotonicCreatedAt(prevState.lastCreatedAt),
|
||||||
content: '',
|
content: '',
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
|
|
@ -116,19 +149,28 @@ export function useBookmarks() {
|
||||||
signedEvent = await signEventViaLnbits(template)
|
signedEvent = await signEventViaLnbits(template)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[useBookmarks] signEventViaLnbits failed:', err)
|
console.error('[useBookmarks] signEventViaLnbits failed:', err)
|
||||||
return
|
rollback()
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
if (!relayHub) return
|
if (!relayHub) {
|
||||||
|
rollback()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const result = await relayHub.publishEvent(signedEvent)
|
const result = await relayHub.publishEvent(signedEvent)
|
||||||
if (result.success > 0) {
|
if (result.success > 0) {
|
||||||
state.value = {
|
state.value = {
|
||||||
bookmarkedCoords: newCoords,
|
bookmarkedCoords: newCoords,
|
||||||
lastEventId: signedEvent.id,
|
lastEventId: signedEvent.id,
|
||||||
|
lastCreatedAt: template.created_at,
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rollback()
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,12 @@ export function useEventDetail(eventId: string) {
|
||||||
)
|
)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
// Already in cache
|
|
||||||
if (event.value) return
|
|
||||||
|
|
||||||
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
const nostrService = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
|
||||||
if (!nostrService) {
|
if (!nostrService) {
|
||||||
error.value = 'Events service not available'
|
error.value = 'Events service not available'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
// Scope both the subscription and the one-shot query to this
|
// Scope both the subscription and the one-shot query to this
|
||||||
// event's d-tag. Without this scope, the query asks every
|
// event's d-tag. Without this scope, the query asks every
|
||||||
// relay for every kind-31922/31923 event and races a 5s timeout
|
// relay for every kind-31922/31923 event and races a 5s timeout
|
||||||
|
|
@ -39,6 +32,15 @@ export function useEventDetail(eventId: string) {
|
||||||
// even when the event is reachable.
|
// even when the event is reachable.
|
||||||
const detailFilters = { dTags: [eventId] }
|
const detailFilters = { dTags: [eventId] }
|
||||||
|
|
||||||
|
// Subscribe for LIVE updates regardless of cache state. NIP-52
|
||||||
|
// calendar events are replaceable, so when the events extension
|
||||||
|
// republishes after a ticket sells (updating tickets_sold /
|
||||||
|
// tickets_available — see events services.py), the new version
|
||||||
|
// arrives here and the reactive `event` (and its ticket counts)
|
||||||
|
// updates without a reload. Subscribing only on a cache miss meant
|
||||||
|
// arriving from the feed (event already cached) left the detail
|
||||||
|
// page with no live subscription, so counts went stale until reload.
|
||||||
|
if (unsubscribe) unsubscribe() // avoid leaking a sub if load() re-runs
|
||||||
unsubscribe = nostrService.subscribeToCalendarEvents(
|
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||||
(incoming) => {
|
(incoming) => {
|
||||||
store.upsertEvent(incoming)
|
store.upsertEvent(incoming)
|
||||||
|
|
@ -49,6 +51,14 @@ export function useEventDetail(eventId: string) {
|
||||||
detailFilters
|
detailFilters
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Already cached — the subscription above keeps it fresh; skip the
|
||||||
|
// one-shot query + loading state.
|
||||||
|
if (event.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
const results = await nostrService.queryCalendarEvents(detailFilters)
|
const results = await nostrService.queryCalendarEvents(detailFilters)
|
||||||
store.upsertEvents(results)
|
store.upsertEvents(results)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,32 @@
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
startOfDay, endOfDay, startOfWeek, endOfWeek,
|
||||||
startOfMonth, endOfMonth, addDays, isSameDay,
|
startOfMonth, endOfMonth, addDays,
|
||||||
} from 'date-fns'
|
} from 'date-fns'
|
||||||
import type { Event } from '../types/event'
|
import type { Event } from '../types/event'
|
||||||
import type { EventCategory } from '../types/category'
|
import type { EventCategory } from '../types/category'
|
||||||
import type { TemporalFilter, EventFilters } from '../types/filters'
|
import type { TemporalFilter, EventFilters } from '../types/filters'
|
||||||
import { DEFAULT_FILTERS } from '../types/filters'
|
import { DEFAULT_FILTERS } from '../types/filters'
|
||||||
|
|
||||||
|
// Filter state is hoisted to module scope so every `useEvents()` /
|
||||||
|
// `useEventFilters()` call shares the same refs. The bottom-nav
|
||||||
|
// Hosting tab in events-app/App.vue and the feed view in
|
||||||
|
// EventsPage.vue both rely on this — without a shared instance,
|
||||||
|
// tapping Hosting toggled a private ref the page never saw.
|
||||||
|
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||||
|
const selectedCategories = ref<EventCategory[]>([])
|
||||||
|
// A specific day picked from the calendar popup. When set it takes
|
||||||
|
// priority over the temporal pills + past/upcoming split (browse any
|
||||||
|
// single day). Cleared independently of categories.
|
||||||
|
const selectedDate = ref<Date | undefined>(undefined)
|
||||||
|
const onlyOwnedTickets = ref(false)
|
||||||
|
const onlyHosting = ref(false)
|
||||||
|
const showPast = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for managing event filter state and applying filters reactively.
|
* Composable for managing event filter state and applying filters reactively.
|
||||||
*/
|
*/
|
||||||
export function useEventFilters() {
|
export function useEventFilters() {
|
||||||
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
|
||||||
const selectedCategories = ref<EventCategory[]>([])
|
|
||||||
const selectedDate = ref<Date | undefined>(undefined)
|
|
||||||
/**
|
|
||||||
* When true, the feed is narrowed to events the current user
|
|
||||||
* holds at least one paid ticket for. Crossed with the
|
|
||||||
* `ownedEventIds` set from useOwnedTickets in useEvents
|
|
||||||
* (this composable stays free of ticket fetching).
|
|
||||||
*/
|
|
||||||
const onlyOwnedTickets = ref(false)
|
|
||||||
/**
|
|
||||||
* When true, the feed is narrowed to events the current user
|
|
||||||
* is hosting (organizer pubkey matches the signed-in user, or the
|
|
||||||
* row is a local LNbits draft of theirs). Reads `event.isMine`
|
|
||||||
* which `useEvents.tagOwnership()` populates.
|
|
||||||
*/
|
|
||||||
const onlyHosting = ref(false)
|
|
||||||
/**
|
|
||||||
* When false (default), events that have already ended are
|
|
||||||
* hidden from the feed. Toggling on includes them so the user can
|
|
||||||
* browse past events. The date-picker overrides this — picking a
|
|
||||||
* specific past date shows that day's events regardless,
|
|
||||||
* mirroring how it overrides the temporal pills.
|
|
||||||
*/
|
|
||||||
const showPast = ref(false)
|
|
||||||
|
|
||||||
const filters = computed<EventFilters>(() => ({
|
const filters = computed<EventFilters>(() => ({
|
||||||
temporal: temporal.value,
|
temporal: temporal.value,
|
||||||
|
|
@ -49,10 +39,10 @@ export function useEventFilters() {
|
||||||
function applyFilters(events: Event[]): Event[] {
|
function applyFilters(events: Event[]): Event[] {
|
||||||
let result = events
|
let result = events
|
||||||
|
|
||||||
// Specific date filter (from DatePickerStrip) takes priority over
|
|
||||||
// temporal. Picking a date also bypasses the past/upcoming split
|
|
||||||
// so the user can browse events for any day they choose.
|
|
||||||
if (selectedDate.value) {
|
if (selectedDate.value) {
|
||||||
|
// Specific day picked from the calendar popup — takes priority over
|
||||||
|
// the temporal pills and bypasses the past/upcoming split so any
|
||||||
|
// day (past or future) can be browsed.
|
||||||
const dayStart = startOfDay(selectedDate.value)
|
const dayStart = startOfDay(selectedDate.value)
|
||||||
const dayEnd = endOfDay(selectedDate.value)
|
const dayEnd = endOfDay(selectedDate.value)
|
||||||
result = result.filter(a => {
|
result = result.filter(a => {
|
||||||
|
|
@ -60,8 +50,9 @@ export function useEventFilters() {
|
||||||
return a.startDate <= dayEnd && eventEnd >= dayStart
|
return a.startDate <= dayEnd && eventEnd >= dayStart
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Temporal filter
|
// Temporal filter (preset pills).
|
||||||
result = applyTemporalFilter(result, temporal.value)
|
result = applyTemporalFilter(result, temporal.value)
|
||||||
|
|
||||||
// Past/upcoming split — the chip narrows to one side of "now",
|
// Past/upcoming split — the chip narrows to one side of "now",
|
||||||
// mirroring the "My tickets" / "Hosting" mental model. Default
|
// mirroring the "My tickets" / "Hosting" mental model. Default
|
||||||
// (showPast=false) is upcoming-only; toggling on flips to
|
// (showPast=false) is upcoming-only; toggling on flips to
|
||||||
|
|
@ -93,16 +84,16 @@ export function useEventFilters() {
|
||||||
|
|
||||||
function setTemporal(value: TemporalFilter) {
|
function setTemporal(value: TemporalFilter) {
|
||||||
temporal.value = value
|
temporal.value = value
|
||||||
selectedDate.value = undefined // clear date pick when using temporal pills
|
selectedDate.value = undefined // a preset pill clears the day pick
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectDate(date: Date) {
|
function selectDate(date: Date) {
|
||||||
if (selectedDate.value && isSameDay(selectedDate.value, date)) {
|
|
||||||
selectedDate.value = undefined // toggle off
|
|
||||||
} else {
|
|
||||||
selectedDate.value = date
|
selectedDate.value = date
|
||||||
temporal.value = 'all' // clear temporal pill when picking a specific date
|
temporal.value = 'all' // a specific day overrides the temporal pill
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSelectedDate() {
|
||||||
|
selectedDate.value = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCategory(category: EventCategory) {
|
function toggleCategory(category: EventCategory) {
|
||||||
|
|
@ -163,6 +154,7 @@ export function useEventFilters() {
|
||||||
applyFilters,
|
applyFilters,
|
||||||
setTemporal,
|
setTemporal,
|
||||||
selectDate,
|
selectDate,
|
||||||
|
clearSelectedDate,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
toggleOwnedTickets,
|
toggleOwnedTickets,
|
||||||
|
|
|
||||||