feat(activities): UI tweaks across feed, detail, hosting, calendar, scan, shell #91

Merged
padreug merged 25 commits from feat/ui-tweaks into dev 2026-06-10 16:35:50 +00:00
29 changed files with 1058 additions and 848 deletions

View file

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

View file

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

View file

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

View file

@ -1,38 +1,122 @@
<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, Home, LogIn, LogOut, Settings, 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>
<SheetHeader> <SheetHeader>
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle> <div class="flex items-center justify-between gap-2">
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
<Button
v-if="isAuthenticated"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:aria-label="t('common.nav.editProfile', 'Edit profile')"
@click="editProfileOpen = true"
>
<Settings class="h-4 w-4" />
</Button>
</div>
<SheetDescription v-if="isAuthenticated"> <SheetDescription v-if="isAuthenticated">
{{ t('common.nav.profileDescription') }} {{ t('common.nav.profileDescription') }}
</SheetDescription> </SheetDescription>
@ -41,18 +125,70 @@ function goLogin() {
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<!-- Identity card (logged in) --> <!-- Identity card (logged in) — read-only summary. Editing happens
<div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3"> through the gear button next to the title. -->
<Avatar class="h-12 w-12"> <div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4">
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" /> <div class="flex items-center gap-3 min-w-0">
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback> <Avatar class="h-12 w-12 shrink-0">
</Avatar> <AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
<div class="min-w-0 flex-1"> <AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p> </Avatar>
<p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p> <div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
<p v-if="displayName && user?.username" class="text-xs text-muted-foreground truncate">
@{{ user.username }}
</p>
</div>
</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">
<a <a
@ -69,7 +205,7 @@ function goLogin() {
<PreferencesRow layout="list" /> <PreferencesRow layout="list" />
</div> </div>
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings --> <!-- Logged-out: prominent log-in CTA -->
<div v-if="!isAuthenticated" class="mt-6"> <div v-if="!isAuthenticated" class="mt-6">
<Separator class="mb-4" /> <Separator class="mb-4" />
<Button class="w-full" @click="goLogin"> <Button class="w-full" @click="goLogin">
@ -78,9 +214,49 @@ function goLogin() {
</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 class="mt-6">
<Separator class="mb-4" /> <Separator class="mb-4" />
<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>
<!-- Edit-profile popup (gear icon) — the full form lives here so the
sheet stays scannable. -->
<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>

View file

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

View 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 max-w-full overflow-y-auto overflow-x-hidden">
<ProfileSheetContent>
<template v-if="props.items.length" #app-nav>
<nav class="mt-4 space-y-1">
<button
v-for="item in props.items"
:key="item.name"
type="button"
:class="[
item.isActive?.()
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'group flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
]"
@click="handleClick(item)"
>
<component :is="item.icon" class="h-5 w-5 shrink-0" />
{{ item.name }}
</button>
</nav>
<Separator class="mt-4" />
</template>
</ProfileSheetContent>
</SheetContent>
</Sheet>
</template>

View file

@ -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: {

View file

@ -3,7 +3,7 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { 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)
} }

View file

@ -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',
@ -98,9 +101,6 @@ 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',
@ -127,7 +127,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',

View file

@ -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',
@ -98,9 +101,6 @@ 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',

View file

@ -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',
@ -98,9 +101,6 @@ 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',
@ -127,7 +127,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',

View file

@ -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,13 +70,12 @@ 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: {
getTicket: string getTicket: string
going: string
maybe: string
notGoing: string
contactOrganizer: string contactOrganizer: string
organizer: string organizer: string
location: string location: string

View file

@ -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,33 +8,38 @@
<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. -->
<ImageUpload <div class="flex-1 min-w-0 w-full">
v-model="uploadedPicture" <ImageUpload
:multiple="false" v-model="uploadedPicture"
:max-files="1" :multiple="false"
:max-size-mb="5" :max-files="1"
:disabled="isUpdating" :max-size-mb="5"
:allow-camera="true" :disabled="isUpdating"
:compress="{ maxWidthOrHeight: 512, maxSizeMB: 0.2 }" :allow-camera="true"
placeholder="Upload picture" :compress="{ maxWidthOrHeight: 512, maxSizeMB: 0.2 }"
accept="image/*" placeholder="Upload picture"
/> accept="image/*"
/>
</div>
</div> </div>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -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>

View file

@ -432,7 +432,7 @@ const handleOpenChange = (open: boolean) => {
<template> <template>
<Dialog :open="open" @update:open="handleOpenChange"> <Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="max-w-lg max-h-[90vh] p-0"> <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 +611,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 +628,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 +639,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>
@ -739,12 +743,26 @@ const handleOpenChange = (open: boolean) => {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<!-- Actions --> <!-- Actions. Stack full-width on narrow viewports so a wide
<div class="flex justify-end gap-3 pt-2"> localized "Submit Event" label can't push the Cancel
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading"> 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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
@ -34,5 +37,19 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
> >
{{ 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="rounded-full text-xs shrink-0 gap-1.5"
@click="emit('toggle-past')"
>
<History class="w-3 h-3" />
{{ t('events.filters.past', 'Past') }}
</Button>
</div> </div>
</template> </template>

View file

@ -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,

View file

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

View file

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

View file

@ -12,10 +12,8 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useEventDetail } from '../composables/useEventDetail' import { useEventDetail } from '../composables/useEventDetail'
import BookmarkButton from '../components/BookmarkButton.vue' import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue' import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue' import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useEventsStore } from '../stores/events' import { useEventsStore } from '../stores/events'
import { useOwnedTickets } from '../composables/useOwnedTickets' import { useOwnedTickets } from '../composables/useOwnedTickets'
@ -170,41 +168,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 +204,115 @@ 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>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground"> <div class="flex items-start justify-between gap-3">
{{ event.title }} <h1 class="text-2xl sm:text-3xl font-bold text-foreground">
</h1> {{ event.title }}
</h1>
<div class="flex items-center gap-1 shrink-0 mt-1">
<Button
v-if="ownedLnbitsEvent"
variant="default"
size="icon"
class="h-8 w-8"
:aria-label="t('events.detail.editEvent', 'Edit event')"
@click="openEditDialog"
>
<Pencil class="w-4 h-4" />
</Button>
<!-- Hosts don't need to favorite their own event — the
"Yours" badge already marks it, and the bookmark
affordance is meant for discovery, not management. -->
<BookmarkButton
v-else
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
/>
</div>
</div>
<p v-if="event.summary" class="text-muted-foreground mt-2"> <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>
<!-- Where -->
<div v-if="event.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<MapPin class="w-4 h-4" />
{{ t('events.detail.location') }}
</div>
<p class="text-sm text-muted-foreground">{{ event.location }}</p>
</div>
</div> </div>
<!-- RSVP --> <!-- Host's primary CTA is to scan tickets at the door. Lives
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind OUTSIDE the ticketInfo gate so it appears even when the
(31922 for date-based, 31923 for time-based). Without this prop the event was published without AIO ticket tags — a host always
button would default to time-based for every event, leaving RSVPs gets to scan attempts. Stays available for past events too
on date-based events pointing at a non-existent event coord. --> so the host can still verify attendance after the fact. -->
<RSVPButton <Button
:pubkey="event.organizer.pubkey" v-if="ownedLnbitsEvent"
:d-tag="event.id" class="w-full gap-1.5"
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT" 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 +325,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 +342,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 +366,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 +380,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>

View file

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

View file

@ -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 { SlidersHorizontal, CalendarDays, Plus } from 'lucide-vue-next'
import brandAppLogoUrl from '@brand-app-logo?url'
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,27 @@ 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 (categories) are currently active even when the
// collapsible is closed. Past lives on the temporal strip now and
// has its own visible pressed state, so it doesn't need to count here.
const filterCount = computed(
() => selectedCategories.value.length,
)
onMounted(() => { onMounted(() => {
subscribe() subscribe()
}) })
@ -54,85 +59,114 @@ 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 — brand-kit logo (per-standalone override or
<div class="mb-4"> global) paired with the standalone's localized name. Resolved
<h1 class="text-2xl sm:text-3xl font-bold text-foreground"> at build time via @brand-app-logo so deployers can override
{{ t('events.title') }} without touching this component. -->
</h1> <h1 class="mb-3 flex items-center gap-2 text-xl sm:text-2xl font-bold text-foreground">
</div> <img
:src="brandAppLogoUrl"
:alt="t('events.title')"
class="h-7 w-7 sm:h-8 sm:w-8 shrink-0"
/>
{{ t('events.title') }}
</h1>
<!-- 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.
</div> Hidden in the Hosting view — operators don't need calendar
navigation when they're managing their own roster. -->
<!-- Temporal filter pills --> <div v-if="!onlyHosting" class="mb-3 flex items-center gap-2">
<div class="mb-4"> <DatePickerStrip
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" /> class="flex-1 min-w-0"
</div> :selected-date="selectedDate"
@select="selectDate"
<!-- Role + past-events filter chips. The role chips ("My tickets", />
"Hosting") narrow the feed to events the signed-in user
has skin in and are hidden when logged out. The "Past events"
chip is always visible since past-browsing doesn't require an
account. -->
<div class="mb-4 flex flex-wrap gap-2">
<template v-if="isAuthenticated">
<Button
:variant="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="ghost"
size="sm" size="icon"
class="gap-1.5" class="h-8 w-8 shrink-0"
@click="togglePast" :aria-label="t('events.nav.calendar')"
@click="openCalendar"
> >
<History class="w-3.5 h-3.5" /> <CalendarDays class="h-4 w-4" />
{{ t('events.filters.pastEvents', 'Past events') }}
</Button> </Button>
</div> </div>
<!-- Category filters (collapsible) --> <!-- Filters trigger + Clear-all stay stationary in a left-aligned
<Collapsible v-model:open="filtersOpen" class="mb-6"> column; only the temporal pills scroll horizontally. The
<CollapsibleTrigger as-child> Filters icon (with a count badge when categories are active)
<Button variant="ghost" size="sm" class="gap-1.5 text-muted-foreground"> opens a collapsible that hosts category chips below. Past is
<SlidersHorizontal class="w-4 h-4" /> a pill at the end of the temporal strip and doesn't live in
Categories the dropdown anymore. Hidden in the Hosting view — the
<span v-if="selectedCategories.length > 0" class="text-xs bg-primary text-primary-foreground rounded-full px-1.5"> operator's roster doesn't need them. -->
{{ selectedCategories.length }} <Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
</span> <div class="flex items-start gap-3">
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': filtersOpen }" /> <div class="shrink-0 flex flex-col items-center gap-0.5">
</Button> <CollapsibleTrigger as-child>
</CollapsibleTrigger> <Button
<CollapsibleContent class="mt-2"> variant="ghost"
size="icon"
class="rounded-full h-8 w-8 relative"
:class="{ 'bg-accent text-accent-foreground': filtersOpen || filterCount > 0 }"
:aria-label="t('events.filters.filters', 'Filters')"
:aria-expanded="filtersOpen"
>
<SlidersHorizontal class="w-4 h-4" />
<span
v-if="filterCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] h-[16px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold flex items-center justify-center"
>
{{ filterCount }}
</span>
</Button>
</CollapsibleTrigger>
<Button
v-if="hasActiveFilters"
variant="ghost"
size="sm"
class="h-5 px-1 text-[10px] text-muted-foreground"
@click="resetFilters"
>
{{ t('events.filters.clearAll', 'Clear all') }}
</Button>
</div>
<div class="flex-1 min-w-0 pt-0.5">
<TemporalFilterBar
:model-value="temporal"
:show-past="showPast"
@update:model-value="setTemporal"
@toggle-past="togglePast"
/>
</div>
</div>
<CollapsibleContent class="mt-3">
<CategoryFilterBar <CategoryFilterBar
:selected="selectedCategories" :selected="selectedCategories"
@toggle="toggleCategory" @toggle="toggleCategory"
@ -141,23 +175,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
</Button> v-if="onlyHosting"
</div> class="w-full mb-3 gap-1.5"
@click="openCreate"
>
<Plus class="w-4 h-4" />
{{ t('events.createNew') }}
</Button>
<!-- Error state --> <!-- Error state -->
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm"> <div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
{{ error }} {{ error }}
</div> </div>
<!-- Event feed --> <!-- 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>

View file

@ -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
<ScanLine class="w-4 h-4" /> share a gap and items-center alignment. TabsTrigger's
Scanner internal slot lives in an inline span, so a `gap-1.5`
on the trigger itself never reaches these two children. -->
<TabsTrigger value="scanner">
<span class="inline-flex items-center justify-center gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="list" class="gap-1.5"> <TabsTrigger value="list">
<Ticket class="w-4 h-4" /> <span class="inline-flex items-center justify-center gap-1.5">
Scanned ({{ registeredCount }}) <Ticket class="w-4 h-4" />
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>

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

@ -1,2 +1,11 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" /> /// <reference types="vite-plugin-pwa/client" />
// Brand-kit alias for the active standalone's logo. Resolved at build
// time by vite-branding.ts (per-standalone override or global). The
// `?url` import returns the asset's URL string just like any other
// vite static asset.
declare module '@brand-app-logo?url' {
const src: string
export default src
}

View file

@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs' import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { join, resolve } from 'node:path'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
/** /**
@ -43,6 +43,55 @@ export const brandAlias = {
'@brand': BRAND_DIR, '@brand': BRAND_DIR,
} as const } as const
/**
* Resolution order for the in-app logo of a given standalone. Mirrors
* what pwa-assets.config.ts does for PWA icons: per-standalone override
* first (SVG then PNG), then the brand's primary logo (SVG then PNG).
*
* Returned path is absolute so vite alias can map directly to it.
*/
export function resolveAppLogo(app?: string): string {
const candidates: string[] = []
if (app) {
candidates.push(
join(BRAND_DIR, 'icons', app, 'logo.svg'),
join(BRAND_DIR, 'icons', app, 'logo.png'),
)
}
candidates.push(
join(BRAND_DIR, 'logo.svg'),
join(BRAND_DIR, 'logo.png'),
)
const found = candidates.find((p) => existsSync(p))
if (!found) {
throw new Error(
`No brand logo found for app="${app ?? ''}". Tried:\n ${candidates.join('\n ')}\n` +
`See branding/README.md for the brand kit contract.`,
)
}
return found
}
/**
* Standalone-aware brand-logo alias entry. Append to a vite config's
* `resolve.alias` array alongside the rest of the alias map. The
* regex matches `@brand-app-logo` with or without a `?url` query so
* `import logoUrl from '@brand-app-logo?url'` resolves to the active
* standalone's logo file (per-app override or global), with no
* fallback chain in the component itself.
*
* Note: when used with the object form of resolve.alias, a bare
* `@brand` entry would shadow this — combine the two as an array
* (see vite.events.config.ts).
*/
export function brandAppLogoAliasEntry(app?: string) {
const resolved = resolveAppLogo(app)
return {
find: /^@brand-app-logo(\?.*)?$/,
replacement: `${resolved}$1`,
} as const
}
/** /**
* PWA manifest name for a standalone. Combines the brand name with the * PWA manifest name for a standalone. Combines the brand name with the
* app's own label, or returns the bare brand when no label is given. * app's own label, or returns the bare brand when no label is given.

View file

@ -5,7 +5,13 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAssetsPlugin, brandManifestName } from './vite-branding' import {
brand,
brandAlias,
brandAppLogoAliasEntry,
brandAssetsPlugin,
brandManifestName,
} from './vite-branding'
/** /**
* Plugin to rewrite dev server requests to events.html * Plugin to rewrite dev server requests to events.html
@ -117,10 +123,14 @@ export default defineConfig(({ mode }) => ({
}), }),
], ],
resolve: { resolve: {
alias: { // Array form so we can mix the per-standalone logo regex (needs to
...brandAlias, // match `@brand-app-logo?url` query suffix) with the bare string
'@': fileURLToPath(new URL('./src', import.meta.url)), // aliases without one shadowing the other.
}, alias: [
brandAppLogoAliasEntry('events'),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
}, },
build: { build: {
outDir: 'dist-events', outDir: 'dist-events',