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>
This commit is contained in:
parent
4bea1a6592
commit
cd35fae674
1 changed files with 142 additions and 21 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue