Compare commits
14 commits
9d98f3fdf2
...
8339cd1272
| Author | SHA1 | Date | |
|---|---|---|---|
| 8339cd1272 | |||
| a6dee29922 | |||
| ccaaa6a6c5 | |||
| 9934abc079 | |||
| 17a3df7865 | |||
| 11db592041 | |||
| 014964b6c2 | |||
| e3ae4109ed | |||
| 8d30556b2c | |||
| 8b03b89b56 | |||
| 8d4f75f158 | |||
| 5cd551fbbc | |||
| 95e7fc3925 | |||
| 50d6fbfc0e |
13 changed files with 31 additions and 99 deletions
|
|
@ -221,16 +221,3 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Disable enter/exit animations on reka-ui overlays (dialog, sheet,
|
|
||||||
* popover, dropdown, tooltip, …) app-wide. They animate via the
|
|
||||||
* data-state open/closed attribute; zeroing the duration keeps the final
|
|
||||||
* state but removes the motion (overlays appear/disappear instantly).
|
|
||||||
* Pulse/spin loaders and CSS transitions (e.g. hovers, the favourite
|
|
||||||
* heart pop) are unaffected.
|
|
||||||
*/
|
|
||||||
[data-state='open'],
|
|
||||||
[data-state='closed'] {
|
|
||||||
animation-duration: 0s !important;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Check, Copy, Home, LogIn, LogOut, Pencil, Zap } from 'lucide-vue-next'
|
import { Check, Copy, Home, LogIn, LogOut, Settings, Zap } from 'lucide-vue-next'
|
||||||
import { nip19 } from 'nostr-tools'
|
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'
|
||||||
|
|
@ -104,7 +104,19 @@ async function onLogout() {
|
||||||
|
|
||||||
<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>
|
||||||
|
|
@ -113,8 +125,8 @@ async function onLogout() {
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<!-- Identity card (logged in) — summary with an inline edit (pencil)
|
<!-- Identity card (logged in) — read-only summary. Editing happens
|
||||||
button that opens the profile form. -->
|
through the gear button next to the title. -->
|
||||||
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4">
|
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<Avatar class="h-12 w-12 shrink-0">
|
<Avatar class="h-12 w-12 shrink-0">
|
||||||
|
|
@ -127,15 +139,6 @@ async function onLogout() {
|
||||||
@{{ user.username }}
|
@{{ user.username }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0 self-start text-muted-foreground"
|
|
||||||
:aria-label="t('common.nav.editProfile', 'Edit profile')"
|
|
||||||
@click="editProfileOpen = true"
|
|
||||||
>
|
|
||||||
<Pencil class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Identifier rows: full-width value with a corner-offset "legend"
|
<!-- Identifier rows: full-width value with a corner-offset "legend"
|
||||||
|
|
@ -243,8 +246,8 @@ async function onLogout() {
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit-profile popup (pencil button in the identity card) — the full
|
<!-- Edit-profile popup (gear icon) — the full form lives here so the
|
||||||
form lives here so the sheet stays scannable. -->
|
sheet stays scannable. -->
|
||||||
<Dialog v-model:open="editProfileOpen">
|
<Dialog v-model:open="editProfileOpen">
|
||||||
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
|
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,13 @@
|
||||||
import { ref, type Component } from 'vue'
|
import { ref, type Component } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { LogIn } from 'lucide-vue-next'
|
import { Menu } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
|
||||||
import ProfileSheetContent from './ProfileSheetContent.vue'
|
import ProfileSheetContent from './ProfileSheetContent.vue'
|
||||||
|
|
||||||
export interface SidebarNavItem {
|
export interface SidebarNavItem {
|
||||||
|
|
@ -38,9 +35,6 @@ const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
|
||||||
const { isAuthenticated } = useAuth()
|
|
||||||
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
|
||||||
|
|
||||||
function handleClick(item: SidebarNavItem) {
|
function handleClick(item: SidebarNavItem) {
|
||||||
if (item.path) router.push(item.path)
|
if (item.path) router.push(item.path)
|
||||||
item.onClick?.()
|
item.onClick?.()
|
||||||
|
|
@ -52,17 +46,11 @@ function handleClick(item: SidebarNavItem) {
|
||||||
<Sheet v-model:open="open">
|
<Sheet v-model:open="open">
|
||||||
<SheetTrigger as-child>
|
<SheetTrigger as-child>
|
||||||
<button
|
<button
|
||||||
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center overflow-hidden rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
|
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)"
|
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
|
||||||
:aria-label="isAuthenticated ? t('common.nav.profile') : t('common.nav.login')"
|
:aria-label="t('common.nav.menu')"
|
||||||
>
|
>
|
||||||
<!-- Logged in: avatar (image, or first initial). Logged out: a
|
<Menu class="w-5 h-5" />
|
||||||
login icon. Opens the same profile/menu sheet either way. -->
|
|
||||||
<Avatar v-if="isAuthenticated" class="h-full w-full">
|
|
||||||
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
|
||||||
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<LogIn v-else class="w-5 h-5" />
|
|
||||||
</button>
|
</button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="right" class="w-80 sm:w-96 max-w-full overflow-y-auto overflow-x-hidden">
|
<SheetContent side="right" class="w-80 sm:w-96 max-w-full overflow-y-auto overflow-x-hidden">
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,6 @@ const messages: LocaleMessages = {
|
||||||
when: 'When',
|
when: 'When',
|
||||||
tickets: 'Tickets',
|
tickets: 'Tickets',
|
||||||
ticketsAvailable: '{count} tickets available',
|
ticketsAvailable: '{count} tickets available',
|
||||||
ticketsRemainingOfTotal: '{count} of {total} tickets left',
|
|
||||||
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
||||||
unlimitedTickets: 'Unlimited tickets',
|
unlimitedTickets: 'Unlimited tickets',
|
||||||
buyTicket: 'Buy ticket',
|
buyTicket: 'Buy ticket',
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,6 @@ const messages: LocaleMessages = {
|
||||||
when: 'Cuándo',
|
when: 'Cuándo',
|
||||||
tickets: 'Boletos',
|
tickets: 'Boletos',
|
||||||
ticketsAvailable: '{count} boletos disponibles',
|
ticketsAvailable: '{count} boletos disponibles',
|
||||||
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
|
|
||||||
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
||||||
unlimitedTickets: 'Boletos ilimitados',
|
unlimitedTickets: 'Boletos ilimitados',
|
||||||
buyTicket: 'Comprar boleto',
|
buyTicket: 'Comprar boleto',
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,6 @@ const messages: LocaleMessages = {
|
||||||
when: 'Quand',
|
when: 'Quand',
|
||||||
tickets: 'Billets',
|
tickets: 'Billets',
|
||||||
ticketsAvailable: '{count} billets disponibles',
|
ticketsAvailable: '{count} billets disponibles',
|
||||||
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
|
|
||||||
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
||||||
unlimitedTickets: 'Billets illimités',
|
unlimitedTickets: 'Billets illimités',
|
||||||
buyTicket: 'Acheter un billet',
|
buyTicket: 'Acheter un billet',
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,6 @@ export interface LocaleMessages {
|
||||||
when: string
|
when: string
|
||||||
tickets: string
|
tickets: string
|
||||||
ticketsAvailable: string
|
ticketsAvailable: string
|
||||||
ticketsRemainingOfTotal: string
|
|
||||||
ticketsOwned: string
|
ticketsOwned: string
|
||||||
unlimitedTickets: string
|
unlimitedTickets: string
|
||||||
buyTicket: string
|
buyTicket: string
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ const isNonApproved = computed(
|
||||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="event.ticketInfo.available > 0">
|
<span v-else-if="event.ticketInfo.available > 0">
|
||||||
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-destructive font-medium">
|
<span v-else class="text-destructive font-medium">
|
||||||
{{ t('events.detail.soldOut') }}
|
{{ t('events.detail.soldOut') }}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,8 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||||
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { ProfileService } from '@/modules/base/nostr/ProfileService'
|
|
||||||
import type { Event } from '../types/event'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
/** Event enriched with its resolved organizer display name for search. */
|
|
||||||
type SearchableEvent = Event & { organizerName: string }
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
events: Event[]
|
events: Event[]
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -27,13 +22,12 @@ const { dateLocale } = useDateLocale()
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const searchOptions: FuzzySearchOptions<SearchableEvent> = {
|
const searchOptions: FuzzySearchOptions<Event> = {
|
||||||
fuseOptions: {
|
fuseOptions: {
|
||||||
keys: [
|
keys: [
|
||||||
{ name: 'title', weight: 0.5 },
|
{ name: 'title', weight: 0.5 },
|
||||||
{ name: 'summary', weight: 0.2 },
|
{ name: 'summary', weight: 0.2 },
|
||||||
{ name: 'description', weight: 0.15 },
|
{ name: 'description', weight: 0.15 },
|
||||||
{ name: 'organizerName', weight: 0.1 },
|
|
||||||
{ name: 'location', weight: 0.1 },
|
{ name: 'location', weight: 0.1 },
|
||||||
{ name: 'tags', weight: 0.05 },
|
{ name: 'tags', weight: 0.05 },
|
||||||
],
|
],
|
||||||
|
|
@ -45,20 +39,7 @@ const searchOptions: FuzzySearchOptions<SearchableEvent> = {
|
||||||
resultLimit: 8,
|
resultLimit: 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Organizer display names aren't stored on the event (they're fetched
|
const eventsRef = computed(() => props.events)
|
||||||
// per-pubkey into the shared ProfileService cache). Read the resolved
|
|
||||||
// name from that same reactive cache so search matches it; the corpus
|
|
||||||
// recomputes as kind-0 metadata lands.
|
|
||||||
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
|
||||||
|
|
||||||
function organizerNameFor(pubkey: string): string {
|
|
||||||
const p = profileService?.profiles.get(pubkey)
|
|
||||||
return p?.display_name ?? p?.name ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchCorpus = computed<SearchableEvent[]>(() =>
|
|
||||||
props.events.map((e) => ({ ...e, organizerName: organizerNameFor(e.organizer.pubkey) })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
|
@ -66,7 +47,7 @@ const {
|
||||||
isSearching,
|
isSearching,
|
||||||
clearSearch,
|
clearSearch,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
} = useFuzzySearch(searchCorpus, searchOptions)
|
} = useFuzzySearch(eventsRef, searchOptions)
|
||||||
|
|
||||||
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
||||||
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
||||||
|
|
@ -113,18 +94,6 @@ function handleClickOutside(e: MouseEvent) {
|
||||||
watch(isOpen, (open) => {
|
watch(isOpen, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
// Warm the shared profile cache for every organizer in the current
|
|
||||||
// set so their names become searchable (fetches dedupe in the
|
|
||||||
// service; the corpus reacts as kind-0 metadata arrives).
|
|
||||||
if (profileService) {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const e of props.events) {
|
|
||||||
const pk = e.organizer.pubkey
|
|
||||||
if (seen.has(pk) || profileService.profiles.get(pk)) continue
|
|
||||||
seen.add(pk)
|
|
||||||
void profileService.getProfile(pk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,13 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- pb-1 pr-1 keep the theme's offset drop-shadow (neobrut casts a hard 4px
|
<div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
|
||||||
shadow down and to the right) from being clipped at the scroll box's
|
|
||||||
bottom/right edges (overflow-x-auto forces overflow-y to auto). -->
|
|
||||||
<div class="flex gap-2 overflow-x-auto pb-1 pr-1" style="-ms-overflow-style: none; scrollbar-width: none;">
|
|
||||||
<Button
|
<Button
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="text-xs shrink-0"
|
class="rounded-full text-xs shrink-0"
|
||||||
@click="emit('update:modelValue', option.value)"
|
@click="emit('update:modelValue', option.value)"
|
||||||
>
|
>
|
||||||
{{ t(option.labelKey) }}
|
{{ t(option.labelKey) }}
|
||||||
|
|
@ -48,7 +45,7 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||||
<Button
|
<Button
|
||||||
:variant="props.showPast ? 'default' : 'outline'"
|
:variant="props.showPast ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="text-xs shrink-0 gap-1.5"
|
class="rounded-full text-xs shrink-0 gap-1.5"
|
||||||
@click="emit('toggle-past')"
|
@click="emit('toggle-past')"
|
||||||
>
|
>
|
||||||
<History class="w-3 h-3" />
|
<History class="w-3 h-3" />
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,6 @@ export interface EventTicketInfo {
|
||||||
available?: number
|
available?: number
|
||||||
/** Running paid count. */
|
/** Running paid count. */
|
||||||
sold: number
|
sold: number
|
||||||
/** Total capacity (available + sold). Undefined means unlimited. */
|
|
||||||
total?: number
|
|
||||||
/** Whether the organizer enabled fiat checkout. */
|
/** Whether the organizer enabled fiat checkout. */
|
||||||
allowFiat: boolean
|
allowFiat: boolean
|
||||||
/** Fiat settle currency when allowFiat is true. */
|
/** Fiat settle currency when allowFiat is true. */
|
||||||
|
|
@ -93,9 +91,6 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
|
||||||
currency: ticket.currency,
|
currency: ticket.currency,
|
||||||
available: ticket.available,
|
available: ticket.available,
|
||||||
sold: ticket.sold,
|
sold: ticket.sold,
|
||||||
// Capacity isn't published directly; derive it from remaining + sold.
|
|
||||||
// Undefined `available` means unlimited, so total stays undefined too.
|
|
||||||
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
|
|
||||||
allowFiat: ticket.allowFiat,
|
allowFiat: ticket.allowFiat,
|
||||||
fiatCurrency: ticket.fiatCurrency,
|
fiatCurrency: ticket.fiatCurrency,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ function goToMyTickets() {
|
||||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,7 @@ onMounted(() => {
|
||||||
|
|
||||||
<!-- No geotagged events -->
|
<!-- No geotagged events -->
|
||||||
<div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
|
<div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
|
||||||
<!-- opacity-30 on the element (not /30 on the colour) so the icon's
|
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||||
overlapping fold strokes fade uniformly instead of compounding
|
|
||||||
alpha where they overlap. -->
|
|
||||||
<Map class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
|
||||||
<p class="text-muted-foreground">No geotagged events found</p>
|
<p class="text-muted-foreground">No geotagged events found</p>
|
||||||
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
|
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue