Use .glass class on form fields and buttons on gradient backgrounds. Buttons get gradient glow border via background-clip trick. Solid white fallback preserved for BottomSheet context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
7.4 KiB
Vue
260 lines
7.4 KiB
Vue
<template>
|
|
<main class="create">
|
|
<header class="create__header">
|
|
<RouterLink to="/" class="create__back" aria-label="Back to home">←</RouterLink>
|
|
<h1 class="create__title">Create</h1>
|
|
</header>
|
|
|
|
<form class="create__form" novalidate @submit.prevent="handleSubmit">
|
|
<div class="form-group">
|
|
<label for="title" class="form-label">Title *</label>
|
|
<input
|
|
id="title"
|
|
v-model="form.title"
|
|
type="text"
|
|
class="form-field glass"
|
|
required
|
|
maxlength="200"
|
|
placeholder="What's the event?"
|
|
:aria-invalid="!!errors.title"
|
|
:aria-describedby="errors.title ? 'title-error' : undefined"
|
|
/>
|
|
<span v-if="errors.title" id="title-error" class="field-error" role="alert">{{ errors.title }}</span>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="description" class="form-label">Description</label>
|
|
<textarea
|
|
id="description"
|
|
v-model="form.description"
|
|
class="form-field glass"
|
|
maxlength="2000"
|
|
placeholder="Tell people more about it…"
|
|
:aria-invalid="!!errors.description"
|
|
:aria-describedby="errors.description ? 'description-error' : undefined"
|
|
/>
|
|
<span v-if="errors.description" id="description-error" class="field-error" role="alert">{{ errors.description }}</span>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="dateTime" class="form-label">Date & Time *</label>
|
|
<input
|
|
id="dateTime"
|
|
v-model="form.dateTime"
|
|
type="datetime-local"
|
|
class="form-field glass"
|
|
required
|
|
:aria-invalid="!!errors.dateTime"
|
|
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
|
|
/>
|
|
<span v-if="errors.dateTime" id="dateTime-error" class="field-error" role="alert">{{ errors.dateTime }}</span>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="location" class="form-label">Location</label>
|
|
<input
|
|
id="location"
|
|
v-model="form.location"
|
|
type="text"
|
|
class="form-field glass"
|
|
maxlength="500"
|
|
placeholder="Where is it?"
|
|
:aria-invalid="!!errors.location"
|
|
:aria-describedby="errors.location ? 'location-error' : undefined"
|
|
/>
|
|
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="expiryDate" class="form-label">Expiry Date *</label>
|
|
<input
|
|
id="expiryDate"
|
|
v-model="form.expiryDate"
|
|
type="date"
|
|
class="form-field glass"
|
|
required
|
|
:min="tomorrow"
|
|
:aria-invalid="!!errors.expiryDate"
|
|
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
|
|
/>
|
|
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
|
|
</div>
|
|
|
|
<button type="submit" class="btn-primary glass" :disabled="submitting">
|
|
{{ submitting ? 'Creating…' : 'Create Event' }}
|
|
</button>
|
|
|
|
<p v-if="serverError" class="field-error text-center" role="alert">{{ serverError }}</p>
|
|
</form>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { reactive, ref, computed, watch } from 'vue'
|
|
import { RouterLink, useRouter } from 'vue-router'
|
|
import { api } from '@/api/client'
|
|
import { useEventStorage } from '@/composables/useEventStorage'
|
|
|
|
const router = useRouter()
|
|
const { saveCreatedEvent } = useEventStorage()
|
|
|
|
const form = reactive({
|
|
title: '',
|
|
description: '',
|
|
dateTime: '',
|
|
location: '',
|
|
expiryDate: '',
|
|
})
|
|
|
|
const errors = reactive({
|
|
title: '',
|
|
description: '',
|
|
dateTime: '',
|
|
location: '',
|
|
expiryDate: '',
|
|
})
|
|
|
|
const submitting = ref(false)
|
|
const serverError = ref('')
|
|
|
|
const tomorrow = computed(() => {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() + 1)
|
|
return d.toISOString().split('T')[0]
|
|
})
|
|
|
|
function clearErrors() {
|
|
errors.title = ''
|
|
errors.description = ''
|
|
errors.dateTime = ''
|
|
errors.location = ''
|
|
errors.expiryDate = ''
|
|
serverError.value = ''
|
|
}
|
|
|
|
// Clear individual field errors when the user types
|
|
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
|
|
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
|
|
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
|
|
watch(() => form.description, () => { serverError.value = '' })
|
|
watch(() => form.location, () => { serverError.value = '' })
|
|
|
|
function validate(): boolean {
|
|
clearErrors()
|
|
let valid = true
|
|
|
|
if (!form.title.trim()) {
|
|
errors.title = 'Title is required.'
|
|
valid = false
|
|
}
|
|
|
|
if (!form.dateTime) {
|
|
errors.dateTime = 'Date and time are required.'
|
|
valid = false
|
|
}
|
|
|
|
if (!form.expiryDate) {
|
|
errors.expiryDate = 'Expiry date is required.'
|
|
valid = false
|
|
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
|
|
errors.expiryDate = 'Expiry date must be in the future.'
|
|
valid = false
|
|
}
|
|
|
|
return valid
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
if (!validate()) return
|
|
|
|
submitting.value = true
|
|
|
|
// Build ISO 8601 dateTime with local timezone offset
|
|
const localDate = new Date(form.dateTime)
|
|
const offsetMinutes = -localDate.getTimezoneOffset()
|
|
const sign = offsetMinutes >= 0 ? '+' : '-'
|
|
const absOffset = Math.abs(offsetMinutes)
|
|
const offsetHours = String(Math.floor(absOffset / 60)).padStart(2, '0')
|
|
const offsetMins = String(absOffset % 60).padStart(2, '0')
|
|
const dateTimeWithOffset = form.dateTime + ':00' + sign + offsetHours + ':' + offsetMins
|
|
|
|
try {
|
|
const { data, error } = await api.POST('/events', {
|
|
body: {
|
|
title: form.title.trim(),
|
|
description: form.description.trim() || undefined,
|
|
dateTime: dateTimeWithOffset,
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
location: form.location.trim() || undefined,
|
|
expiryDate: form.expiryDate,
|
|
},
|
|
})
|
|
|
|
submitting.value = false
|
|
|
|
if (error) {
|
|
if ('fieldErrors' in error && Array.isArray(error.fieldErrors)) {
|
|
for (const fe of error.fieldErrors) {
|
|
const field = fe.field as keyof typeof errors
|
|
if (field in errors) {
|
|
errors[field] = fe.message
|
|
}
|
|
}
|
|
} else {
|
|
serverError.value = error.detail || 'Something went wrong. Please try again.'
|
|
}
|
|
return
|
|
}
|
|
|
|
if (data) {
|
|
saveCreatedEvent({
|
|
eventToken: data.eventToken,
|
|
organizerToken: data.organizerToken,
|
|
title: data.title,
|
|
dateTime: data.dateTime,
|
|
expiryDate: data.expiryDate,
|
|
})
|
|
|
|
router.push({ name: 'event', params: { eventToken: data.eventToken } })
|
|
}
|
|
} catch {
|
|
submitting.value = false
|
|
serverError.value = 'Could not reach the server. Please try again.'
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.create {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-lg);
|
|
padding-top: var(--spacing-lg);
|
|
}
|
|
|
|
.create__header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.create__back {
|
|
color: var(--color-text-on-gradient);
|
|
font-size: 1.5rem;
|
|
text-decoration: none;
|
|
line-height: 1;
|
|
}
|
|
|
|
.create__title {
|
|
font-size: 1.3rem;
|
|
font-weight: 700;
|
|
color: var(--color-text-on-gradient);
|
|
}
|
|
|
|
.create__form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-lg);
|
|
}
|
|
</style>
|