Add series management and ticketing features: Introduce series event functionality in event creation, enhance event display with series information, and implement ticketing options for public events. Update layouts and improve form handling for better user experience.
This commit is contained in:
parent
c3a29fa47c
commit
a88aa62198
24 changed files with 2897 additions and 44 deletions
238
app/components/NaturalDateInput.vue
Normal file
238
app/components/NaturalDateInput.vue
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="naturalInput"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:class="[
|
||||
'w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
inputClass,
|
||||
{
|
||||
'border-green-300 bg-green-50': isValidParse && naturalInput.trim(),
|
||||
'border-red-300 bg-red-50': hasError && naturalInput.trim()
|
||||
}
|
||||
]"
|
||||
@input="parseNaturalInput"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<div v-if="naturalInput.trim()" class="absolute right-3 top-2.5">
|
||||
<Icon
|
||||
v-if="isValidParse"
|
||||
name="heroicons:check-circle"
|
||||
class="w-5 h-5 text-green-500"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="hasError"
|
||||
name="heroicons:exclamation-circle"
|
||||
class="w-5 h-5 text-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="parsedDate && isValidParse" class="text-sm text-green-700 bg-green-50 px-3 py-2 rounded-lg border border-green-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="heroicons:calendar" class="w-4 h-4" />
|
||||
<span>{{ formatParsedDate(parsedDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasError && naturalInput.trim()" class="text-sm text-red-700 bg-red-50 px-3 py-2 rounded-lg border border-red-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback datetime-local input -->
|
||||
<details class="text-sm">
|
||||
<summary class="cursor-pointer text-gray-600 hover:text-gray-900">
|
||||
Use traditional date picker
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
v-model="datetimeValue"
|
||||
type="datetime-local"
|
||||
:class="[
|
||||
'w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
inputClass
|
||||
]"
|
||||
@change="onDatetimeChange"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import * as chrono from 'chrono-node'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"'
|
||||
},
|
||||
inputClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const naturalInput = ref('')
|
||||
const parsedDate = ref(null)
|
||||
const isValidParse = ref(false)
|
||||
const hasError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const datetimeValue = ref('')
|
||||
|
||||
// Initialize with current value
|
||||
onMounted(() => {
|
||||
if (props.modelValue) {
|
||||
const date = new Date(props.modelValue)
|
||||
if (!isNaN(date.getTime())) {
|
||||
parsedDate.value = date
|
||||
datetimeValue.value = formatForDatetimeLocal(date)
|
||||
isValidParse.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
|
||||
const date = new Date(newValue)
|
||||
if (!isNaN(date.getTime())) {
|
||||
parsedDate.value = date
|
||||
datetimeValue.value = formatForDatetimeLocal(date)
|
||||
isValidParse.value = true
|
||||
naturalInput.value = '' // Clear natural input when set externally
|
||||
}
|
||||
} else if (!newValue) {
|
||||
reset()
|
||||
}
|
||||
})
|
||||
|
||||
const parseNaturalInput = () => {
|
||||
const input = naturalInput.value.trim()
|
||||
|
||||
if (!input) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse with chrono-node
|
||||
const results = chrono.parse(input)
|
||||
|
||||
if (results.length > 0) {
|
||||
const result = results[0]
|
||||
const date = result.date()
|
||||
|
||||
// Validate the parsed date
|
||||
if (date && !isNaN(date.getTime())) {
|
||||
parsedDate.value = date
|
||||
isValidParse.value = true
|
||||
hasError.value = false
|
||||
datetimeValue.value = formatForDatetimeLocal(date)
|
||||
emit('update:modelValue', formatForDatetimeLocal(date))
|
||||
} else {
|
||||
setError('Could not parse this date format')
|
||||
}
|
||||
} else {
|
||||
setError('Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Error parsing date')
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
// If we have a valid parse but the input changed, try to parse again
|
||||
if (naturalInput.value.trim() && !isValidParse.value) {
|
||||
parseNaturalInput()
|
||||
}
|
||||
}
|
||||
|
||||
const onDatetimeChange = () => {
|
||||
if (datetimeValue.value) {
|
||||
const date = new Date(datetimeValue.value)
|
||||
if (!isNaN(date.getTime())) {
|
||||
parsedDate.value = date
|
||||
isValidParse.value = true
|
||||
hasError.value = false
|
||||
naturalInput.value = '' // Clear natural input when using traditional picker
|
||||
emit('update:modelValue', datetimeValue.value)
|
||||
}
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
parsedDate.value = null
|
||||
isValidParse.value = false
|
||||
hasError.value = false
|
||||
errorMessage.value = ''
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
const setError = (message) => {
|
||||
isValidParse.value = false
|
||||
hasError.value = true
|
||||
errorMessage.value = message
|
||||
parsedDate.value = null
|
||||
}
|
||||
|
||||
const formatForDatetimeLocal = (date) => {
|
||||
if (!date) return ''
|
||||
// Format as YYYY-MM-DDTHH:MM for datetime-local input
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const formatParsedDate = (date) => {
|
||||
if (!date) return ''
|
||||
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
const tomorrow = new Date(now)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const isTomorrow = date.toDateString() === tomorrow.toDateString()
|
||||
|
||||
const timeStr = date.toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
|
||||
if (isToday) {
|
||||
return `Today at ${timeStr}`
|
||||
} else if (isTomorrow) {
|
||||
return `Tomorrow at ${timeStr}`
|
||||
} else {
|
||||
return date.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue