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:
Claude Agent
2026-07-11 19:04:58 +00:00
parent e38d78ecb8
commit a15951c645
27 changed files with 3311 additions and 375 deletions

View File

@@ -1,296 +1,336 @@
<template>
<div class="public-booking-container">
<div class="public-booking-card card">
<h2>Book a Space</h2>
<p class="subtitle">Reserve a meeting room or workspace without an account</p>
<!-- Step 1: Select Property -->
<div v-if="step === 'property'">
<div v-if="loadingProperties" class="loading-inline">Loading properties...</div>
<div v-else-if="properties.length === 0" class="empty-msg">No public properties available.</div>
<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 properties"
v-for="prop in pickerProperties"
:key="prop.id"
class="selectable-card"
@click="selectProperty(prop)"
@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 }} spaces</span>
<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>
<!-- Step 2: Select Space -->
<div v-else-if="step === 'space'">
<button class="btn-back" @click="step = 'property'">Back to properties</button>
<h3 class="step-title">{{ selectedProperty?.name }} - Choose a Space</h3>
<div v-if="loadingSpaces" class="loading-inline">Loading spaces...</div>
<div v-else-if="spaces.length === 0" class="empty-msg">No spaces available.</div>
<div v-else class="space-list">
<div
v-for="sp in spaces"
:key="sp.id"
class="selectable-card"
@click="selectSpace(sp)"
>
<h4>{{ sp.name }}</h4>
<div class="card-meta-row">
<span>{{ formatType(sp.type) }}</span>
<span>Capacity: {{ sp.capacity }}</span>
</div>
</div>
</div>
</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>
<!-- Step 3: Booking Form -->
<div v-else-if="step === 'form'">
<button class="btn-back" @click="step = 'space'">Back to spaces</button>
<h3 class="step-title">Book {{ selectedSpace?.name }}</h3>
<form @submit.prevent="handleSubmit" class="booking-form">
<div class="form-group">
<label for="guest_name">Your Name *</label>
<input id="guest_name" v-model="form.guest_name" type="text" required placeholder="John Doe" />
</div>
<div class="form-group">
<label for="guest_email">Your Email *</label>
<input id="guest_email" v-model="form.guest_email" type="email" required placeholder="john@example.com" />
</div>
<div class="form-group">
<label for="guest_organization">Organization (optional)</label>
<input id="guest_organization" v-model="form.guest_organization" type="text" placeholder="Company name" />
</div>
<div class="form-group">
<label for="title">Booking Title *</label>
<input id="title" v-model="form.title" type="text" required placeholder="Team meeting" />
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea id="description" v-model="form.description" rows="2" placeholder="Additional details..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="date">Date *</label>
<input id="date" v-model="form.date" type="date" required :min="minDate" />
</div>
<div class="form-group">
<label for="start_time">Start Time *</label>
<input id="start_time" v-model="form.start_time" type="time" required />
</div>
<div class="form-group">
<label for="end_time">End Time *</label>
<input id="end_time" v-model="form.end_time" type="time" required />
</div>
</div>
<div v-if="error" class="error">{{ error }}</div>
<button type="submit" class="btn btn-primary btn-block" :disabled="submitting">
{{ submitting ? 'Submitting...' : 'Submit Booking Request' }}
</button>
</form>
</div>
<!-- Step 4: Success -->
<div v-else-if="step === 'success'" class="success-state">
<div v-if="successBooking" class="success-state card">
<div class="success-icon">&#10003;</div>
<h3>Booking Request Sent!</h3>
<p>Your booking request has been submitted. You will receive updates at <strong>{{ form.guest_email }}</strong>.</p>
<button class="btn btn-primary" @click="resetForm">Book Another</button>
<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"> 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">
Already have an account? <router-link to="/login">Sign in</router-link>
Ai deja cont? <router-link to="/login">Autentifică-te</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
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 } from '@/types'
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()
const step = ref<'property' | 'space' | 'form' | 'success'>('property')
const loadingProperties = ref(false)
const loadingSpaces = ref(false)
const submitting = ref(false)
const error = ref('')
const properties = ref<Property[]>([])
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 selectedProperty = ref<Property | null>(null)
const loadingSpaces = ref(false)
const selectedSpace = ref<Space | null>(null)
const form = ref({
guest_name: '',
guest_email: '',
guest_organization: '',
title: '',
description: '',
date: '',
start_time: '',
end_time: ''
})
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 minDate = computed(() => new Date().toISOString().split('T')[0])
const calendarRef = ref<InstanceType<typeof PublicCalendar> | null>(null)
const formPaneRef = ref<HTMLElement | null>(null)
const formatType = (type: string): string => {
const map: Record<string, string> = {
desk: 'Desk', meeting_room: 'Meeting Room', conference_room: 'Conference Room',
sala: 'Sala', birou: 'Birou'
}
return map[type] || type
}
const isDemoProperty = computed(() => property.value?.slug === 'proprietate-demo')
const visibleTabs = computed(() => spaces.value.slice(0, 6))
const loadProperties = async () => {
loadingProperties.value = true
async function loadPicker() {
loadingPicker.value = true
pickerError.value = ''
try {
properties.value = await publicApi.getProperties()
// If propertyId in route, auto-select
const pid = route.params.propertyId
if (pid) {
const prop = properties.value.find(p => p.id === Number(pid))
if (prop) {
selectProperty(prop)
}
}
pickerProperties.value = await publicApi.getProperties()
} catch (err) {
error.value = handleApiError(err)
pickerError.value = handleApiError(err)
} finally {
loadingProperties.value = false
loadingPicker.value = false
}
}
const selectProperty = async (prop: Property) => {
selectedProperty.value = prop
step.value = 'space'
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(prop.id)
spaces.value = await publicApi.getPropertySpaces(slugOrId)
selectedSpace.value = spaces.value[0] ?? null
} catch (err) {
error.value = handleApiError(err)
spaces.value = []
} finally {
loadingSpaces.value = false
}
}
const selectSpace = (sp: Space) => {
selectedSpace.value = sp
step.value = 'form'
error.value = ''
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'
}
}
}
const handleSubmit = async () => {
error.value = ''
if (!selectedSpace.value) return
if (form.value.start_time >= form.value.end_time) {
error.value = 'End time must be after start time'
return
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 {
await publicApi.createBooking({
space_id: selectedSpace.value.id,
start_datetime: `${form.value.date}T${form.value.start_time}:00`,
end_datetime: `${form.value.date}T${form.value.end_time}: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
})
step.value = 'success'
const booking = await publicApi.createBooking(payload)
successBooking.value = booking
selection.value = null
} catch (err) {
error.value = handleApiError(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
}
}
const resetForm = () => {
step.value = 'property'
selectedProperty.value = null
selectedSpace.value = null
form.value = {
guest_name: '',
guest_email: '',
guest_organization: '',
title: '',
description: '',
date: '',
start_time: '',
end_time: ''
}
error.value = ''
function bookAnother() {
successBooking.value = null
selection.value = null
submitError.value = ''
}
onMounted(() => {
loadProperties()
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-container {
display: flex;
justify-content: center;
align-items: flex-start;
.public-booking-page {
min-height: 100vh;
padding: 2rem 1rem;
background: var(--color-bg-primary);
padding: 1.5rem clamp(1rem, 3vw, 2.5rem) 3rem;
}
.public-booking-card {
.picker-wrap {
display: flex;
justify-content: center;
padding-top: 1.5rem;
}
.picker-card {
width: 100%;
max-width: 560px;
}
h2 {
.state-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.state-card {
max-width: 480px;
text-align: center;
margin-bottom: 0.25rem;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
h2 {
color: var(--color-text-primary);
margin: 0 0 0.25rem;
}
.subtitle {
text-align: center;
color: var(--color-text-secondary);
margin-bottom: 1.5rem;
}
.step-title {
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 16px;
}
.btn-back {
background: none;
border: none;
color: var(--color-accent);
font-size: 14px;
cursor: pointer;
padding: 0;
margin-bottom: 12px;
font-weight: 500;
}
.btn-back:hover {
text-decoration: underline;
}
.loading-inline {
text-align: center;
padding: 24px;
@@ -303,7 +343,27 @@ h2 {
color: var(--color-text-muted);
}
.property-list, .space-list {
.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;
@@ -341,107 +401,123 @@ h2 {
margin: 0 0 4px;
}
.card-meta-row {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--color-text-secondary);
}
.card-count {
font-size: 12px;
font-weight: 500;
color: var(--color-accent);
}
.booking-form {
.booking-wrap {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
gap: 1.25rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
.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;
}
.form-group {
.space-tabs {
display: flex;
flex-direction: column;
gap: 4px;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
flex: 1;
}
.form-group label {
font-weight: 500;
.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-primary);
color: var(--color-text-secondary);
white-space: nowrap;
}
.form-group input,
.form-group textarea,
.form-group select {
.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);
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
font-family: inherit;
min-height: 44px;
}
.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);
.booking-layout {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 1.5rem;
align-items: start;
}
.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);
.calendar-pane {
min-width: 0;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
.form-pane {
position: sticky;
top: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-block {
width: 100%;
margin-top: 0.5rem;
}
.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;
@media (max-width: 900px) {
.booking-layout {
grid-template-columns: 1fr;
}
.form-pane {
position: static;
}
}
.success-state {
text-align: center;
padding: 24px 0;
padding: 32px 24px;
max-width: 480px;
margin: 0 auto;
}
.success-icon {
@@ -462,9 +538,9 @@ h2 {
margin-bottom: 8px;
}
.success-state p {
.ref-code {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: 20px;
}
.login-hint {
@@ -485,9 +561,27 @@ h2 {
text-decoration: underline;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
.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>