Compare commits
3 commits
9b5f1273b3
...
63fc7b3ab8
| Author | SHA1 | Date | |
|---|---|---|---|
| 63fc7b3ab8 | |||
| 556b9e5cfe | |||
| dbc8b7abf4 |
6 changed files with 198 additions and 7 deletions
|
|
@ -7,6 +7,7 @@ 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'
|
||||||
import { useActivitiesStore } from '@/modules/activities/stores/activities'
|
import { useActivitiesStore } from '@/modules/activities/stores/activities'
|
||||||
|
import { useActivities } from '@/modules/activities/composables/useActivities'
|
||||||
import { useApprovalState } from '@/modules/activities/composables/useApprovalState'
|
import { useApprovalState } from '@/modules/activities/composables/useApprovalState'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
|
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
|
||||||
|
|
@ -18,6 +19,10 @@ const { t } = useI18n()
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
const activitiesStore = useActivitiesStore()
|
const activitiesStore = useActivitiesStore()
|
||||||
const { isAdmin, autoApprove } = useApprovalState()
|
const { isAdmin, autoApprove } = useApprovalState()
|
||||||
|
// Used to merge own LNbits drafts into the activities feed right after
|
||||||
|
// the user creates or edits an event — otherwise the new draft only
|
||||||
|
// surfaces on the next ActivitiesPage subscribe cycle.
|
||||||
|
const { loadOwnEvents } = useActivities()
|
||||||
|
|
||||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||||
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
|
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
|
||||||
|
|
@ -96,6 +101,8 @@ function handleDialogOpenChange(open: boolean) {
|
||||||
:on-create-event="handleCreateEvent"
|
:on-create-event="handleCreateEvent"
|
||||||
:on-update-event="handleUpdateEvent"
|
:on-update-event="handleUpdateEvent"
|
||||||
@update:open="handleDialogOpenChange"
|
@update:open="handleDialogOpenChange"
|
||||||
|
@event-created="loadOwnEvents"
|
||||||
|
@event-updated="loadOwnEvents"
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { MapPin, Calendar, Ticket } from 'lucide-vue-next'
|
import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next'
|
||||||
import BookmarkButton from './BookmarkButton.vue'
|
import BookmarkButton from './BookmarkButton.vue'
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Activity } from '../types/activity'
|
||||||
|
|
@ -87,6 +87,17 @@ const placeholderBg = computed(() => {
|
||||||
{{ categoryLabel }}
|
{{ categoryLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
|
<!-- Ownership badge — the creator can spot their own events at a
|
||||||
|
glance on the feed. -->
|
||||||
|
<Badge
|
||||||
|
v-if="activity.isMine"
|
||||||
|
variant="outline"
|
||||||
|
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
||||||
|
>
|
||||||
|
<User class="w-3 h-3" />
|
||||||
|
Yours
|
||||||
|
</Badge>
|
||||||
|
|
||||||
<!-- Price badge -->
|
<!-- Price badge -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="priceDisplay"
|
v-if="priceDisplay"
|
||||||
|
|
@ -95,6 +106,17 @@ const placeholderBg = computed(() => {
|
||||||
{{ priceDisplay }}
|
{{ priceDisplay }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
|
<!-- Pending/rejected overlay for the creator's own non-approved
|
||||||
|
drafts. Only present when the activity originated from a
|
||||||
|
local LNbits event (Nostr-sourced activities have no
|
||||||
|
lnbitsStatus). -->
|
||||||
|
<Badge
|
||||||
|
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
|
||||||
|
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||||
|
class="absolute bottom-2 left-2 text-xs capitalize"
|
||||||
|
>
|
||||||
|
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,12 @@ const emit = defineEmits<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isEditMode = computed(() => Boolean(props.event?.id))
|
const isEditMode = computed(() => Boolean(props.event?.id))
|
||||||
const willGoToPending = computed(
|
// True when the submission will land in `proposed` status: a non-admin
|
||||||
() => isEditMode.value && !props.isAdmin && !props.autoApprove
|
// owner with the extension's auto_approve toggle off. Same gate for
|
||||||
|
// create and edit; the edit path also keys the in-form warning banner
|
||||||
|
// on this so the user sees the consequence before submitting.
|
||||||
|
const willLandInPending = computed(
|
||||||
|
() => !props.isAdmin && !props.autoApprove
|
||||||
)
|
)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
@ -302,8 +306,8 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
}
|
}
|
||||||
await props.onUpdateEvent(props.event.id, eventData)
|
await props.onUpdateEvent(props.event.id, eventData)
|
||||||
toastService.success(
|
toastService.success(
|
||||||
willGoToPending.value
|
willLandInPending.value
|
||||||
? 'Event updated — pending re-approval'
|
? 'Updated — awaiting re-approval. Hidden from the public feed until reviewed.'
|
||||||
: 'Event updated!'
|
: 'Event updated!'
|
||||||
)
|
)
|
||||||
emit('event-updated')
|
emit('event-updated')
|
||||||
|
|
@ -313,7 +317,11 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await props.onCreateEvent(eventData)
|
await props.onCreateEvent(eventData)
|
||||||
toastService.success('Event submitted!')
|
toastService.success(
|
||||||
|
willLandInPending.value
|
||||||
|
? 'Submitted! Awaiting admin approval — your draft is visible on your feed with a Pending badge.'
|
||||||
|
: 'Event submitted!'
|
||||||
|
)
|
||||||
emit('event-created')
|
emit('event-created')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,7 +371,7 @@ const handleOpenChange = (open: boolean) => {
|
||||||
|
|
||||||
<ScrollArea class="max-h-[70vh] px-6 pb-6">
|
<ScrollArea class="max-h-[70vh] px-6 pb-6">
|
||||||
<form @submit="onSubmit" class="space-y-4">
|
<form @submit="onSubmit" class="space-y-4">
|
||||||
<Alert v-if="willGoToPending" variant="default" class="border-orange-500/40 bg-orange-500/5">
|
<Alert v-if="isEditMode && willLandInPending" variant="default" class="border-orange-500/40 bg-orange-500/5">
|
||||||
<AlertCircle class="h-4 w-4 text-orange-500" />
|
<AlertCircle class="h-4 w-4 text-orange-500" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Saving will resubmit for approval. The event will be removed
|
Saving will resubmit for approval. The event will be removed
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { ref, computed, onUnmounted } from 'vue'
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
||||||
import type { CalendarEventFilters } from '../services/ActivitiesNostrService'
|
import type { CalendarEventFilters } from '../services/ActivitiesNostrService'
|
||||||
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
|
import type { TicketedEvent } from '../types/ticket'
|
||||||
|
import { ticketedEventToActivity } from '../types/activity'
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useActivitiesStore } from '../stores/activities'
|
||||||
import { useActivityFilters } from './useActivityFilters'
|
import { useActivityFilters } from './useActivityFilters'
|
||||||
|
|
||||||
|
|
@ -12,11 +16,55 @@ import { useActivityFilters } from './useActivityFilters'
|
||||||
export function useActivities() {
|
export function useActivities() {
|
||||||
const store = useActivitiesStore()
|
const store = useActivitiesStore()
|
||||||
const filters = useActivityFilters()
|
const filters = useActivityFilters()
|
||||||
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
|
|
||||||
const isSubscribed = ref(false)
|
const isSubscribed = ref(false)
|
||||||
const subscriptionError = ref<string | null>(null)
|
const subscriptionError = ref<string | null>(null)
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the caller's own LNbits events (any status) into the feed.
|
||||||
|
*
|
||||||
|
* The `/activities` feed is Nostr-driven, so an event that hasn't
|
||||||
|
* been published yet — typically because it's still `proposed` under
|
||||||
|
* auto_approve=off — would silently vanish from the creator's view
|
||||||
|
* until an admin approves it. Pull own events from the events
|
||||||
|
* extension and upsert them as Activities so users see their own
|
||||||
|
* drafts with a Pending-review badge.
|
||||||
|
*
|
||||||
|
* Once an event is approved and the Nostr relay delivers the kind
|
||||||
|
* 31922/31923 event, the relay-sourced Activity has a newer
|
||||||
|
* createdAt and wins on upsert (it lacks `lnbitsStatus`, so the
|
||||||
|
* badge disappears).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Stamp `isMine` on a Nostr-sourced activity when the organizer
|
||||||
|
* pubkey matches the logged-in user's Nostr key. LNbits drafts come
|
||||||
|
* pre-tagged via the adapter.
|
||||||
|
*/
|
||||||
|
function tagOwnership(activity: { organizer: { pubkey: string }; isMine?: boolean }) {
|
||||||
|
const myPubkey = currentUser.value?.pubkey
|
||||||
|
if (myPubkey && activity.organizer.pubkey === myPubkey) {
|
||||||
|
activity.isMine = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOwnEvents() {
|
||||||
|
if (!isAuthenticated.value) return
|
||||||
|
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
||||||
|
if (!invoiceKey) return
|
||||||
|
const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
|
||||||
|
if (!ticketApi) return
|
||||||
|
try {
|
||||||
|
const mine = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
|
||||||
|
for (const ev of mine) {
|
||||||
|
store.upsertActivity(ticketedEventToActivity(ev))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[useActivities] loadOwnEvents failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filtered and sorted activities (from all activities, filters handle time range)
|
// Filtered and sorted activities (from all activities, filters handle time range)
|
||||||
const filteredActivities = computed(() => {
|
const filteredActivities = computed(() => {
|
||||||
const all = store.activities.sort(
|
const all = store.activities.sort(
|
||||||
|
|
@ -43,6 +91,7 @@ export function useActivities() {
|
||||||
|
|
||||||
unsubscribe = nostrService.subscribeToCalendarEvents(
|
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||||
(activity) => {
|
(activity) => {
|
||||||
|
tagOwnership(activity)
|
||||||
store.upsertActivity(activity)
|
store.upsertActivity(activity)
|
||||||
store.isLoading = false
|
store.isLoading = false
|
||||||
},
|
},
|
||||||
|
|
@ -51,6 +100,10 @@ export function useActivities() {
|
||||||
|
|
||||||
isSubscribed.value = true
|
isSubscribed.value = true
|
||||||
|
|
||||||
|
// Best-effort merge of own LNbits events (any status) so the
|
||||||
|
// creator sees their own pending drafts on the feed too.
|
||||||
|
loadOwnEvents()
|
||||||
|
|
||||||
// Set loading to false after a timeout (in case no events arrive)
|
// Set loading to false after a timeout (in case no events arrive)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
store.isLoading = false
|
store.isLoading = false
|
||||||
|
|
@ -75,6 +128,7 @@ export function useActivities() {
|
||||||
store.isLoading = true
|
store.isLoading = true
|
||||||
subscriptionError.value = null
|
subscriptionError.value = null
|
||||||
const activities = await nostrService.queryCalendarEvents(eventFilters)
|
const activities = await nostrService.queryCalendarEvents(eventFilters)
|
||||||
|
for (const a of activities) tagOwnership(a)
|
||||||
store.upsertActivities(activities)
|
store.upsertActivities(activities)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
|
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
|
||||||
|
|
@ -125,5 +179,6 @@ export function useActivities() {
|
||||||
query,
|
query,
|
||||||
stop,
|
stop,
|
||||||
refresh,
|
refresh,
|
||||||
|
loadOwnEvents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import ngeohash from 'ngeohash'
|
import ngeohash from 'ngeohash'
|
||||||
import type { ActivityCategory } from './category'
|
import type { ActivityCategory } from './category'
|
||||||
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
|
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
|
||||||
|
import type { TicketedEvent } from './ticket'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified view model for displaying activities in the UI.
|
* Unified view model for displaying activities in the UI.
|
||||||
|
|
@ -45,6 +46,22 @@ export interface Activity {
|
||||||
isPrivate: boolean
|
isPrivate: boolean
|
||||||
/** Nostr event created_at timestamp */
|
/** Nostr event created_at timestamp */
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
|
/**
|
||||||
|
* LNbits approval status, when the activity came from the events
|
||||||
|
* extension rather than a Nostr relay. Undefined for activities
|
||||||
|
* sourced from Nostr (approved by definition — only published
|
||||||
|
* events make it onto relays). Used to render a "Pending review"
|
||||||
|
* badge for the creator's own non-approved drafts.
|
||||||
|
*/
|
||||||
|
lnbitsStatus?: 'approved' | 'proposed' | 'rejected'
|
||||||
|
/**
|
||||||
|
* Belongs to the current user. Set by the adapter for own LNbits
|
||||||
|
* drafts and by the activities-subscribe callback when the Nostr
|
||||||
|
* organizer pubkey matches the logged-in user. Used to render a
|
||||||
|
* "Yours" badge on the feed so the creator can spot their events
|
||||||
|
* at a glance.
|
||||||
|
*/
|
||||||
|
isMine?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizerInfo {
|
export interface OrganizerInfo {
|
||||||
|
|
@ -128,6 +145,74 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an LNbits TicketedEvent to an Activity view model.
|
||||||
|
*
|
||||||
|
* Used to surface the caller's own pending events on the activities
|
||||||
|
* feed alongside Nostr-published activities. Once an event is approved
|
||||||
|
* and published, the Nostr-derived Activity (newer createdAt) wins on
|
||||||
|
* upsert in the activities store and this draft version is replaced.
|
||||||
|
*
|
||||||
|
* The wire format for dates mirrors how nostr_publisher emits NIP-52:
|
||||||
|
* - "YYYY-MM-DD" → date-based (kind 31922 on publish)
|
||||||
|
* - "YYYY-MM-DDTHH:MM..." → time-based (kind 31923 on publish)
|
||||||
|
*/
|
||||||
|
export function ticketedEventToActivity(
|
||||||
|
event: TicketedEvent,
|
||||||
|
organizer?: Partial<OrganizerInfo>,
|
||||||
|
): Activity {
|
||||||
|
const hasTime = event.event_start_date.includes('T')
|
||||||
|
const startDate = hasTime
|
||||||
|
? new Date(event.event_start_date)
|
||||||
|
: parseDateOnly(event.event_start_date)
|
||||||
|
const endRaw = event.event_end_date
|
||||||
|
const endDate = endRaw
|
||||||
|
? endRaw.includes('T')
|
||||||
|
? new Date(endRaw)
|
||||||
|
: parseDateOnly(endRaw)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const category = event.categories?.[0] as ActivityCategory | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
// No published Nostr event yet for pending drafts; reuse the LNbits
|
||||||
|
// id as a placeholder. Approved + published versions will overwrite
|
||||||
|
// this with the real Nostr event id.
|
||||||
|
nostrEventId: event.id,
|
||||||
|
type: hasTime ? 'time' : 'date',
|
||||||
|
organizer: {
|
||||||
|
// Pending events have no Nostr pubkey yet. Empty string is fine
|
||||||
|
// — the card layer falls back gracefully and the OrganizerCard
|
||||||
|
// is only shown for approved (Nostr-sourced) activities anyway.
|
||||||
|
pubkey: '',
|
||||||
|
...organizer,
|
||||||
|
},
|
||||||
|
title: event.name,
|
||||||
|
description: event.info ?? '',
|
||||||
|
image: event.banner ?? undefined,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
location: event.location ?? undefined,
|
||||||
|
category,
|
||||||
|
tags: event.categories ?? [],
|
||||||
|
isPrivate: false,
|
||||||
|
// event.time is the LNbits creation timestamp (ISO string after
|
||||||
|
// FastAPI serialization). new Date() handles both ISO strings and
|
||||||
|
// numeric epoch — same shape used in useEvents sorting.
|
||||||
|
createdAt: new Date(event.time) || new Date(),
|
||||||
|
lnbitsStatus: event.status as Activity['lnbitsStatus'],
|
||||||
|
// fetchMyEvents only returns the caller's own events, so anything
|
||||||
|
// reaching this adapter is by definition mine.
|
||||||
|
isMine: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateOnly(dateStr: string): Date {
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number)
|
||||||
|
return new Date(Date.UTC(year, month - 1, day))
|
||||||
|
}
|
||||||
|
|
||||||
function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined {
|
function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined {
|
||||||
if (!geohash) return undefined
|
if (!geohash) return undefined
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,20 @@ function goBack() {
|
||||||
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
|
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
|
||||||
{{ categoryLabel }}
|
{{ categoryLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
|
||||||
|
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||||
|
class="shrink-0 mt-1 capitalize"
|
||||||
|
>
|
||||||
|
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="activity.isMine"
|
||||||
|
variant="outline"
|
||||||
|
class="shrink-0 mt-1"
|
||||||
|
>
|
||||||
|
Yours
|
||||||
|
</Badge>
|
||||||
<div v-for="tag in activity.tags.slice(1)" :key="tag">
|
<div v-for="tag in activity.tags.slice(1)" :key="tag">
|
||||||
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue