feat: sistem de linkuri publice pentru proprietăți
Expune fluxul de rezervare anonimă printr-un link public partajabil
(/book/<slug>), cu calendar public, cod QR și proprietate demo publică.
Backend:
- slug + list_on_landing pe Property; utilitar slugify (diacritice RO,
cuvinte rezervate, ban all-digit) + migrare idempotentă raw-DDL rulată
din entrypoint; SQLite WAL + busy_timeout
- GET /public/properties/{slug_or_id}(+/spaces): rezolvare slug/ID, 404 uniform
- GET /public/spaces/{id}/busy: doar {start_time,end_time} (fără PII),
pending+approved, tz-aware/interval>60z → 422, orfan/privat → 404
- availability: scrub user_name/title (fix scurgere PII)
- POST /public/bookings: overlap pending+approved, cap 5/email/spațiu/zi,
rate-limit per-IP, suprimare notificări+email pe demo, email confirmare guest
- PATCH slug (409 duplicat / 422 format / 403 non-manager); guest_email EmailStr
- email guest la aprobare/respingere cu cod #RB-<id>
- teste noi: test_public.py + test_migrate_slug.py (33 teste)
Frontend:
- rută /book/:slug; PublicBooking rescris cu calendar grid manual
(PublicCalendar + GuestBookingForm), 30min, 08-20, mobil day-view, RO
- card „Link public" în PropertyDetail: copy, edit slug, QR pe URL-ul cu ID
- secțiune proprietăți publice + CTA pe Landing; buton copy în Properties
Gate aprobat (U1-U3, G1 grid manual, G2 list_on_landing); G3-G5 în TODOS.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
337
frontend/src/components/GuestBookingForm.vue
Normal file
337
frontend/src/components/GuestBookingForm.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<form class="guest-form" @submit.prevent="handleSubmit">
|
||||
<h3 class="form-title">Detalii rezervare</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="guest_name">Nume *</label>
|
||||
<input id="guest_name" v-model="form.guest_name" type="text" required placeholder="Popescu Ion" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="guest_email">Email *</label>
|
||||
<input id="guest_email" v-model="form.guest_email" type="email" required placeholder="ion@exemplu.ro" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="guest_organization">Organizație (opțional)</label>
|
||||
<input id="guest_organization" v-model="form.guest_organization" type="text" placeholder="Numele companiei" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Titlu rezervare *</label>
|
||||
<input id="title" v-model="form.title" type="text" required placeholder="Ședință echipă" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Descriere (opțional)</label>
|
||||
<textarea id="description" v-model="form.description" rows="2" placeholder="Detalii suplimentare..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="datetime-block">
|
||||
<div v-if="selection && !manualMode" class="datetime-mirror">
|
||||
<div class="mirror-row">
|
||||
<span class="mirror-label">Data</span>
|
||||
<span class="mirror-value">{{ selection.date }}</span>
|
||||
</div>
|
||||
<div class="mirror-row">
|
||||
<span class="mirror-label">Interval</span>
|
||||
<span class="mirror-value">{{ selection.start }} – {{ selection.end }}</span>
|
||||
</div>
|
||||
<p class="mirror-hint">
|
||||
Selecție din calendar. <button type="button" class="btn-link" @click="goManual">Introdu manual</button>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="date">Data *</label>
|
||||
<input id="date" v-model="manualDate" type="date" required :min="minDate" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="start_time">Ora start *</label>
|
||||
<input id="start_time" v-model="manualStart" type="time" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end_time">Ora sfârșit *</label>
|
||||
<input id="end_time" v-model="manualEnd" type="time" required />
|
||||
</div>
|
||||
<p v-if="selection" class="mirror-hint">
|
||||
<button type="button" class="btn-link" @click="goCalendar">« schimbă din calendar</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="requireApproval" class="notice-approval">
|
||||
Această proprietate necesită aprobare manuală — vei primi un răspuns după validare.
|
||||
</p>
|
||||
|
||||
<div v-if="validationError" class="error">{{ validationError }}</div>
|
||||
<div v-if="submitError" class="error">{{ submitError }}</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" :disabled="submitting || !canSubmit">
|
||||
{{ submitting ? 'Se trimite...' : 'Trimite cererea de rezervare' }}
|
||||
</button>
|
||||
<p v-if="!selection && !manualMode" class="hint-select">Selectează un interval din calendar pentru a continua.</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { AnonymousBookingCreate } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
spaceId: number | null
|
||||
selection: { date: string; start: string; end: string } | null
|
||||
submitting: boolean
|
||||
submitError: string
|
||||
requireApproval: boolean
|
||||
busyLoaded: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: AnonymousBookingCreate]
|
||||
}>()
|
||||
|
||||
const form = ref({
|
||||
guest_name: '',
|
||||
guest_email: '',
|
||||
guest_organization: '',
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const manualMode = ref(false)
|
||||
const manualDate = ref('')
|
||||
const manualStart = ref('')
|
||||
const manualEnd = ref('')
|
||||
const validationError = ref('')
|
||||
|
||||
function pad(n: number): string {
|
||||
return n < 10 ? '0' + n : '' + n
|
||||
}
|
||||
|
||||
const minDate = computed(() => {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.selection,
|
||||
(sel) => {
|
||||
if (sel) {
|
||||
manualMode.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function goManual() {
|
||||
if (props.selection) {
|
||||
manualDate.value = props.selection.date
|
||||
manualStart.value = props.selection.start
|
||||
manualEnd.value = props.selection.end
|
||||
}
|
||||
manualMode.value = true
|
||||
}
|
||||
|
||||
function goCalendar() {
|
||||
manualMode.value = false
|
||||
}
|
||||
|
||||
const effectiveDate = computed(() => (manualMode.value || !props.selection ? manualDate.value : props.selection.date))
|
||||
const effectiveStart = computed(() =>
|
||||
manualMode.value || !props.selection ? manualStart.value : props.selection.start
|
||||
)
|
||||
const effectiveEnd = computed(() => (manualMode.value || !props.selection ? manualEnd.value : props.selection.end))
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return props.busyLoaded && !!effectiveDate.value && !!effectiveStart.value && !!effectiveEnd.value
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
validationError.value = ''
|
||||
if (!props.spaceId) return
|
||||
if (!effectiveDate.value || !effectiveStart.value || !effectiveEnd.value) {
|
||||
validationError.value = 'Selectează data și intervalul orar.'
|
||||
return
|
||||
}
|
||||
if (effectiveStart.value >= effectiveEnd.value) {
|
||||
validationError.value = 'Ora de sfârșit trebuie să fie după ora de start.'
|
||||
return
|
||||
}
|
||||
emit('submit', {
|
||||
space_id: props.spaceId,
|
||||
start_datetime: `${effectiveDate.value}T${effectiveStart.value}:00`,
|
||||
end_datetime: `${effectiveDate.value}T${effectiveEnd.value}:00`,
|
||||
title: form.value.title,
|
||||
description: form.value.description || undefined,
|
||||
guest_name: form.value.guest_name,
|
||||
guest_email: form.value.guest_email,
|
||||
guest_organization: form.value.guest_organization || undefined
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guest-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.datetime-block {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.datetime-mirror {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mirror-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mirror-label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mirror-value {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.mirror-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notice-approval {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-left: 3px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hint-select {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
613
frontend/src/components/PublicCalendar.vue
Normal file
613
frontend/src/components/PublicCalendar.vue
Normal file
@@ -0,0 +1,613 @@
|
||||
<template>
|
||||
<div class="public-calendar">
|
||||
<div class="cal-header">
|
||||
<div class="week-nav">
|
||||
<button type="button" class="nav-btn" @click="prevWeek" aria-label="Săptămâna anterioară">‹</button>
|
||||
<span class="week-label">{{ weekRangeLabel }}</span>
|
||||
<button type="button" class="nav-btn" @click="nextWeek" aria-label="Săptămâna următoare">›</button>
|
||||
<button type="button" class="btn-today" @click="goToday">Azi</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="day-picker" role="tablist">
|
||||
<button
|
||||
v-for="(d, i) in weekDays"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="day-chip"
|
||||
:class="{ active: i === mobileDayIndex, today: isSameDay(d, today) }"
|
||||
@click="mobileDayIndex = i"
|
||||
>
|
||||
<span class="day-name">{{ dayName(d) }}</span>
|
||||
<span class="day-num">{{ d.getDate() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="cal-error">
|
||||
<span>{{ error }}</span>
|
||||
<button type="button" class="btn-retry" @click="fetchBusy">Reîncearcă</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="cal-skeleton">
|
||||
<div v-for="n in (isMobile ? 12 : 24)" :key="n" class="skeleton-row"></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="isEmpty" class="cal-empty">
|
||||
Toate intervalele sunt ocupate — vezi săptămâna următoare
|
||||
<button type="button" class="btn-link" @click="nextWeek">Săptămâna următoare »</button>
|
||||
</div>
|
||||
|
||||
<div class="cal-grid" :class="{ mobile: isMobile }">
|
||||
<div class="cal-times">
|
||||
<div class="cal-corner"></div>
|
||||
<div v-for="(s, i) in slots" :key="i" class="time-label">
|
||||
{{ i % 2 === 0 ? slotLabel(s.h, s.m) : '' }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(day, di) in visibleDays"
|
||||
:key="di"
|
||||
class="cal-day-col"
|
||||
>
|
||||
<div class="cal-day-head" :class="{ today: isSameDay(day, today) }">
|
||||
<template v-if="!isMobile">{{ dayName(day) }} {{ day.getDate() }}</template>
|
||||
</div>
|
||||
<button
|
||||
v-for="(s, si) in slots"
|
||||
:key="si"
|
||||
type="button"
|
||||
class="slot"
|
||||
:class="{
|
||||
busy: isBusy(day, s.h, s.m),
|
||||
selected: isSelected(day, s.h, s.m),
|
||||
'slot-hour': s.m === 0
|
||||
}"
|
||||
:disabled="isBusy(day, s.h, s.m)"
|
||||
:aria-label="isBusy(day, s.h, s.m) ? 'Ocupat' : `Liber ${slotLabel(s.h, s.m)}`"
|
||||
@click="onSlotClick(day, s.h, s.m)"
|
||||
>
|
||||
<span v-if="isBusy(day, s.h, s.m)" class="slot-tag">Ocupat</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { publicApi, handleApiError } from '@/services/api'
|
||||
import type { BusyInterval } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
spaceId: number
|
||||
workingHoursStart?: number | null
|
||||
workingHoursEnd?: number | null
|
||||
}>()
|
||||
|
||||
const selection = defineModel<{ date: string; start: string; end: string } | null>('selection', {
|
||||
default: null
|
||||
})
|
||||
|
||||
const today = new Date()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const busy = ref<BusyInterval[]>([])
|
||||
const weekOffset = ref(0)
|
||||
const isMobile = ref(typeof window !== 'undefined' ? window.innerWidth < 768 : false)
|
||||
const mobileDayIndex = ref(0)
|
||||
|
||||
let requestSeq = 0
|
||||
|
||||
const startHour = computed(() => props.workingHoursStart ?? 8)
|
||||
const endHour = computed(() => props.workingHoursEnd ?? 20)
|
||||
|
||||
function pad(n: number): string {
|
||||
return n < 10 ? '0' + n : '' + n
|
||||
}
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
function slotLabel(h: number, m: number): string {
|
||||
return `${pad(h)}:${pad(m)}`
|
||||
}
|
||||
|
||||
function minutesToLabel(min: number): string {
|
||||
return slotLabel(Math.floor(min / 60), min % 60)
|
||||
}
|
||||
|
||||
function labelToMinutes(label: string): number {
|
||||
const [h, m] = label.split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
|
||||
function startOfWeek(d: Date): Date {
|
||||
const day = d.getDay()
|
||||
const diff = (day === 0 ? -6 : 1) - day
|
||||
const monday = new Date(d)
|
||||
monday.setHours(0, 0, 0, 0)
|
||||
monday.setDate(d.getDate() + diff)
|
||||
return monday
|
||||
}
|
||||
|
||||
const weekStartDate = computed(() => {
|
||||
const base = startOfWeek(today)
|
||||
base.setDate(base.getDate() + weekOffset.value * 7)
|
||||
return base
|
||||
})
|
||||
|
||||
const weekDays = computed(() => {
|
||||
const days: Date[] = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(weekStartDate.value)
|
||||
d.setDate(d.getDate() + i)
|
||||
days.push(d)
|
||||
}
|
||||
return days
|
||||
})
|
||||
|
||||
const visibleDays = computed(() => (isMobile.value ? [weekDays.value[mobileDayIndex.value]] : weekDays.value))
|
||||
|
||||
const slots = computed(() => {
|
||||
const list: { h: number; m: number }[] = []
|
||||
const totalMinutes = (endHour.value - startHour.value) * 60
|
||||
for (let i = 0; i < totalMinutes / 30; i++) {
|
||||
const min = startHour.value * 60 + i * 30
|
||||
list.push({ h: Math.floor(min / 60), m: min % 60 })
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const DAY_NAMES = ['Dum', 'Lun', 'Mar', 'Mie', 'Joi', 'Vin', 'Sâm']
|
||||
|
||||
function dayName(d: Date): string {
|
||||
return DAY_NAMES[d.getDay()]
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
const weekRangeLabel = computed(() => {
|
||||
const start = weekDays.value[0]
|
||||
const end = weekDays.value[6]
|
||||
const MONTHS = ['ian', 'feb', 'mar', 'apr', 'mai', 'iun', 'iul', 'aug', 'sep', 'oct', 'nov', 'dec']
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
return `${start.getDate()} - ${end.getDate()} ${MONTHS[end.getMonth()]} ${end.getFullYear()}`
|
||||
}
|
||||
return `${start.getDate()} ${MONTHS[start.getMonth()]} - ${end.getDate()} ${MONTHS[end.getMonth()]} ${end.getFullYear()}`
|
||||
})
|
||||
|
||||
function isBusy(day: Date, h: number, m: number): boolean {
|
||||
const slotStart = new Date(day)
|
||||
slotStart.setHours(h, m, 0, 0)
|
||||
const slotEnd = new Date(slotStart.getTime() + 30 * 60000)
|
||||
return busy.value.some((b) => {
|
||||
const bs = new Date(b.start_time)
|
||||
const be = new Date(b.end_time)
|
||||
return slotStart < be && slotEnd > bs
|
||||
})
|
||||
}
|
||||
|
||||
function isSelected(day: Date, h: number, m: number): boolean {
|
||||
if (!selection.value) return false
|
||||
if (selection.value.date !== fmtDate(day)) return false
|
||||
const min = h * 60 + m
|
||||
const start = labelToMinutes(selection.value.start)
|
||||
const end = labelToMinutes(selection.value.end)
|
||||
return min >= start && min < end
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
if (!visibleDays.value.length) return false
|
||||
return visibleDays.value.every((day) => slots.value.every((s) => isBusy(day, s.h, s.m)))
|
||||
})
|
||||
|
||||
function clampEndForBusy(day: Date, startMin: number, desiredEndMin: number): number {
|
||||
let end = startMin + 30
|
||||
for (let cur = startMin; cur < desiredEndMin; cur += 30) {
|
||||
const h = Math.floor(cur / 60)
|
||||
const m = cur % 60
|
||||
if (isBusy(day, h, m)) break
|
||||
end = cur + 30
|
||||
}
|
||||
return Math.max(end, startMin + 30)
|
||||
}
|
||||
|
||||
function onSlotClick(day: Date, h: number, m: number) {
|
||||
if (isBusy(day, h, m)) return
|
||||
const dateStr = fmtDate(day)
|
||||
const startLabel = slotLabel(h, m)
|
||||
const startMin = h * 60 + m
|
||||
const maxMin = endHour.value * 60
|
||||
|
||||
if (!selection.value || selection.value.date !== dateStr) {
|
||||
const desiredEnd = Math.min(startMin + 60, maxMin)
|
||||
const end = clampEndForBusy(day, startMin, desiredEnd)
|
||||
selection.value = { date: dateStr, start: startLabel, end: minutesToLabel(end) }
|
||||
return
|
||||
}
|
||||
|
||||
if (selection.value.start === startLabel) {
|
||||
selection.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const curStartMin = labelToMinutes(selection.value.start)
|
||||
if (startMin < curStartMin) {
|
||||
const desiredEnd = Math.min(startMin + 60, maxMin)
|
||||
const end = clampEndForBusy(day, startMin, desiredEnd)
|
||||
selection.value = { date: dateStr, start: startLabel, end: minutesToLabel(end) }
|
||||
} else {
|
||||
const desiredEnd = Math.min(startMin + 30, maxMin)
|
||||
const end = clampEndForBusy(day, curStartMin, desiredEnd)
|
||||
selection.value = { ...selection.value, end: minutesToLabel(end) }
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBusy() {
|
||||
const reqId = ++requestSeq
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const start = `${fmtDate(weekStartDate.value)}T00:00:00`
|
||||
const endDate = new Date(weekStartDate.value)
|
||||
endDate.setDate(endDate.getDate() + 7)
|
||||
const end = `${fmtDate(endDate)}T00:00:00`
|
||||
const result = await publicApi.getBusy(props.spaceId, start, end)
|
||||
if (reqId !== requestSeq) return
|
||||
busy.value = result
|
||||
} catch (err) {
|
||||
if (reqId !== requestSeq) return
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
if (reqId === requestSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prevWeek() {
|
||||
weekOffset.value -= 1
|
||||
selection.value = null
|
||||
fetchBusy()
|
||||
}
|
||||
|
||||
function nextWeek() {
|
||||
weekOffset.value += 1
|
||||
selection.value = null
|
||||
fetchBusy()
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
weekOffset.value = 0
|
||||
mobileDayIndex.value = weekDays.value.findIndex((d) => isSameDay(d, today))
|
||||
if (mobileDayIndex.value < 0) mobileDayIndex.value = 0
|
||||
selection.value = null
|
||||
fetchBusy()
|
||||
}
|
||||
|
||||
function markOccupied(sel: { date: string; start: string; end: string } | null) {
|
||||
if (!sel) return
|
||||
busy.value = [...busy.value, { start_time: `${sel.date}T${sel.start}:00`, end_time: `${sel.date}T${sel.end}:00` }]
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.spaceId,
|
||||
() => {
|
||||
selection.value = null
|
||||
fetchBusy()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
mobileDayIndex.value = weekDays.value.findIndex((d) => isSameDay(d, today))
|
||||
if (mobileDayIndex.value < 0) mobileDayIndex.value = 0
|
||||
window.addEventListener('resize', handleResize)
|
||||
fetchBusy()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
requestSeq++
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refreshBusy: fetchBusy,
|
||||
markOccupied,
|
||||
clearSelection: () => {
|
||||
selection.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.public-calendar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.week-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.week-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 160px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-today {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-today:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.day-picker {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.day-chip {
|
||||
min-width: 52px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.day-chip.today {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.day-chip.active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.day-chip.active .day-name,
|
||||
.day-chip.active .day-num {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.day-num {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.cal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-left: 3px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cal-empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cal-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-secondary) 25%,
|
||||
var(--color-bg-tertiary) 37%,
|
||||
var(--color-bg-secondary) 63%
|
||||
);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-loading 1.4s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 56px repeat(7, 1fr);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: auto;
|
||||
max-height: 560px;
|
||||
}
|
||||
|
||||
.cal-grid.mobile {
|
||||
grid-template-columns: 56px 1fr;
|
||||
}
|
||||
|
||||
.cal-times {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-secondary);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cal-corner {
|
||||
height: 32px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.time-label {
|
||||
height: 28px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
padding-right: 6px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cal-day-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.cal-day-head {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cal-day-head.today {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.slot {
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slot.slot-hour {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.slot:hover:not(.busy) {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.slot.busy {
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slot-tag {
|
||||
font-size: 9px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.slot.selected {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.slot {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.time-label {
|
||||
height: 44px;
|
||||
}
|
||||
.cal-grid {
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user