Compare commits

..

3 commits

Author SHA1 Message Date
af3c9853c0 feat(activities): edit button on user-owned events
Pencil button in the card footer of upcoming events the current user
owns (event.wallet ∈ currentUser.wallets). Clicking opens the same
CreateEventDialog in edit mode, pre-populated with the event.

Probe `is_admin` and `auto_approve` once at mount so the dialog can
render the "going back to pending" warning copy accurately for
non-admin owners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:30:00 +02:00
cd35fae674 feat(activities): dual-mode CreateEventDialog supports edit
Accept optional `event` prop and `onUpdateEvent` handler. Dialog
toggles title, description, submit button text, and a warning Alert
based on edit mode plus an `isAdmin`/`autoApprove` pair the parent
supplies.

On open in edit mode, populate the form from the event — split stored
"YYYY-MM-DD[THH:MM]" back into date+time inputs, restore categories,
and seed bannerImages from the stored URL by extracting the pict-rs
file ID (same pattern as market's CreateProductDialog).

A clearing-the-banner action during edit sends `banner: null` so the
backend wipes the field instead of keeping the old image. Auto-mirror
watcher is guarded against firing during the initial population.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:29:49 +02:00
4bea1a6592 feat(activities): TicketApiService.updateEvent + admin/auto_approve probes
`updateEvent` calls PUT /events/{id} with the event's wallet admin key
— mirrors the backend's `require_admin_key` decorator (different key
than the inkey used by createEvent).

Add `isAdmin` and `getAutoApprove` probes so the dialog can decide
whether to show "edit will go back to pending approval" copy. Both
degrade to `false` on failure, which biases the warning toward being
shown when in doubt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:29:37 +02:00
3 changed files with 280 additions and 34 deletions

View file

