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>
588 lines
14 KiB
Vue
588 lines
14 KiB
Vue
<template>
|
|
<div class="public-booking-page">
|
|
<!-- Property picker (no slug in route) -->
|
|
<div v-if="phase === 'picker'" class="picker-wrap">
|
|
<div class="picker-card card">
|
|
<h2>Rezervă un spațiu</h2>
|
|
<p class="subtitle">Alege o proprietate pentru a continua rezervarea, fără cont</p>
|
|
<div v-if="loadingPicker" class="loading-inline">Se încarcă proprietățile...</div>
|
|
<div v-else-if="pickerError" class="error-banner">
|
|
{{ pickerError }}
|
|
<button class="btn-retry" @click="loadPicker">Reîncearcă</button>
|
|
</div>
|
|
<div v-else-if="pickerProperties.length === 0" class="empty-msg">
|
|
Nu există proprietăți publice disponibile momentan.
|
|
</div>
|
|
<div v-else class="property-list">
|
|
<div
|
|
v-for="prop in pickerProperties"
|
|
:key="prop.id"
|
|
class="selectable-card"
|
|
@click="goToProperty(prop)"
|
|
>
|
|
<h4>{{ prop.name }}</h4>
|
|
<p v-if="prop.description" class="card-desc">{{ prop.description }}</p>
|
|
<p v-if="prop.address" class="card-meta">{{ prop.address }}</p>
|
|
<span class="card-count">{{ prop.space_count || 0 }} spații</span>
|
|
</div>
|
|
</div>
|
|
<p class="login-hint">
|
|
Ai deja cont? <router-link to="/login">Autentifică-te</router-link>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resolving property -->
|
|
<div v-else-if="phase === 'resolving'" class="state-wrap">
|
|
<div class="loading-inline">Se încarcă pagina de rezervare...</div>
|
|
</div>
|
|
|
|
<!-- Not found / private -->
|
|
<div v-else-if="phase === 'notfound'" class="state-wrap">
|
|
<div class="state-card card">
|
|
<h3>Pagina de rezervare nu mai e disponibilă</h3>
|
|
<p>Linkul folosit nu (mai) corespunde unei proprietăți publice.</p>
|
|
<router-link to="/book" class="btn btn-primary">Vezi proprietăți disponibile</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load error -->
|
|
<div v-else-if="phase === 'error'" class="state-wrap">
|
|
<div class="state-card card">
|
|
<h3>A apărut o eroare</h3>
|
|
<p>{{ loadError }}</p>
|
|
<button class="btn btn-primary" @click="init">Reîncearcă</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ready: calendar booking -->
|
|
<div v-else-if="phase === 'ready'" class="booking-wrap">
|
|
<div v-if="isDemoProperty" class="demo-banner">
|
|
Mediu demo — rezervările se șterg automat la 3 ore.
|
|
</div>
|
|
|
|
<header class="booking-header">
|
|
<h2>{{ property?.name }}</h2>
|
|
<p v-if="property?.address" class="address">{{ property.address }}</p>
|
|
<p v-if="property?.require_approval" class="approval-note">Necesită aprobare</p>
|
|
</header>
|
|
|
|
<div v-if="successBooking" class="success-state card">
|
|
<div class="success-icon">✓</div>
|
|
<h3>Cererea a fost trimisă!</h3>
|
|
<p>
|
|
Vei primi actualizări la <strong>{{ successBooking.guest_email }}</strong>.
|
|
</p>
|
|
<p class="ref-code">Cod de referință: <strong>#RB-{{ successBooking.id }}</strong></p>
|
|
<button class="btn btn-primary" @click="bookAnother">Fă o altă rezervare</button>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div v-if="loadingSpaces" class="loading-inline">Se încarcă spațiile...</div>
|
|
<div v-else-if="spaces.length === 0" class="empty-msg">
|
|
Această proprietate nu are spații disponibile pentru rezervare.
|
|
</div>
|
|
<template v-else>
|
|
<div class="space-tabs-row">
|
|
<div class="space-tabs">
|
|
<button
|
|
v-for="sp in visibleTabs"
|
|
:key="sp.id"
|
|
type="button"
|
|
class="space-tab"
|
|
:class="{ active: selectedSpace?.id === sp.id }"
|
|
@click="setSpace(sp)"
|
|
>
|
|
{{ sp.name }}
|
|
</button>
|
|
</div>
|
|
<select
|
|
v-if="spaces.length > 6"
|
|
class="space-select"
|
|
:value="selectedSpace?.id"
|
|
@change="onSelectChange"
|
|
>
|
|
<option v-for="sp in spaces" :key="sp.id" :value="sp.id">{{ sp.name }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="booking-layout" :class="{ 'has-selection': !!selection }">
|
|
<div class="calendar-pane">
|
|
<PublicCalendar
|
|
v-if="selectedSpace"
|
|
ref="calendarRef"
|
|
v-model:selection="selection"
|
|
:space-id="selectedSpace.id"
|
|
:working-hours-start="selectedSpace.working_hours_start"
|
|
:working-hours-end="selectedSpace.working_hours_end"
|
|
/>
|
|
</div>
|
|
<div class="form-pane" ref="formPaneRef">
|
|
<GuestBookingForm
|
|
v-if="selectedSpace"
|
|
:space-id="selectedSpace.id"
|
|
:selection="selection"
|
|
:submitting="submitting"
|
|
:submit-error="submitError"
|
|
:require-approval="!!property?.require_approval"
|
|
:busy-loaded="true"
|
|
@submit="handleSubmit"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<p class="login-hint">
|
|
Ai deja cont? <router-link to="/login">Autentifică-te</router-link>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { AxiosError } from 'axios'
|
|
import { publicApi, handleApiError } from '@/services/api'
|
|
import type { Property, Space, Booking, AnonymousBookingCreate } from '@/types'
|
|
import PublicCalendar from '@/components/PublicCalendar.vue'
|
|
import GuestBookingForm from '@/components/GuestBookingForm.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
type Phase = 'picker' | 'resolving' | 'notfound' | 'error' | 'ready'
|
|
|
|
const phase = ref<Phase>('resolving')
|
|
const loadError = ref('')
|
|
|
|
const loadingPicker = ref(false)
|
|
const pickerError = ref('')
|
|
const pickerProperties = ref<Property[]>([])
|
|
|
|
const property = ref<Property | null>(null)
|
|
const spaces = ref<Space[]>([])
|
|
const loadingSpaces = ref(false)
|
|
const selectedSpace = ref<Space | null>(null)
|
|
|
|
const selection = ref<{ date: string; start: string; end: string } | null>(null)
|
|
const submitting = ref(false)
|
|
const submitError = ref('')
|
|
const successBooking = ref<Booking | null>(null)
|
|
|
|
const calendarRef = ref<InstanceType<typeof PublicCalendar> | null>(null)
|
|
const formPaneRef = ref<HTMLElement | null>(null)
|
|
|
|
const isDemoProperty = computed(() => property.value?.slug === 'proprietate-demo')
|
|
const visibleTabs = computed(() => spaces.value.slice(0, 6))
|
|
|
|
async function loadPicker() {
|
|
loadingPicker.value = true
|
|
pickerError.value = ''
|
|
try {
|
|
pickerProperties.value = await publicApi.getProperties()
|
|
} catch (err) {
|
|
pickerError.value = handleApiError(err)
|
|
} finally {
|
|
loadingPicker.value = false
|
|
}
|
|
}
|
|
|
|
function goToProperty(prop: Property) {
|
|
router.push(`/book/${prop.slug || prop.id}`)
|
|
}
|
|
|
|
async function loadSpaces(slugOrId: string) {
|
|
loadingSpaces.value = true
|
|
try {
|
|
spaces.value = await publicApi.getPropertySpaces(slugOrId)
|
|
selectedSpace.value = spaces.value[0] ?? null
|
|
} catch (err) {
|
|
spaces.value = []
|
|
} finally {
|
|
loadingSpaces.value = false
|
|
}
|
|
}
|
|
|
|
async function resolveProperty(slugOrId: string) {
|
|
phase.value = 'resolving'
|
|
loadError.value = ''
|
|
try {
|
|
property.value = await publicApi.getProperty(slugOrId)
|
|
await loadSpaces(slugOrId)
|
|
phase.value = 'ready'
|
|
} catch (err) {
|
|
if (err instanceof AxiosError && err.response?.status === 404) {
|
|
phase.value = 'notfound'
|
|
} else {
|
|
loadError.value = handleApiError(err)
|
|
phase.value = 'error'
|
|
}
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
const slug = route.params.slug as string | undefined
|
|
if (slug) {
|
|
resolveProperty(slug)
|
|
} else {
|
|
phase.value = 'picker'
|
|
loadPicker()
|
|
}
|
|
}
|
|
|
|
function setSpace(sp: Space) {
|
|
selectedSpace.value = sp
|
|
selection.value = null
|
|
submitError.value = ''
|
|
}
|
|
|
|
function onSelectChange(e: Event) {
|
|
const id = Number((e.target as HTMLSelectElement).value)
|
|
const sp = spaces.value.find((s) => s.id === id)
|
|
if (sp) setSpace(sp)
|
|
}
|
|
|
|
async function handleSubmit(payload: AnonymousBookingCreate) {
|
|
submitting.value = true
|
|
submitError.value = ''
|
|
try {
|
|
const booking = await publicApi.createBooking(payload)
|
|
successBooking.value = booking
|
|
selection.value = null
|
|
} catch (err) {
|
|
if (err instanceof AxiosError && (err.response?.status === 400 || err.response?.status === 409)) {
|
|
submitError.value = 'Slotul tocmai a fost ocupat, alege altul'
|
|
calendarRef.value?.markOccupied(selection.value)
|
|
selection.value = null
|
|
calendarRef.value?.refreshBusy()
|
|
} else {
|
|
submitError.value = handleApiError(err)
|
|
}
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function bookAnother() {
|
|
successBooking.value = null
|
|
selection.value = null
|
|
submitError.value = ''
|
|
}
|
|
|
|
watch(selection, (sel) => {
|
|
if (sel && window.innerWidth < 768) {
|
|
nextTick(() => {
|
|
formPaneRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
})
|
|
}
|
|
})
|
|
|
|
watch(
|
|
() => route.params.slug,
|
|
() => init()
|
|
)
|
|
|
|
init()
|
|
</script>
|
|
|
|
<style scoped>
|
|
.public-booking-page {
|
|
min-height: 100vh;
|
|
background: var(--color-bg-primary);
|
|
padding: 1.5rem clamp(1rem, 3vw, 2.5rem) 3rem;
|
|
}
|
|
|
|
.picker-wrap {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding-top: 1.5rem;
|
|
}
|
|
|
|
.picker-card {
|
|
width: 100%;
|
|
max-width: 560px;
|
|
}
|
|
|
|
.state-wrap {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 60vh;
|
|
}
|
|
|
|
.state-card {
|
|
max-width: 480px;
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
h2 {
|
|
color: var(--color-text-primary);
|
|
margin: 0 0 0.25rem;
|
|
}
|
|
|
|
.subtitle {
|
|
color: var(--color-text-secondary);
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.loading-inline {
|
|
text-align: center;
|
|
padding: 24px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.empty-msg {
|
|
text-align: center;
|
|
padding: 24px;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.error-banner {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
align-items: center;
|
|
padding: 16px;
|
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
.btn-retry {
|
|
background: var(--color-danger);
|
|
color: white;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
padding: 6px 14px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.property-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.selectable-card {
|
|
padding: 16px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
transition: all var(--transition-fast);
|
|
background: var(--color-bg-secondary);
|
|
}
|
|
|
|
.selectable-card:hover {
|
|
border-color: var(--color-accent);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.selectable-card h4 {
|
|
margin: 0 0 4px;
|
|
font-size: 16px;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.card-desc {
|
|
font-size: 14px;
|
|
color: var(--color-text-secondary);
|
|
margin: 0 0 4px;
|
|
}
|
|
|
|
.card-meta {
|
|
font-size: 13px;
|
|
color: var(--color-text-muted);
|
|
margin: 0 0 4px;
|
|
}
|
|
|
|
.card-count {
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--color-accent);
|
|
}
|
|
|
|
.booking-wrap {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.demo-banner {
|
|
padding: 10px 16px;
|
|
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
|
border-left: 3px solid var(--color-warning);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-text-primary);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.booking-header .address {
|
|
color: var(--color-text-secondary);
|
|
margin: 2px 0;
|
|
}
|
|
|
|
.approval-note {
|
|
display: inline-block;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--color-warning);
|
|
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
|
padding: 3px 10px;
|
|
border-radius: 999px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.space-tabs-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.space-tabs {
|
|
display: flex;
|
|
gap: 8px;
|
|
overflow-x: auto;
|
|
padding-bottom: 4px;
|
|
flex: 1;
|
|
}
|
|
|
|
.space-tab {
|
|
flex-shrink: 0;
|
|
padding: 10px 16px;
|
|
min-height: 44px;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-surface);
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
color: var(--color-text-secondary);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.space-tab.active {
|
|
background: var(--color-accent);
|
|
border-color: var(--color-accent);
|
|
color: white;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.space-select {
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-surface);
|
|
color: var(--color-text-primary);
|
|
min-height: 44px;
|
|
}
|
|
|
|
.booking-layout {
|
|
display: grid;
|
|
grid-template-columns: 1.5fr 1fr;
|
|
gap: 1.5rem;
|
|
align-items: start;
|
|
}
|
|
|
|
.calendar-pane {
|
|
min-width: 0;
|
|
}
|
|
|
|
.form-pane {
|
|
position: sticky;
|
|
top: 1rem;
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.25rem;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.booking-layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.form-pane {
|
|
position: static;
|
|
}
|
|
}
|
|
|
|
.success-state {
|
|
text-align: center;
|
|
padding: 32px 24px;
|
|
max-width: 480px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.success-icon {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 50%;
|
|
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
|
color: var(--color-success);
|
|
font-size: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 0 auto 16px;
|
|
}
|
|
|
|
.success-state h3 {
|
|
color: var(--color-success);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.ref-code {
|
|
font-size: 14px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.login-hint {
|
|
text-align: center;
|
|
margin-top: 1.5rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid var(--color-border);
|
|
color: var(--color-text-secondary);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.login-hint a {
|
|
color: var(--color-accent);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.login-hint a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all var(--transition-fast);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--color-accent);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--color-accent-hover);
|
|
}
|
|
</style>
|