diff --git a/flake.nix b/flake.nix index 588438c..14859ec 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ inherit (pkgs.stdenv.hostPlatform) system; }; - mkWebapp = { pkgs, brandDir ? ./branding/default, app ? "main" }: + mkWebapp = { pkgs, brandDir ? ./branding/default, app ? "main", extraEnv ? {} }: let buildScript = if app == "main" then "build" else "build:${app}"; outDir = if app == "main" then "dist" else "dist-${app}"; @@ -59,13 +59,19 @@ # pwa-assets.config.ts. brandDir is either ./branding/default # (a path inside this flake's source) or an external path that # nix has copied into the build sandbox. + # + # `extraEnv` flows in VITE_* and any other build-time env vars + # the caller wants to bake into the bundle (e.g. webapp-module + # passes VITE_NOSTR_RELAYS / VITE_LNBITS_BASE_URL / …; the + # server-deploy standalones module passes VITE_BASE_PATH + + # VITE_APP_NAME for per-app path mounts). env = { BRAND_DIR = "${brandDir}"; BRAND_APP = if app == "main" then "" else app; # Avoid pnpm 10's interactive modules-purge prompt in the # sandbox (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY). CI = "true"; - }; + } // extraEnv; buildPhase = '' runHook preBuild diff --git a/src/components/layout/AppShell.vue b/src/components/layout/AppShell.vue index c59b54c..20affdd 100644 --- a/src/components/layout/AppShell.vue +++ b/src/components/layout/AppShell.vue @@ -4,24 +4,23 @@ import { useRoute } from 'vue-router' import { Toaster } from '@/components/ui/sonner' import { useTheme } from '@/components/theme-provider' import BottomNav, { type BottomTab } from './BottomNav.vue' -import HubPill from './HubPill.vue' +import StandaloneMenu, { type SidebarNavItem } from './StandaloneMenu.vue' interface Props { /** App-specific tabs displayed before the constant Profile entry. */ tabs: BottomTab[] /** Active-tab matcher. Forwarded to BottomNav. */ isActive: (path: string) => boolean - /** Hide the top-right HubPill — only true when this shell is rendering - * the hub itself. Standalones leave this false (default). */ + /** Hide the top-right standalone menu — only true when this shell is + * rendering the hub itself. Standalones leave this false (default). */ hideHub?: boolean - /** Forwarded to BottomNav. Hub passes true so logged-out users can still - * reach prefs from the sheet. Standalones leave it false. */ - loggedOutOpensSheet?: boolean + /** App-specific nav items rendered at the top of the standalone menu. */ + sidebarNav?: SidebarNavItem[] } const props = withDefaults(defineProps(), { hideHub: false, - loggedOutOpensSheet: false, + sidebarNav: () => [], }) const route = useRoute() @@ -45,11 +44,13 @@ const isLoginPage = computed(() => route.path === '/login') v-if="!isLoginPage" :tabs="props.tabs" :is-active="props.isActive" - :logged-out-opens-sheet="props.loggedOutOpensSheet" /> - + - diff --git a/src/components/layout/HubPill.vue b/src/components/layout/HubPill.vue deleted file mode 100644 index dbe4f5a..0000000 --- a/src/components/layout/HubPill.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/src/components/layout/ProfileSheetContent.vue b/src/components/layout/ProfileSheetContent.vue index d08735a..26667b1 100644 --- a/src/components/layout/ProfileSheetContent.vue +++ b/src/components/layout/ProfileSheetContent.vue @@ -53,6 +53,9 @@ function goLogin() { + + +
+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(), { 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 +} + + + diff --git a/src/components/ui/avatar/index.ts b/src/components/ui/avatar/index.ts index 5367952..0cc8926 100644 --- a/src/components/ui/avatar/index.ts +++ b/src/components/ui/avatar/index.ts @@ -5,7 +5,7 @@ export { default as AvatarFallback } from './AvatarFallback.vue' export { default as AvatarImage } from './AvatarImage.vue' export const avatarVariant = cva( - 'inline-flex items-center justify-center font-normal text-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: { size: { diff --git a/src/events-app/App.vue b/src/events-app/App.vue index 2540dad..ee16fb1 100644 --- a/src/events-app/App.vue +++ b/src/events-app/App.vue @@ -3,7 +3,7 @@ import { computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' -import { 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 type { BottomTab } from '@/components/layout/BottomNav.vue' import { useAuth } from '@/composables/useAuthService' @@ -23,36 +23,71 @@ const eventsStore = useEventsStore() const { isAdmin, autoApprove } = useApprovalState() // Used to merge own LNbits drafts into the events feed right after // the user creates or edits an event — otherwise the new draft only -// surfaces on the next EventsPage subscribe cycle. -const { loadOwnEvents } = useEvents() +// surfaces on the next EventsPage subscribe cycle. `onlyHosting` +// is the feed filter that backs the Hosting bottom-nav tab — tapping +// it toggles the filter on; Home tab toggles it off. +const { loadOwnEvents, onlyHosting, toggleHosting } = useEvents() + +// True for /events and its sub-routes (incl. detail pages) but +// not for the routes owned by other tabs (map/favorites). Used by +// both Home and Hosting active-state predicates so the highlight +// only shifts based on the onlyHosting flag while you're in the feed. +function inFeedRoute(): boolean { + if (route.path.startsWith('/events/map')) return false + if (route.path.startsWith('/events/favorites')) return false + return route.path === '/events' || route.path.startsWith('/events/') +} -// 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(() => [ - { name: t('events.nav.feed'), icon: Search, path: '/events' }, - { name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' }, { - name: t('events.createNew'), - icon: Plus, + name: t('events.nav.feed'), + icon: Home, + onClick: () => { + // Tapping Home clears the hosting filter so the feed always + // returns to the unfiltered view, regardless of where the + // user just came from. + if (onlyHosting.value) toggleHosting() + if (route.path !== '/events') router.push('/events') + }, + isActive: () => inFeedRoute() && !onlyHosting.value, + }, + { + name: t('events.filters.myTickets'), + icon: Ticket, + path: '/my-tickets', onClick: () => { if (!isAuthenticated.value) { - toast.info('Log in to create an event', { + toast.info(t('events.detail.loginToBuyTickets'), { action: { - label: 'Log in', + label: t('events.detail.logIn'), onClick: () => router.push('/login'), }, }) return } - // Defensively clear any lingering edit selection so the Create - // tap always opens in Create mode regardless of a prior Edit. - eventsStore.editingEvent = null - eventsStore.showCreateDialog = true + router.push('/my-tickets') }, disabled: !isAuthenticated.value, }, + { + name: t('events.filters.hosting'), + icon: Megaphone, + onClick: () => { + if (!isAuthenticated.value) { + toast.info(t('events.hosting.loginPrompt', 'Log in to manage your hosted events'), { + action: { + label: t('events.favorites.logIn'), + onClick: () => router.push('/login'), + }, + }) + return + } + if (!onlyHosting.value) toggleHosting() + if (route.path !== '/events') router.push('/events') + }, + isActive: () => inFeedRoute() && onlyHosting.value, + disabled: !isAuthenticated.value, + }, { name: t('events.nav.map'), icon: Map, path: '/events/map' }, { name: t('events.nav.favorites'), @@ -77,18 +112,8 @@ const tabs = computed(() => [ }, ]) -// Feed tab is active for the bare /events route AND all sub-paths that -// aren't owned by another tab (e.g. /events/ detail pages). +// Path-based fallback for tabs that don't carry their own `isActive`. 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) } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 52fc2e0..751e146 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -22,6 +22,7 @@ const messages: LocaleMessages = { profileDescription: 'Your Nostr identity and display name.', profileLoggedOutDescription: 'Sign in or change your preferences.', login: 'Log in', + menu: 'Menu', backToHub: 'Back to hub', hub: 'Hub', theme: 'Theme', @@ -68,6 +69,8 @@ const messages: LocaleMessages = { hosting: 'Hosting', pastEvents: 'Past events', past: 'Past', + filters: 'Filters', + clearAll: 'Clear all', }, categories: { concert: 'Concert', @@ -127,7 +130,7 @@ const messages: LocaleMessages = { registered: 'Registered', }, nav: { - feed: 'Feed', + feed: 'Home', calendar: 'Calendar', map: 'Map', favorites: 'Favorites', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 9e5407d..edc5d82 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -22,6 +22,7 @@ const messages: LocaleMessages = { profileDescription: 'Tu identidad Nostr y nombre de visualización.', profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.', login: 'Iniciar sesión', + menu: 'Menú', backToHub: 'Volver al hub', hub: 'Hub', theme: 'Tema', @@ -68,6 +69,8 @@ const messages: LocaleMessages = { hosting: 'Organizo', pastEvents: 'Eventos pasados', past: 'Pasado', + filters: 'Filtros', + clearAll: 'Limpiar todo', }, categories: { concert: 'Concierto', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 4c9a4f1..f42cc4b 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -22,6 +22,7 @@ const messages: LocaleMessages = { profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.', profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.', login: 'Se connecter', + menu: 'Menu', backToHub: 'Retour au hub', hub: 'Hub', theme: 'Thème', @@ -68,6 +69,8 @@ const messages: LocaleMessages = { hosting: 'J\'organise', pastEvents: 'Événements passés', past: 'Passé', + filters: 'Filtres', + clearAll: 'Tout effacer', }, categories: { concert: 'Concert', @@ -127,7 +130,7 @@ const messages: LocaleMessages = { registered: 'Enregistré', }, nav: { - feed: 'Fil', + feed: 'Accueil', calendar: 'Calendrier', map: 'Carte', favorites: 'Favoris', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 81ef71e..3aebdf0 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -21,6 +21,7 @@ export interface LocaleMessages { profileDescription: string profileLoggedOutDescription: string login: string + menu: string backToHub: string hub: string theme: string @@ -69,6 +70,8 @@ export interface LocaleMessages { hosting: string pastEvents: string past: string + filters: string + clearAll: string } categories: Record detail: { diff --git a/src/modules/events/components/EventCard.vue b/src/modules/events/components/EventCard.vue index 694df6a..b26976d 100644 --- a/src/modules/events/components/EventCard.vue +++ b/src/modules/events/components/EventCard.vue @@ -12,6 +12,10 @@ import type { Event } from '../types/event' const props = defineProps<{ event: Event + /** Render a compact row: no hero image, no summary, single-line + * title, tighter padding. Used by the Hosting view where the + * host already knows what their events look like. */ + compact?: boolean }>() const emit = defineEmits<{ @@ -52,42 +56,58 @@ const priceDisplay = computed(() => { return `${info.price} ${info.currency}` }) -const placeholderBg = computed(() => { - // Generate a consistent hue from the event title - const hash = props.event.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) - const hue = hash % 360 - return `hsl(${hue}, 40%, 85%)` -}) - const isPast = computed(() => { const a = props.event const end = a.endDate ?? a.startDate if (!end || isNaN(end.getTime())) return false return end.getTime() < Date.now() }) + +// Pending / rejected events get a washed-out look so the user +// sees at a glance the event isn't live, not just the small badge. +const isNonApproved = computed( + () => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved', +) diff --git a/src/modules/events/components/EventList.vue b/src/modules/events/components/EventList.vue index 8a8ab65..cffb3c5 100644 --- a/src/modules/events/components/EventList.vue +++ b/src/modules/events/components/EventList.vue @@ -7,6 +7,10 @@ import type { Event } from '../types/event' defineProps<{ events: Event[] isLoading?: boolean + /** Render compact rows instead of full-image cards. Used by the + * Hosting view so an operator can scan their roster of events + * without the visual weight of hero images they already recognize. */ + compact?: boolean }>() const emit = defineEmits<{ @@ -39,20 +43,24 @@ const { t } = useI18n() class="flex flex-col items-center justify-center py-16 text-center" > -

+

{{ t('events.noEvents') }}

-

- {{ t('events.search.noResults') }} -

- -
+ +
diff --git a/src/modules/events/components/PurchaseTicketDialog.vue b/src/modules/events/components/PurchaseTicketDialog.vue index b5b5d3f..6617496 100644 --- a/src/modules/events/components/PurchaseTicketDialog.vue +++ b/src/modules/events/components/PurchaseTicketDialog.vue @@ -443,7 +443,7 @@ onUnmounted(() => {
diff --git a/src/modules/events/composables/useEventFilters.ts b/src/modules/events/composables/useEventFilters.ts index 5f9379c..71ce1ff 100644 --- a/src/modules/events/composables/useEventFilters.ts +++ b/src/modules/events/composables/useEventFilters.ts @@ -8,35 +8,22 @@ import type { EventCategory } from '../types/category' import type { TemporalFilter, EventFilters } from '../types/filters' import { DEFAULT_FILTERS } from '../types/filters' +// Filter state is hoisted to module scope so every `useEvents()` / +// `useEventFilters()` call shares the same refs. The bottom-nav +// Hosting tab in events-app/App.vue and the feed view in +// EventsPage.vue both rely on this — without a shared instance, +// tapping Hosting toggled a private ref the page never saw. +const temporal = ref(DEFAULT_FILTERS.temporal) +const selectedCategories = ref([]) +const selectedDate = ref(undefined) +const onlyOwnedTickets = ref(false) +const onlyHosting = ref(false) +const showPast = ref(false) + /** * Composable for managing event filter state and applying filters reactively. */ export function useEventFilters() { - const temporal = ref(DEFAULT_FILTERS.temporal) - const selectedCategories = ref([]) - const selectedDate = ref(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(() => ({ temporal: temporal.value, diff --git a/src/modules/events/composables/useTicketScanner.ts b/src/modules/events/composables/useTicketScanner.ts index e8afc09..1afe819 100644 --- a/src/modules/events/composables/useTicketScanner.ts +++ b/src/modules/events/composables/useTicketScanner.ts @@ -188,6 +188,38 @@ export function useTicketScanner(eventId: Ref) { isPaused.value = false } + /** + * Mark a ticket as registered without going through the camera — + * used when the host knows the attendee in person or accepts an + * alternate proof of identity. Same backend endpoint as a scan + * (so it also gates on event ownership and rejects unpaid / + * already-registered tickets), but skips the scanner pause + + * full-screen banner since the operator initiated the action + * from the roster directly. Refreshes stats on success. + */ + async function registerManually( + ticketId: string, + ): Promise<{ ok: boolean; error?: string }> { + const adminKey = currentUser.value?.wallets?.[0]?.adminkey + if (!adminKey) return { ok: false, error: 'No wallet admin key available' } + try { + await ticketApi.registerTicket(ticketId, adminKey) + // Mirror the session-local dedup the scan path uses so a + // subsequent QR scan of the same ticket reports "Already + // scanned" instead of round-tripping a duplicate register. + if (!scanned.value.some(r => r.ticketId === ticketId)) { + scanned.value = [ + { ticketId, name: null, registeredAt: new Date().toISOString() }, + ...scanned.value, + ] + } + await refreshStats() + return { ok: true } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) } + } + } + function clearScanned() { scanned.value = [] lastScan.value = null @@ -210,5 +242,6 @@ export function useTicketScanner(eventId: Ref) { onDecode, resume, clearScanned, + registerManually, } } diff --git a/src/modules/events/views/EventDetailPage.vue b/src/modules/events/views/EventDetailPage.vue index 1839775..cf24c80 100644 --- a/src/modules/events/views/EventDetailPage.vue +++ b/src/modules/events/views/EventDetailPage.vue @@ -170,41 +170,14 @@ function goToMyTickets() { diff --git a/src/modules/events/views/EventsCalendarPage.vue b/src/modules/events/views/EventsCalendarPage.vue index 90d2549..3198976 100644 --- a/src/modules/events/views/EventsCalendarPage.vue +++ b/src/modules/events/views/EventsCalendarPage.vue @@ -1,12 +1,31 @@