@ -32,28 +32,45 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Calendar, Loader2, MapPin } from 'lucide-vue-next'
import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next'
import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
import { ALL_CATEGORIES } from '../types/category'
interface Props {
open: boolean
onCreateEvent: (eventData: CreateEventRequest) => Promise<void>
/** When set, dialog opens in edit mode for this event. */
event?: TicketedEvent | null
/** Create handler. Required when not editing. */
onCreateEvent?: (eventData: CreateEventRequest) => Promise<void>
/** Update handler. Required when editing. */
onUpdateEvent?: (eventId: string, eventData: CreateEventRequest) => Promise<void>
/** Whether the current user is an LNbits admin. Drives the
* "edit will go back to pending approval" warning copy. */
isAdmin?: boolean
/** Whether the events extension has auto_approve enabled. */
autoApprove?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
'event-created': []
'event-updated': []
}>()
const isEditMode = computed(() => Boolean(props.event?.id))
const willGoToPending = computed(
() => isEditMode.value && !props.isAdmin && !props.autoApprove
)
const { t } = useI18n()
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
@ -117,6 +134,18 @@ interface BannerImage extends UploadedImage {
}
const bannerImages = ref<BannerImage[]>([])
// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM]" back
// into separate date + time pieces for the form inputs.
function splitDateTime(value: string | null | undefined): { date: string; time: string } {
if (!value) return { date: '', time: '' }
const [date, time = ''] = value.split('T')
return { date, time: time.slice(0, 5) }
}
// When `true`, suppress the auto-mirror watcher so we don't clobber an
// edit-mode population with start-date side effects mid-setValues.
const isPopulating = ref(false)
// Auto-mirror end date to start: when the user picks a start date,
// surface that same date in the end-date picker so a one-day event
// requires no extra clicks. Don't overwrite an end date the user
@ -125,6 +154,7 @@ const bannerImages = ref<BannerImage[]>([])
watch(
() => form.values.event_start_date,
(start, prev) => {
if (isPopulating.value) return
if (!start) return
const end = form.values.event_end_date
if (!end || end < start || end === prev) {
@ -141,18 +171,61 @@ const availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false)
const selectedCategories = ref<string[]>([])
watch(() => props.open, async (isOpen) => {
if (isOpen && ticketApi && !loadingCurrencies.value) {
loadingCurrencies.value = true
try {
availableCurrencies.value = await ticketApi.getCurrencies()
} catch (error) {
console.warn('Failed to load currencies:', error)
} finally {
loadingCurrencies.value = false
}
function populateFromEvent(event: TicketedEvent) {
isPopulating.value = true
const start = splitDateTime(event.event_start_date)
const end = splitDateTime(event.event_end_date)
form.setValues({
name: event.name,
info: event.info ?? '',
event_start_date: start.date,
event_start_time: start.time,
event_end_date: end.date,
event_end_time: end.time,
location: event.location ?? '',
currency: event.currency ?? 'sat',
amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0,
})
selectedCategories.value = [...(event.categories ?? [])]
if (event.banner) {
// Mirror the URL-to-alias bridge from market's CreateProductDialog
// so the <ImageUpload> renders the existing banner via its pict-rs
// file ID. delete_token is unknown for already-uploaded images, so
// removal just clears the slot client-side.
const url = event.banner
const alias = url.includes('/image/original/')
? url.split('/image/original/')[1]
: url
bannerImages.value = [
{ alias, isPrimary: true, delete_token: '', details: {} as any },
]
} else {
bannerImages.value = []
}
if (!isOpen) {
// Release the watcher guard on the next tick so vee-validate's batched
// updates settle before user input can drive the auto-mirror.
setTimeout(() => {
isPopulating.value = false
}, 0)
}
watch(() => props.open, async (isOpen) => {
if (isOpen) {
if (ticketApi && !loadingCurrencies.value) {
loadingCurrencies.value = true
try {
availableCurrencies.value = await ticketApi.getCurrencies()
} catch (error) {
console.warn('Failed to load currencies:', error)
} finally {
loadingCurrencies.value = false
}
}
if (props.event) {
populateFromEvent(props.event)
}
} else {
selectedCategories.value = []
}
})
@ -195,7 +268,11 @@ const onSubmit = form.handleSubmit(async (formValues) => {
formValues.event_start_date,
formValues.event_start_time
),
wallet: preferredWallet.id,
}
if (!isEditMode.value) {
// Wallet binds at creation. The backend ignores the field on
// update so we leave it off the edit payload for clean wire.
eventData.wallet = preferredWallet.id
}
// Optional fields only include if provided
@ -209,21 +286,49 @@ const onSubmit = form.handleSubmit(async (formValues) => {
if (formValues.location) eventData.location = formValues.location
if (bannerImages.value.length > 0) {
eventData.banner = imageService.getImageUrl(bannerImages.value[0].alias)
} else if (isEditMode.value) {
// User cleared the banner during edit propagate the null so the
// backend wipes the field instead of keeping the old image.
eventData.banner = null
}
if (formValues.currency) eventData.currency = formValues.currency
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
await props.onCreateEvent(eventData)
toastService.success('Event submitted!')
if (isEditMode.value) {
if (!props.onUpdateEvent || !props.event?.id) {
toastService.error('Update handler missing')
return
}
await props.onUpdateEvent(props.event.id, eventData)
toastService.success(
willGoToPending.value
? 'Event updated — pending re-approval'
: 'Event updated!'
)
emit('event-updated')
} else {
if (!props.onCreateEvent) {
toastService.error('Create handler missing')
return
}
await props.onCreateEvent(eventData)
toastService.success('Event submitted!')
emit('event-created')
}
resetForm()
selectedCategories.value = []
bannerImages.value = []
emit('update:open', false)
emit('event-created')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to create event'
const errorMessage =
error instanceof Error
? error.message
: isEditMode.value
? 'Failed to update event'
: 'Failed to create event'
toastService.error(errorMessage)
} finally {
isLoading.value = false
@ -246,15 +351,27 @@ const handleOpenChange = (open: boolean) => {
<DialogHeader class="px-6 pt-6 pb-2">
<DialogTitle class="flex items-center gap-2">
<Calendar class="w-5 h-5" />
Create Event
{{ isEditMode ? 'Edit Event' : 'Create Event' }}
</DialogTitle>
<DialogDescription>
Only a title and start date are required.
{{
isEditMode
? 'Update event details. Tickets already sold are not affected.'
: 'Only a title and start date are required.'
}}
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[70vh] px-6 pb-6">
<form @submit="onSubmit" class="space-y-4">
<Alert v-if="willGoToPending" variant="default" class="border-orange-500/40 bg-orange-500/5">
<AlertCircle class="h-4 w-4 text-orange-500" />
<AlertDescription>
Saving will resubmit for approval. The event will be removed
from public feeds until reviewed.
</AlertDescription>
</Alert>
<!-- Title (required) -->
<FormField v-slot="{ componentField }" name="name">
<FormItem>
@ -451,7 +568,11 @@ const handleOpenChange = (open: boolean) => {
</Button>
<Button type="submit" :disabled="isLoading || !isFormValid">
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{ isLoading ? 'Submitting...' : 'Submit Event' }}
{{
isLoading
? (isEditMode ? 'Saving...' : 'Submitting...')
: (isEditMode ? 'Save changes' : 'Submit Event')
}}
</Button>
</div>
</form>

View file

@ -148,6 +148,60 @@ export class TicketApiService {
})
}
/**
* Update an existing event. Requires the event's wallet admin key.
* Status is re-derived server-side from admin/auto_approve a non-
* admin owner editing under `auto_approve=false` lands back at
* `proposed` regardless of the current state.
*/
async updateEvent(
eventId: string,
eventData: CreateEventRequest,
adminKey: string,
): Promise<TicketedEvent> {
return this.request(`/events/api/v1/events/${eventId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': adminKey,
},
body: JSON.stringify(eventData),
})
}
/**
* Probe whether the current user has LNbits admin privileges. The
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",
* any other response means "not admin".
*/
async isAdmin(adminKey: string): Promise<boolean> {
try {
await this.request('/events/api/v1/events/all', {
method: 'GET',
headers: { 'X-API-KEY': adminKey },
})
return true
} catch {
return false
}
}
/**
* Read the extension's auto_approve flag. Admin-only endpoint, so
* non-admin callers see false (the safe default for UI gating).
*/
async getAutoApprove(adminKey: string): Promise<boolean> {
try {
const settings = await this.request('/events/api/v1/events/settings', {
method: 'GET',
headers: { 'X-API-KEY': adminKey },
})
return Boolean(settings?.auto_approve)
} catch {
return false
}
}
/**
* Fetch available currencies from LNbits.
*/

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useEvents } from '../composables/useEvents'
import { useAuth } from '@/composables/useAuthService'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
@ -10,14 +10,14 @@ import { Badge } from '@/components/ui/badge'
import { format } from 'date-fns'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import CreateEventDialog from '../components/CreateEventDialog.vue'
import { User, LogIn, Plus } from 'lucide-vue-next'
import { User, LogIn, Plus, Pencil } from 'lucide-vue-next'
import { formatEventPrice } from '@/lib/utils/formatting'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
const { isAuthenticated, userDisplay } = useAuth()
const { isAuthenticated, userDisplay, currentUser } = useAuth()
const showPurchaseDialog = ref(false)
const selectedEvent = ref<{
@ -27,7 +27,33 @@ const selectedEvent = ref<{
currency: string
} | null>(null)
const showCreateDialog = ref(false)
const showEventDialog = ref(false)
// `null` create mode; populated edit mode.
const editingEvent = ref<TicketedEvent | null>(null)
// Probe once at mount so the dialog can render the "going to pending"
// warning accurately. Both probes degrade to safe defaults on failure
// (not admin, not auto-approved), which biases the warning toward
// being shown when in doubt.
const isAdmin = ref(false)
const autoApprove = ref(false)
const myWalletIds = computed(
() => new Set((currentUser.value?.wallets ?? []).map((w) => w.id))
)
function canEdit(event: TicketedEvent): boolean {
return isAuthenticated.value && myWalletIds.value.has(event.wallet)
}
onMounted(async () => {
if (!isAuthenticated.value) return
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) return
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
isAdmin.value = await ticketApi.isAdmin(adminKey)
autoApprove.value = await ticketApi.getAutoApprove(adminKey)
})
function formatDate(dateStr: string | null | undefined) {
if (!dateStr) return 'Date not available'
@ -52,7 +78,6 @@ function handlePurchaseClick(event: {
async function handleCreateEvent(eventData: CreateEventRequest) {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const { currentUser } = useAuth()
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
if (!invoiceKey) {
throw new Error('No wallet available. Please log in first.')
@ -61,7 +86,36 @@ async function handleCreateEvent(eventData: CreateEventRequest) {
await ticketApi.createEvent(eventData, invoiceKey)
}
function handleEventCreated() {
async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest) {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
// PUT /events/{id} requires the event's wallet admin key.
const wallet = (currentUser.value?.wallets ?? []).find(
(w) => w.id === editingEvent.value?.wallet,
)
const adminKey = wallet?.adminkey
if (!adminKey) {
throw new Error("Can't find the admin key for this event's wallet.")
}
await ticketApi.updateEvent(eventId, eventData, adminKey)
}
function openCreateDialog() {
editingEvent.value = null
showEventDialog.value = true
}
function openEditDialog(event: TicketedEvent) {
editingEvent.value = event
showEventDialog.value = true
}
function handleDialogClosed() {
// Reset the edit selection so a subsequent "New Event" click opens
// clean instead of inheriting the last-edited event.
if (!showEventDialog.value) editingEvent.value = null
}
function handleEventChanged() {
refresh?.()
}
</script>
@ -83,7 +137,7 @@ function handleEventCreated() {
</div>
</div>
<div class="flex gap-2 sm:flex-shrink-0">
<Button v-if="isAuthenticated" variant="default" size="sm" @click="showCreateDialog = true" class="flex-1 sm:flex-none">
<Button v-if="isAuthenticated" variant="default" size="sm" @click="openCreateDialog" class="flex-1 sm:flex-none">
<Plus class="w-4 h-4" />
<span class="ml-2">Create Event</span>
</Button>
@ -128,9 +182,9 @@ function handleEventCreated() {
</div>
</div>
</CardContent>
<CardFooter>
<CardFooter class="flex gap-2">
<Button
class="w-full"
class="flex-1"
variant="default"
:disabled="event.amount_tickets <= event.sold || !isAuthenticated"
@click="handlePurchaseClick(event)"
@ -141,6 +195,15 @@ function handleEventCreated() {
</span>
<span v-else>Buy Ticket</span>
</Button>
<Button
v-if="canEdit(event)"
variant="outline"
size="icon"
aria-label="Edit event"
@click="openEditDialog(event)"
>
<Pencil class="w-4 h-4" />
</Button>
</CardFooter>
</Card>
</div>
@ -189,10 +252,18 @@ function handleEventCreated() {
<PurchaseTicketDialog v-if="selectedEvent" :event="selectedEvent" v-model:is-open="showPurchaseDialog" />
<CreateEventDialog
:open="showCreateDialog"
@update:open="showCreateDialog = $event"
:open="showEventDialog"
:event="editingEvent"
:is-admin="isAdmin"
:auto-approve="autoApprove"
:on-create-event="handleCreateEvent"
@event-created="handleEventCreated"
:on-update-event="handleUpdateEvent"
@update:open="
showEventDialog = $event
handleDialogClosed()
"
@event-created="handleEventChanged"
@event-updated="handleEventChanged"
/>
</div>
</template>