Compare commits
19 commits
c88df44545
...
34ecbd570a
| Author | SHA1 | Date | |
|---|---|---|---|
| 34ecbd570a | |||
| b5eb81e44f | |||
| 8c3dd2e342 | |||
| 1208ec26c2 | |||
| 38bc880939 | |||
| 7649ef00ab | |||
| e0f21822a7 | |||
| 7073fe43b0 | |||
| fd4a75c30b | |||
| 0cef195d94 | |||
| db15675c09 | |||
| 4205d22e9f | |||
| 7751d3c243 | |||
| 5d1f499bd8 | |||
| 3bb853c307 | |||
| 91c83b0f6c | |||
| 1930802cb2 | |||
| 4fc026d14d | |||
| 378a16d621 |
21 changed files with 737 additions and 364 deletions
32
flake.nix
32
flake.nix
|
|
@ -10,24 +10,32 @@
|
||||||
let
|
let
|
||||||
apps = [ "main" "events" "wallet" "chat" "market" "forum" "tasks" "restaurant" "libra" ];
|
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" }:
|
mkWebapp = { pkgs, brandDir ? ./branding/default, app ? "main" }:
|
||||||
let
|
let
|
||||||
buildScript = if app == "main" then "build" else "build:${app}";
|
buildScript = if app == "main" then "build" else "build:${app}";
|
||||||
outDir = if app == "main" then "dist" else "dist-${app}";
|
outDir = if app == "main" then "dist" else "dist-${app}";
|
||||||
|
flakePkgs = flakePkgsFor pkgs;
|
||||||
in
|
in
|
||||||
pkgs.stdenv.mkDerivation (finalAttrs: {
|
flakePkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
pname = "aio-webapp-${app}";
|
pname = "aio-webapp-${app}";
|
||||||
version = "0.0.0";
|
version = "0.0.0";
|
||||||
|
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|
||||||
# Pin pnpm major version (10.x) regardless of consumer's nixpkgs
|
# pnpm comes from THIS flake's pinned nixpkgs (via flakePkgs),
|
||||||
# so the pnpmDeps hash stays stable for downstream callers that
|
# never the consumer's, so the pnpmDeps snapshot is stable.
|
||||||
# bring their own pkgs. package.json's packageManager field
|
pnpm = flakePkgs.pnpm_10;
|
||||||
# declares pnpm@10.33.0; pnpm_10 satisfies that.
|
|
||||||
pnpm = pkgs.pnpm_10;
|
|
||||||
|
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
pnpmDeps = flakePkgs.fetchPnpmDeps {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
inherit (finalAttrs) pnpm;
|
inherit (finalAttrs) pnpm;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
|
|
@ -35,16 +43,16 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
pkgs.nodejs
|
flakePkgs.nodejs
|
||||||
finalAttrs.pnpm
|
finalAttrs.pnpm
|
||||||
pkgs.pnpmConfigHook
|
flakePkgs.pnpmConfigHook
|
||||||
pkgs.autoPatchelfHook
|
flakePkgs.autoPatchelfHook
|
||||||
];
|
];
|
||||||
|
|
||||||
# sharp's prebuilt libvips binaries (under @img/sharp-libvips-*)
|
# sharp's prebuilt libvips binaries (under @img/sharp-libvips-*)
|
||||||
# are dynamically linked; autoPatchelfHook needs the runtime libs.
|
# are dynamically linked; autoPatchelfHook needs the runtime libs.
|
||||||
buildInputs = [
|
buildInputs = [
|
||||||
pkgs.stdenv.cc.cc.lib
|
flakePkgs.stdenv.cc.cc.lib
|
||||||
];
|
];
|
||||||
|
|
||||||
# Brand kit env knobs read by vite-branding.ts and
|
# Brand kit env knobs read by vite-branding.ts and
|
||||||
|
|
@ -72,7 +80,7 @@
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
meta = with flakePkgs.lib; {
|
||||||
description = "AIO webapp${if app == "main" then "" else " (${app} standalone)"}";
|
description = "AIO webapp${if app == "main" then "" else " (${app} standalone)"}";
|
||||||
homepage = "https://git.atitlan.io/aiolabs/webapp";
|
homepage = "https://git.atitlan.io/aiolabs/webapp";
|
||||||
license = licenses.mit;
|
license = licenses.mit;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
|
|
||||||
|
|
||||||
export interface BottomTab {
|
export interface BottomTab {
|
||||||
/** Translated label shown under the icon. */
|
/** Translated label shown under the icon. */
|
||||||
|
|
@ -18,6 +17,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 +29,9 @@ 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>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
@ -42,6 +42,11 @@ 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>
|
||||||
|
|
@ -56,12 +61,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 +78,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>
|
|
||||||
|
|
@ -53,6 +53,9 @@ function goLogin() {
|
||||||
</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">
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
81
src/components/layout/StandaloneMenu.vue
Normal file
81
src/components/layout/StandaloneMenu.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, type Component } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Menu } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 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="t('common.nav.menu')"
|
||||||
|
>
|
||||||
|
<Menu class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" class="w-80 sm:w-96 overflow-y-auto">
|
||||||
|
<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>
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,36 +23,71 @@ 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.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.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.map'), icon: Map, path: '/events/map' },
|
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
|
||||||
{
|
{
|
||||||
name: t('events.nav.favorites'),
|
name: t('events.nav.favorites'),
|
||||||
|
|
@ -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',
|
||||||
|
|
@ -68,6 +69,8 @@ const messages: LocaleMessages = {
|
||||||
hosting: 'Hosting',
|
hosting: 'Hosting',
|
||||||
pastEvents: 'Past events',
|
pastEvents: 'Past events',
|
||||||
past: 'Past',
|
past: 'Past',
|
||||||
|
filters: 'Filters',
|
||||||
|
clearAll: 'Clear all',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -127,7 +130,7 @@ const messages: LocaleMessages = {
|
||||||
registered: 'Registered',
|
registered: 'Registered',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
feed: 'Feed',
|
feed: 'Home',
|
||||||
calendar: 'Calendar',
|
calendar: 'Calendar',
|
||||||
map: 'Map',
|
map: 'Map',
|
||||||
favorites: 'Favorites',
|
favorites: 'Favorites',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
@ -68,6 +69,8 @@ const messages: LocaleMessages = {
|
||||||
hosting: 'Organizo',
|
hosting: 'Organizo',
|
||||||
pastEvents: 'Eventos pasados',
|
pastEvents: 'Eventos pasados',
|
||||||
past: 'Pasado',
|
past: 'Pasado',
|
||||||
|
filters: 'Filtros',
|
||||||
|
clearAll: 'Limpiar todo',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concierto',
|
concert: 'Concierto',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
@ -68,6 +69,8 @@ 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',
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
concert: 'Concert',
|
concert: 'Concert',
|
||||||
|
|
@ -127,7 +130,7 @@ const messages: LocaleMessages = {
|
||||||
registered: 'Enregistré',
|
registered: 'Enregistré',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
feed: 'Fil',
|
feed: 'Accueil',
|
||||||
calendar: 'Calendrier',
|
calendar: 'Calendrier',
|
||||||
map: 'Carte',
|
map: 'Carte',
|
||||||
favorites: 'Favoris',
|
favorites: 'Favoris',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -69,6 +70,8 @@ export interface LocaleMessages {
|
||||||
hosting: string
|
hosting: string
|
||||||
pastEvents: string
|
pastEvents: string
|
||||||
past: string
|
past: string
|
||||||
|
filters: string
|
||||||
|
clearAll: string
|
||||||
}
|
}
|
||||||
categories: Record<string, string>
|
categories: Record<string, string>
|
||||||
detail: {
|
detail: {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ 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 +56,58 @@ const priceDisplay = computed(() => {
|
||||||
return `${info.price} ${info.currency}`
|
return `${info.price} ${info.currency}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const placeholderBg = computed(() => {
|
|
||||||
// Generate a consistent hue from the event title
|
|
||||||
const hash = props.event.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
|
||||||
const hue = hash % 360
|
|
||||||
return `hsl(${hue}, 40%, 85%)`
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPast = computed(() => {
|
const isPast = computed(() => {
|
||||||
const a = props.event
|
const a = props.event
|
||||||
const end = a.endDate ?? a.startDate
|
const end = a.endDate ?? a.startDate
|
||||||
if (!end || isNaN(end.getTime())) return false
|
if (!end || isNaN(end.getTime())) return false
|
||||||
return end.getTime() < Date.now()
|
return end.getTime() < Date.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pending / rejected events get a washed-out look so the user
|
||||||
|
// sees at a glance the event isn't live, not just the small badge.
|
||||||
|
const isNonApproved = computed(
|
||||||
|
() => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved',
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card
|
<Card
|
||||||
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
|
class="relative 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 +137,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 +152,62 @@ 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'">
|
||||||
<!-- 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" />
|
||||||
|
|
@ -216,5 +257,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>
|
||||||
|
|
|
||||||
|
|
@ -443,7 +443,7 @@ onUnmounted(() => {
|
||||||
</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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,35 +8,22 @@ 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[]>([])
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,38 @@ export function useTicketScanner(eventId: Ref<string>) {
|
||||||
isPaused.value = false
|
isPaused.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a ticket as registered without going through the camera —
|
||||||
|
* used when the host knows the attendee in person or accepts an
|
||||||
|
* alternate proof of identity. Same backend endpoint as a scan
|
||||||
|
* (so it also gates on event ownership and rejects unpaid /
|
||||||
|
* already-registered tickets), but skips the scanner pause +
|
||||||
|
* full-screen banner since the operator initiated the action
|
||||||
|
* from the roster directly. Refreshes stats on success.
|
||||||
|
*/
|
||||||
|
async function registerManually(
|
||||||
|
ticketId: string,
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
|
||||||
|
if (!adminKey) return { ok: false, error: 'No wallet admin key available' }
|
||||||
|
try {
|
||||||
|
await ticketApi.registerTicket(ticketId, adminKey)
|
||||||
|
// Mirror the session-local dedup the scan path uses so a
|
||||||
|
// subsequent QR scan of the same ticket reports "Already
|
||||||
|
// scanned" instead of round-tripping a duplicate register.
|
||||||
|
if (!scanned.value.some(r => r.ticketId === ticketId)) {
|
||||||
|
scanned.value = [
|
||||||
|
{ ticketId, name: null, registeredAt: new Date().toISOString() },
|
||||||
|
...scanned.value,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
await refreshStats()
|
||||||
|
return { ok: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearScanned() {
|
function clearScanned() {
|
||||||
scanned.value = []
|
scanned.value = []
|
||||||
lastScan.value = null
|
lastScan.value = null
|
||||||
|
|
@ -210,5 +242,6 @@ export function useTicketScanner(eventId: Ref<string>) {
|
||||||
onDecode,
|
onDecode,
|
||||||
resume,
|
resume,
|
||||||
clearScanned,
|
clearScanned,
|
||||||
|
registerManually,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,41 +170,14 @@ function goToMyTickets() {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
||||||
<!-- Top bar -->
|
<!-- Top bar — back-link only. Edit moves into the title row as a
|
||||||
<div class="flex items-center justify-between mb-4">
|
prominent icon button; Scan moves into the tickets section
|
||||||
|
where it replaces the Buy-ticket CTA for the host. -->
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
||||||
<ArrowLeft class="w-4 h-4" />
|
<ArrowLeft class="w-4 h-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<Button
|
|
||||||
v-if="ownedLnbitsEvent"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="gap-1.5"
|
|
||||||
@click="openScannerPage"
|
|
||||||
aria-label="Scan tickets"
|
|
||||||
>
|
|
||||||
<ScanLine class="w-4 h-4" />
|
|
||||||
Scan
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
v-if="ownedLnbitsEvent"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="gap-1.5"
|
|
||||||
@click="openEditDialog"
|
|
||||||
aria-label="Edit event"
|
|
||||||
>
|
|
||||||
<Pencil class="w-4 h-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<BookmarkButton
|
|
||||||
v-if="event"
|
|
||||||
:pubkey="event.organizer.pubkey"
|
|
||||||
:d-tag="event.id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
|
|
@ -233,104 +206,128 @@ function goToMyTickets() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Title + Category -->
|
<!-- Title + bookmark + captions -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-start gap-2 mb-2">
|
<div class="flex flex-wrap items-start gap-2 mb-2">
|
||||||
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
|
<!-- "Yours" leads the row in the highlighted variant so the
|
||||||
|
ownership signal stands out against the neutral
|
||||||
|
category/tag chips that follow. -->
|
||||||
|
<Badge
|
||||||
|
v-if="event.isMine"
|
||||||
|
variant="secondary"
|
||||||
|
class="shrink-0"
|
||||||
|
>
|
||||||
|
Yours
|
||||||
|
</Badge>
|
||||||
|
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
|
||||||
{{ categoryLabel }}
|
{{ categoryLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||||
class="shrink-0 mt-1 capitalize"
|
class="shrink-0 capitalize"
|
||||||
>
|
>
|
||||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
|
||||||
v-if="event.isMine"
|
|
||||||
variant="outline"
|
|
||||||
class="shrink-0 mt-1"
|
|
||||||
>
|
|
||||||
Yours
|
|
||||||
</Badge>
|
|
||||||
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
||||||
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||||
{{ event.title }}
|
{{ event.title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
<div class="flex items-center gap-1 shrink-0 mt-1">
|
||||||
|
<Button
|
||||||
|
v-if="ownedLnbitsEvent"
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
:aria-label="t('events.detail.editEvent', 'Edit event')"
|
||||||
|
@click="openEditDialog"
|
||||||
|
>
|
||||||
|
<Pencil class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<!-- Hosts don't need to favorite their own event — the
|
||||||
|
"Yours" badge already marks it, and the bookmark
|
||||||
|
affordance is meant for discovery, not management. -->
|
||||||
|
<BookmarkButton
|
||||||
|
v-else
|
||||||
|
:pubkey="event.organizer.pubkey"
|
||||||
|
:d-tag="event.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p v-if="event.summary" class="text-muted-foreground mt-2">
|
<p v-if="event.summary" class="text-muted-foreground mt-2">
|
||||||
{{ event.summary }}
|
{{ event.summary }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- When + Where captions -->
|
||||||
|
<div class="mt-3 space-y-1 text-sm text-muted-foreground">
|
||||||
|
<div class="flex items-start gap-1.5">
|
||||||
|
<Calendar class="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
{{ dateDisplay }}
|
||||||
|
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="event.location" class="flex items-start gap-1.5">
|
||||||
|
<MapPin class="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{{ event.location }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<!-- Info section -->
|
<!-- Description -->
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="prose prose-sm max-w-none text-foreground">
|
||||||
<!-- When -->
|
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||||
<div class="bg-muted/50 rounded-lg p-4 space-y-1">
|
|
||||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
||||||
<Calendar class="w-4 h-4" />
|
|
||||||
{{ t('events.detail.when') }}
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
|
|
||||||
<p v-if="event.timezone" class="text-xs text-muted-foreground/70">
|
|
||||||
{{ event.timezone }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Where -->
|
<!-- RSVP — hidden for the host since RSVPing to your own event
|
||||||
<div v-if="event.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
|
is a noise affordance. -->
|
||||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
||||||
<MapPin class="w-4 h-4" />
|
|
||||||
{{ t('events.detail.location') }}
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-muted-foreground">{{ event.location }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RSVP -->
|
|
||||||
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
|
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
|
||||||
(31922 for date-based, 31923 for time-based). Without this prop the
|
(31922 for date-based, 31923 for time-based). Without this prop the
|
||||||
button would default to time-based for every event, leaving RSVPs
|
button would default to time-based for every event, leaving RSVPs
|
||||||
on date-based events pointing at a non-existent event coord. -->
|
on date-based events pointing at a non-existent event coord. -->
|
||||||
<RSVPButton
|
<RSVPButton
|
||||||
|
v-if="!ownedLnbitsEvent"
|
||||||
:pubkey="event.organizer.pubkey"
|
:pubkey="event.organizer.pubkey"
|
||||||
:d-tag="event.id"
|
:d-tag="event.id"
|
||||||
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Host's primary CTA is to scan tickets at the door. Lives
|
||||||
|
OUTSIDE the ticketInfo gate so it appears even when the
|
||||||
|
event was published without AIO ticket tags — a host always
|
||||||
|
gets to scan attempts. Stays available for past events too
|
||||||
|
so the host can still verify attendance after the fact. -->
|
||||||
|
<Button
|
||||||
|
v-if="ownedLnbitsEvent"
|
||||||
|
class="w-full gap-1.5"
|
||||||
|
size="lg"
|
||||||
|
@click="openScannerPage"
|
||||||
|
>
|
||||||
|
<ScanLine class="w-4 h-4" />
|
||||||
|
{{ t('events.detail.scanTickets', 'Scan tickets') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- Tickets — gated on the event carrying ticketInfo (set
|
<!-- Tickets — gated on the event carrying ticketInfo (set
|
||||||
by the calendar→Event converter from the AIO custom
|
by the calendar→Event converter from the AIO custom
|
||||||
tickets_* tags on the published event). Sections render
|
tickets_* tags on the published event). Skipped for the
|
||||||
bottom-up: availability count, then existing owned
|
host entirely — they have the Scan CTA above and don't
|
||||||
tickets (when count > 0) above a Purchase CTA (when
|
need a Buy CTA for their own event. -->
|
||||||
capacity remains). -->
|
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
|
||||||
<div v-if="event.ticketInfo" class="space-y-3">
|
|
||||||
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<Ticket class="w-4 h-4 shrink-0" />
|
|
||||||
<span v-if="event.ticketInfo.available === undefined">
|
|
||||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="event.ticketInfo.available > 0">
|
|
||||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-destructive font-medium">
|
|
||||||
{{ t('events.detail.soldOut') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="ownedPaidCount > 0"
|
v-if="ownedPaidCount > 0"
|
||||||
class="bg-primary/10 border border-primary/30 rounded-lg p-4 flex items-center justify-between gap-3"
|
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
|
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
|
||||||
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -343,10 +340,11 @@ function goToMyTickets() {
|
||||||
<History class="w-4 h-4 shrink-0" />
|
<History class="w-4 h-4 shrink-0" />
|
||||||
{{ t('events.detail.pastEvent', 'This event has already happened') }}
|
{{ t('events.detail.pastEvent', 'This event has already happened') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="canBuyTicket">
|
<div v-else-if="canBuyTicket" class="space-y-1">
|
||||||
<Button
|
<Button
|
||||||
class="w-full gap-1.5"
|
class="w-full gap-1.5"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
|
||||||
@click="openPurchaseDialog"
|
@click="openPurchaseDialog"
|
||||||
>
|
>
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
|
|
@ -359,6 +357,14 @@ function goToMyTickets() {
|
||||||
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
|
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<p class="text-xs text-muted-foreground text-center">
|
||||||
|
<span v-if="event.ticketInfo.available === undefined">
|
||||||
|
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-else-if="ownedPaidCount === 0"
|
v-else-if="ownedPaidCount === 0"
|
||||||
|
|
@ -375,6 +381,13 @@ function goToMyTickets() {
|
||||||
@update:is-open="showPurchaseDialog = $event"
|
@update:is-open="showPurchaseDialog = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- External references -->
|
||||||
|
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||||
|
<!-- References would go here in future phases -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<!-- Organizer -->
|
<!-- Organizer -->
|
||||||
<div class="bg-muted/50 rounded-lg p-4">
|
<div class="bg-muted/50 rounded-lg p-4">
|
||||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
|
@ -382,18 +395,6 @@ function goToMyTickets() {
|
||||||
</p>
|
</p>
|
||||||
<OrganizerCard :pubkey="event.organizer.pubkey" />
|
<OrganizerCard :pubkey="event.organizer.pubkey" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="prose prose-sm max-w-none text-foreground">
|
|
||||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- External references -->
|
|
||||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
|
||||||
<!-- References would go here in future phases -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,31 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Ticket } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { useEvents } from '../composables/useEvents'
|
import { useEvents } from '../composables/useEvents'
|
||||||
|
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import EventCalendarView from '../components/EventCalendarView.vue'
|
import EventCalendarView from '../components/EventCalendarView.vue'
|
||||||
import type { Event } from '../types/event'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
const { allEvents, subscribe } = useEvents()
|
const { allEvents, subscribe } = useEvents()
|
||||||
|
const { ownedEventIds } = useOwnedTickets()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
// Per-page toggle, intentionally not wired to the feed's
|
||||||
|
// onlyOwnedTickets filter — narrowing the calendar shouldn't also
|
||||||
|
// narrow the feed the user navigates back to.
|
||||||
|
const onlyMine = ref(false)
|
||||||
|
|
||||||
|
const visibleEvents = computed<Event[]>(() => {
|
||||||
|
if (!onlyMine.value) return allEvents.value
|
||||||
|
const owned = ownedEventIds.value
|
||||||
|
return allEvents.value.filter(a => owned.has(a.id))
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
subscribe()
|
subscribe()
|
||||||
|
|
@ -19,8 +38,23 @@ function handleSelectEvent(event: Event) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||||
|
<!-- Filter chip: narrows the calendar to events the user has
|
||||||
|
paid tickets for. Hidden when logged out — nothing to own.
|
||||||
|
Left-aligned so it doesn't collide with the fixed top-right
|
||||||
|
hamburger menu. -->
|
||||||
|
<div v-if="isAuthenticated" class="mb-3 flex">
|
||||||
|
<Button
|
||||||
|
:variant="onlyMine ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
class="gap-1.5"
|
||||||
|
@click="onlyMine = !onlyMine"
|
||||||
|
>
|
||||||
|
<Ticket class="w-3.5 h-3.5" />
|
||||||
|
{{ t('events.filters.myTickets', 'My tickets') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<EventCalendarView
|
<EventCalendarView
|
||||||
:events="allEvents"
|
:events="visibleEvents"
|
||||||
@select-event="handleSelectEvent"
|
@select-event="handleSelectEvent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -8,9 +8,10 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { SlidersHorizontal, History, CalendarDays, Plus } from 'lucide-vue-next'
|
||||||
import { useEvents } from '../composables/useEvents'
|
import { useEvents } from '../composables/useEvents'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useEventsStore } from '../stores/events'
|
||||||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||||
|
|
@ -20,6 +21,7 @@ import type { Event } from '../types/event'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const eventsStore = useEventsStore()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
events,
|
events,
|
||||||
|
|
@ -29,24 +31,26 @@ const {
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
onlyOwnedTickets,
|
|
||||||
onlyHosting,
|
|
||||||
showPast,
|
showPast,
|
||||||
|
onlyHosting,
|
||||||
selectDate,
|
selectDate,
|
||||||
setTemporal,
|
setTemporal,
|
||||||
toggleCategory,
|
toggleCategory,
|
||||||
clearCategories,
|
clearCategories,
|
||||||
toggleOwnedTickets,
|
|
||||||
toggleHosting,
|
|
||||||
togglePast,
|
togglePast,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
subscribe,
|
subscribe,
|
||||||
} = useEvents()
|
} = useEvents()
|
||||||
|
|
||||||
const { isAuthenticated } = useAuth()
|
|
||||||
|
|
||||||
const filtersOpen = ref(false)
|
const filtersOpen = ref(false)
|
||||||
|
|
||||||
|
// Badge count on the Filters trigger so the user can see at a glance
|
||||||
|
// that hidden toggles (past-events, categories) are currently active
|
||||||
|
// even when the collapsible is closed.
|
||||||
|
const filterCount = computed(
|
||||||
|
() => selectedCategories.value.length + (showPast.value ? 1 : 0),
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
subscribe()
|
subscribe()
|
||||||
})
|
})
|
||||||
|
|
@ -54,61 +58,100 @@ onMounted(() => {
|
||||||
function handleSelectEvent(event: Event) {
|
function handleSelectEvent(event: Event) {
|
||||||
router.push({ name: 'event-detail', params: { id: event.id } })
|
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create-activity CTA in the Hosting view. Calendar-tab → page lives
|
||||||
|
// on /events/calendar; the icon button at the end of the date
|
||||||
|
// strip is the only entry point now that the bottom-nav Calendar
|
||||||
|
// tab is gone.
|
||||||
|
function openCreate() {
|
||||||
|
eventsStore.editingEvent = null
|
||||||
|
eventsStore.showCreateDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCalendar() {
|
||||||
|
router.push('/events/calendar')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto py-6 px-4">
|
<div class="container mx-auto py-4 px-4">
|
||||||
<!-- Page header -->
|
<!-- Page header -->
|
||||||
<div class="mb-4">
|
<h1 class="mb-3 text-xl sm:text-2xl font-bold text-foreground">
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
|
||||||
{{ t('events.title') }}
|
{{ t('events.title') }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search with dropdown overlay -->
|
<!-- Search with dropdown overlay -->
|
||||||
<div class="mb-4">
|
<div class="mb-3">
|
||||||
<EventSearchOverlay
|
<EventSearchOverlay
|
||||||
:events="events"
|
:events="events"
|
||||||
@select="handleSelectEvent"
|
@select="handleSelectEvent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date picker strip (p'a semana style) -->
|
<!-- Date picker strip + calendar shortcut. The calendar icon used
|
||||||
<div class="mb-4">
|
to be a bottom-nav tab; it now lives on the right of the week
|
||||||
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
|
strip so the tabs row stays focused on the primary views.
|
||||||
|
Hidden in the Hosting view — operators don't need calendar
|
||||||
|
navigation when they're managing their own roster. -->
|
||||||
|
<div v-if="!onlyHosting" class="mb-3 flex items-center gap-2">
|
||||||
|
<DatePickerStrip
|
||||||
|
class="flex-1 min-w-0"
|
||||||
|
:selected-date="selectedDate"
|
||||||
|
@select="selectDate"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0"
|
||||||
|
:aria-label="t('events.nav.calendar')"
|
||||||
|
@click="openCalendar"
|
||||||
|
>
|
||||||
|
<CalendarDays class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Temporal filter pills -->
|
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
|
||||||
<div class="mb-4">
|
column; only the temporal pills scroll horizontally. The
|
||||||
|
Filters icon (with a count badge when past-events or any
|
||||||
|
categories are active) opens a collapsible that hosts the
|
||||||
|
past-events toggle + category chips below. Hidden in the
|
||||||
|
Hosting view — the operator's roster doesn't need them. -->
|
||||||
|
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="shrink-0 flex flex-col items-center gap-0.5">
|
||||||
|
<CollapsibleTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="rounded-full h-8 w-8 relative"
|
||||||
|
:class="{ 'bg-accent text-accent-foreground': filtersOpen || filterCount > 0 }"
|
||||||
|
:aria-label="t('events.filters.filters', 'Filters')"
|
||||||
|
:aria-expanded="filtersOpen"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal class="w-4 h-4" />
|
||||||
|
<span
|
||||||
|
v-if="filterCount > 0"
|
||||||
|
class="absolute -top-1 -right-1 min-w-[16px] h-[16px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ filterCount }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<Button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-5 px-1 text-[10px] text-muted-foreground"
|
||||||
|
@click="resetFilters"
|
||||||
|
>
|
||||||
|
{{ t('events.filters.clearAll', 'Clear all') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0 pt-0.5">
|
||||||
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
<CollapsibleContent class="mt-3 space-y-3">
|
||||||
"Hosting") narrow the feed to events the signed-in user
|
|
||||||
has skin in and are hidden when logged out. The "Past events"
|
|
||||||
chip is always visible since past-browsing doesn't require an
|
|
||||||
account. -->
|
|
||||||
<div class="mb-4 flex flex-wrap gap-2">
|
|
||||||
<template v-if="isAuthenticated">
|
|
||||||
<Button
|
|
||||||
:variant="onlyOwnedTickets ? 'default' : 'outline'"
|
|
||||||
size="sm"
|
|
||||||
class="gap-1.5"
|
|
||||||
@click="toggleOwnedTickets"
|
|
||||||
>
|
|
||||||
<Ticket class="w-3.5 h-3.5" />
|
|
||||||
{{ t('events.filters.myTickets', 'My tickets') }}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
:variant="onlyHosting ? 'default' : 'outline'"
|
|
||||||
size="sm"
|
|
||||||
class="gap-1.5"
|
|
||||||
@click="toggleHosting"
|
|
||||||
>
|
|
||||||
<Megaphone class="w-3.5 h-3.5" />
|
|
||||||
{{ t('events.filters.hosting', 'Hosting') }}
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
<Button
|
<Button
|
||||||
:variant="showPast ? 'default' : 'outline'"
|
:variant="showPast ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -118,21 +161,7 @@ function handleSelectEvent(event: Event) {
|
||||||
<History class="w-3.5 h-3.5" />
|
<History class="w-3.5 h-3.5" />
|
||||||
{{ t('events.filters.pastEvents', 'Past events') }}
|
{{ t('events.filters.pastEvents', 'Past events') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<Separator />
|
||||||
|
|
||||||
<!-- Category filters (collapsible) -->
|
|
||||||
<Collapsible v-model:open="filtersOpen" class="mb-6">
|
|
||||||
<CollapsibleTrigger as-child>
|
|
||||||
<Button variant="ghost" size="sm" class="gap-1.5 text-muted-foreground">
|
|
||||||
<SlidersHorizontal class="w-4 h-4" />
|
|
||||||
Categories
|
|
||||||
<span v-if="selectedCategories.length > 0" class="text-xs bg-primary text-primary-foreground rounded-full px-1.5">
|
|
||||||
{{ selectedCategories.length }}
|
|
||||||
</span>
|
|
||||||
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': filtersOpen }" />
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent class="mt-2">
|
|
||||||
<CategoryFilterBar
|
<CategoryFilterBar
|
||||||
:selected="selectedCategories"
|
:selected="selectedCategories"
|
||||||
@toggle="toggleCategory"
|
@toggle="toggleCategory"
|
||||||
|
|
@ -141,23 +170,31 @@ function handleSelectEvent(event: Event) {
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<!-- Active filters indicator -->
|
<!-- Create-activity CTA — shown when the Hosting bottom-nav tab is
|
||||||
<div v-if="hasActiveFilters" class="flex items-center gap-2 mb-4">
|
active. Replaces the dedicated Create entry that used to live
|
||||||
<span class="text-xs text-muted-foreground">Filters active</span>
|
in the bottom nav; lives here so it shows up exactly when the
|
||||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="resetFilters">
|
user is in the "events I'm running" view. -->
|
||||||
Clear all
|
<Button
|
||||||
|
v-if="onlyHosting"
|
||||||
|
class="w-full mb-3 gap-1.5"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
{{ t('events.createNew') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
|
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Event feed -->
|
<!-- Event feed. The Hosting view renders compact rows so the
|
||||||
|
operator can scan their roster without the visual weight of
|
||||||
|
hero images they already recognize. -->
|
||||||
<EventList
|
<EventList
|
||||||
:events="events"
|
:events="events"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
|
:compact="onlyHosting"
|
||||||
@select="handleSelectEvent"
|
@select="handleSelectEvent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
|
@ -9,15 +10,20 @@ import {
|
||||||
Ticket,
|
Ticket,
|
||||||
ScanLine,
|
ScanLine,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
UserCheck,
|
||||||
|
Search,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
import QRScanner from '@/components/ui/qr-scanner.vue'
|
||||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
import { useTicketScanner } from '../composables/useTicketScanner'
|
||||||
|
import type { EventTicket } from '../composables/useTicketScanner'
|
||||||
import { useEventDetail } from '../composables/useEventDetail'
|
import { useEventDetail } from '../composables/useEventDetail'
|
||||||
|
import { useFuzzySearch } from '@/composables/useFuzzySearch'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -35,8 +41,14 @@ const {
|
||||||
refreshStats,
|
refreshStats,
|
||||||
onDecode,
|
onDecode,
|
||||||
resume,
|
resume,
|
||||||
|
registerManually,
|
||||||
} = useTicketScanner(eventId)
|
} = useTicketScanner(eventId)
|
||||||
|
|
||||||
|
// Tracks tickets currently mid-register (manual button click), so each
|
||||||
|
// row can render a per-row spinner without blocking the rest of the
|
||||||
|
// list. A Set keeps add/remove O(1).
|
||||||
|
const pendingRegister = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const scannerOpen = ref(true)
|
const scannerOpen = ref(true)
|
||||||
const activeTab = ref<'scanner' | 'list'>('scanner')
|
const activeTab = ref<'scanner' | 'list'>('scanner')
|
||||||
|
|
||||||
|
|
@ -64,11 +76,55 @@ const remainingCount = computed(() => {
|
||||||
return Math.max(0, soldCount.value - registeredCount.value)
|
return Math.max(0, soldCount.value - registeredCount.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Registered tickets only — what the "Scanned" tab shows.
|
// Full ticket roster, sorted so unregistered (actionable) rows lead
|
||||||
const registeredTickets = computed(
|
// and registered rows follow most-recent-first. Powers the Tickets
|
||||||
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
|
// tab where the host can manually register attendees who can prove
|
||||||
|
// identity but can't present a scannable QR.
|
||||||
|
const allTickets = computed<EventTicket[]>(() => {
|
||||||
|
const list = eventStats.value?.tickets ?? []
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
if (a.registered !== b.registered) return a.registered ? 1 : -1
|
||||||
|
if (a.registered && b.registered) {
|
||||||
|
return (b.registeredAt ?? '').localeCompare(a.registeredAt ?? '')
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalTicketsCount = computed(() => eventStats.value?.tickets.length ?? 0)
|
||||||
|
const unregisteredCount = computed(
|
||||||
|
() => allTickets.value.filter(t => !t.registered).length,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Fuzzy match on holder name + ticket id. When the search box is
|
||||||
|
// empty, Fuse returns the list in its incoming order so our
|
||||||
|
// unregistered-first sort is preserved.
|
||||||
|
const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
|
||||||
|
allTickets,
|
||||||
|
{
|
||||||
|
fuseOptions: {
|
||||||
|
keys: [
|
||||||
|
{ name: 'name', weight: 0.7 },
|
||||||
|
{ name: 'id', weight: 0.3 },
|
||||||
|
],
|
||||||
|
threshold: 0.3,
|
||||||
|
ignoreLocation: true,
|
||||||
|
},
|
||||||
|
matchAllWhenSearchEmpty: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleManualRegister(ticket: EventTicket) {
|
||||||
|
pendingRegister.value.add(ticket.id)
|
||||||
|
const res = await registerManually(ticket.id)
|
||||||
|
pendingRegister.value.delete(ticket.id)
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success(`Registered ${ticket.name || ticket.id.slice(0, 8) + '…'}`)
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || 'Failed to register')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleResult(qrText: string) {
|
function handleResult(qrText: string) {
|
||||||
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
||||||
// already throttles, and useTicketScanner.onDecode dedups the same
|
// already throttles, and useTicketScanner.onDecode dedups the same
|
||||||
|
|
@ -156,13 +212,21 @@ function fmtTime(iso: string) {
|
||||||
|
|
||||||
<Tabs v-model="activeTab" class="w-full">
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
<TabsList class="grid w-full grid-cols-2 mb-4">
|
<TabsList class="grid w-full grid-cols-2 mb-4">
|
||||||
<TabsTrigger value="scanner" class="gap-1.5">
|
<!-- Icon + label wrapped in a real flex container so they
|
||||||
|
share a gap and items-center alignment. TabsTrigger's
|
||||||
|
internal slot lives in an inline span, so a `gap-1.5`
|
||||||
|
on the trigger itself never reaches these two children. -->
|
||||||
|
<TabsTrigger value="scanner">
|
||||||
|
<span class="inline-flex items-center justify-center gap-1.5">
|
||||||
<ScanLine class="w-4 h-4" />
|
<ScanLine class="w-4 h-4" />
|
||||||
Scanner
|
Scanner
|
||||||
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="list" class="gap-1.5">
|
<TabsTrigger value="list">
|
||||||
|
<span class="inline-flex items-center justify-center gap-1.5">
|
||||||
<Ticket class="w-4 h-4" />
|
<Ticket class="w-4 h-4" />
|
||||||
Scanned ({{ registeredCount }})
|
Tickets ({{ totalTicketsCount }})
|
||||||
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|
@ -190,39 +254,83 @@ function fmtTime(iso: string) {
|
||||||
<TabsContent value="list" class="mt-0">
|
<TabsContent value="list" class="mt-0">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h2 class="text-sm font-medium text-foreground">
|
<h2 class="text-sm font-medium text-foreground">
|
||||||
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
|
{{ registeredCount }} / {{ totalTicketsCount }} registered
|
||||||
|
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
|
||||||
|
· {{ unregisteredCount }} to go
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
|
<!-- Fuzzy filter on holder name + ticket id (Fuse.js via
|
||||||
|
useFuzzySearch). Empty query → all rows in their
|
||||||
|
sort order; typing → reordered by relevance. -->
|
||||||
|
<div v-if="allTickets.length > 0" class="relative">
|
||||||
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by name or ticket id…"
|
||||||
|
class="pl-8 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unregistered rows lead the list so the operator can act
|
||||||
|
on the actionable ones first; tap "Register" to mark an
|
||||||
|
attendee present without a QR (e.g. lost phone, known
|
||||||
|
in person). Failures surface as a toast; the row reverts. -->
|
||||||
|
<ScrollArea v-if="searchedTickets.length > 0" class="h-[60vh]">
|
||||||
<ul class="space-y-1.5 pr-3">
|
<ul class="space-y-1.5 pr-3">
|
||||||
<li
|
<li
|
||||||
v-for="record in registeredTickets"
|
v-for="ticket in searchedTickets"
|
||||||
:key="record.id"
|
:key="ticket.id"
|
||||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
v-if="record.registeredAt"
|
v-if="ticket.registered && ticket.registeredAt"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="text-[10px] font-mono px-1.5"
|
class="text-[10px] font-mono px-1.5"
|
||||||
>
|
>
|
||||||
{{ fmtTime(record.registeredAt) }}
|
{{ fmtTime(ticket.registeredAt) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span v-if="record.name" class="font-medium text-foreground">
|
<span v-if="ticket.name" class="font-medium text-foreground">
|
||||||
{{ record.name }}
|
{{ ticket.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
||||||
{{ record.id }}
|
{{ ticket.id }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
|
<CheckCircle2
|
||||||
|
v-if="ticket.registered"
|
||||||
|
class="w-4 h-4 text-emerald-500 shrink-0"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="shrink-0 gap-1"
|
||||||
|
:disabled="pendingRegister.has(ticket.id)"
|
||||||
|
@click="handleManualRegister(ticket)"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
v-if="pendingRegister.has(ticket.id)"
|
||||||
|
class="w-3.5 h-3.5 animate-spin"
|
||||||
|
/>
|
||||||
|
<UserCheck v-else class="w-3.5 h-3.5" />
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
<p
|
||||||
|
v-else-if="allTickets.length === 0"
|
||||||
|
class="text-sm text-muted-foreground text-center py-12"
|
||||||
|
>
|
||||||
|
No tickets sold yet.
|
||||||
|
</p>
|
||||||
<p v-else class="text-sm text-muted-foreground text-center py-12">
|
<p v-else class="text-sm text-muted-foreground text-center py-12">
|
||||||
No tickets scanned yet.
|
No tickets match “{{ searchQuery }}”.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue