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:
@@ -46,6 +46,7 @@
|
||||
<div class="hero-cta">
|
||||
<router-link to="/register" class="btn btn-primary btn-lg">{{ ctaText }}</router-link>
|
||||
<a href="#cum" class="btn btn-secondary btn-lg">Vezi cum arată</a>
|
||||
<router-link to="/book" class="btn btn-ghost btn-lg">Rezervă fără cont</router-link>
|
||||
</div>
|
||||
<p class="hero-note">Cont gratuit, gata în câteva secunde.</p>
|
||||
</div>
|
||||
@@ -232,6 +233,39 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PUBLIC PROPERTIES -->
|
||||
<section v-if="showLandingProperties" class="landing-properties">
|
||||
<h2>Spații deschise rezervării acum</h2>
|
||||
<p class="landing-properties-sub">Alege un spațiu și rezervă fără cont, în câteva minute.</p>
|
||||
|
||||
<div class="lp-grid">
|
||||
<template v-if="loadingProperties">
|
||||
<div v-for="i in 3" :key="`sk-${i}`" class="lp-card lp-skeleton">
|
||||
<div class="lp-skel-line lp-skel-title"></div>
|
||||
<div class="lp-skel-line lp-skel-sub"></div>
|
||||
<div class="lp-skel-line lp-skel-sub short"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link
|
||||
v-for="p in landingProperties"
|
||||
:key="p.id"
|
||||
:to="`/book/${p.slug || p.id}`"
|
||||
class="lp-card"
|
||||
>
|
||||
<div class="lp-card-head">
|
||||
<h3>{{ p.name }}</h3>
|
||||
<span v-if="p.slug === 'proprietate-demo'" class="lp-badge">Demo</span>
|
||||
</div>
|
||||
<p v-if="p.address" class="lp-address"><MapPin :size="14" />{{ p.address }}</p>
|
||||
<p class="lp-count">{{ p.space_count ?? 0 }} {{ (p.space_count ?? 0) === 1 ? 'spațiu' : 'spații' }}</p>
|
||||
<p v-if="p.slug === 'proprietate-demo'" class="lp-demo-note">se resetează la 3 ore</p>
|
||||
<span class="lp-cta">Rezervă acum →</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA BAND -->
|
||||
<section id="start" class="cta-band">
|
||||
<div class="cta-inner">
|
||||
@@ -262,8 +296,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useLandingTheme } from '@/composables/useLandingTheme'
|
||||
import { publicApi } from '@/services/api'
|
||||
import type { Property } from '@/types'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
@@ -350,6 +386,29 @@ const selectedLabel = computed(() => {
|
||||
const s = slots[selected.value]
|
||||
return s.booked ? `Booked · ${s.time}` : `Selected · ${s.time}`
|
||||
})
|
||||
|
||||
/* ── Public properties open for booking ── */
|
||||
const loadingProperties = ref(true)
|
||||
const landingProperties = ref<Property[]>([])
|
||||
const propertiesFetchFailed = ref(false)
|
||||
|
||||
const showLandingProperties = computed(() => {
|
||||
if (loadingProperties.value) return true
|
||||
if (propertiesFetchFailed.value) return false
|
||||
return landingProperties.value.length > 0
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const props = await publicApi.getProperties()
|
||||
landingProperties.value = (props || []).filter((p) => p.list_on_landing === true)
|
||||
} catch {
|
||||
propertiesFetchFailed.value = true
|
||||
landingProperties.value = []
|
||||
} finally {
|
||||
loadingProperties.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -473,6 +532,12 @@ const selectedLabel = computed(() => {
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
.btn-secondary:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-color: transparent;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--brand); border-color: var(--border-default); }
|
||||
|
||||
/* ── Nav ── */
|
||||
.nav {
|
||||
@@ -630,6 +695,116 @@ const selectedLabel = computed(() => {
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
|
||||
/* ── Public properties open for booking ── */
|
||||
.landing-properties {
|
||||
max-width: var(--container);
|
||||
margin: 0 auto;
|
||||
padding: 8px 32px 64px;
|
||||
}
|
||||
.landing-properties h2 {
|
||||
font-size: 1.875rem;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.landing-properties-sub {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.lp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.lp-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 22px 22px 20px;
|
||||
text-decoration: none;
|
||||
color: var(--text-body);
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
.lp-card:hover {
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.lp-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.lp-card-head h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
margin: 0;
|
||||
}
|
||||
.lp-badge {
|
||||
flex: none;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 9px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--brand) 16%, var(--surface-card));
|
||||
color: var(--brand);
|
||||
}
|
||||
.lp-address {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.lp-address svg { flex: none; color: var(--text-subtle); }
|
||||
.lp-count {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-subtle);
|
||||
margin: 0;
|
||||
}
|
||||
.lp-demo-note {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--text-subtle);
|
||||
margin: 0;
|
||||
}
|
||||
.lp-cta {
|
||||
margin-top: 10px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
}
|
||||
.lp-skeleton {
|
||||
gap: 10px;
|
||||
cursor: default;
|
||||
}
|
||||
.lp-skel-line {
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--border-subtle);
|
||||
animation: lp-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.lp-skel-title { width: 60%; height: 20px; }
|
||||
.lp-skel-sub { width: 85%; }
|
||||
.lp-skel-sub.short { width: 40%; }
|
||||
@keyframes lp-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.lp-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.lp-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── CTA band (subtle accent-tinted surface — accent stays on the button) ── */
|
||||
.cta-band {
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--card));
|
||||
|
||||
@@ -55,6 +55,16 @@
|
||||
<div class="property-footer">
|
||||
<span class="space-count">{{ prop.space_count || 0 }} spaces</span>
|
||||
<div class="property-actions" @click.stop>
|
||||
<button
|
||||
class="btn-icon"
|
||||
:class="{ 'btn-icon-success': copiedId === prop.id }"
|
||||
:disabled="!canCopyLink(prop)"
|
||||
:title="canCopyLink(prop) ? (copiedId === prop.id ? 'Copiat ✓' : 'Copiază link') : 'Proprietatea trebuie să fie publică pentru un link public'"
|
||||
@click="copyLink(prop)"
|
||||
>
|
||||
<Check v-if="copiedId === prop.id" :size="15" />
|
||||
<Link2 v-else :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-icon"
|
||||
:title="prop.is_active ? 'Deactivate' : 'Activate'"
|
||||
@@ -136,7 +146,7 @@ import { useRouter } from 'vue-router'
|
||||
import { propertiesApi, handleApiError } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||
import { Landmark, Plus, PowerOff, Trash2 } from 'lucide-vue-next'
|
||||
import { Landmark, Plus, PowerOff, Trash2, Link2, Check } from 'lucide-vue-next'
|
||||
import type { Property } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -260,6 +270,37 @@ const goToProperty = (id: number) => {
|
||||
router.push(`/properties/${id}`)
|
||||
}
|
||||
|
||||
const copiedId = ref<number | null>(null)
|
||||
|
||||
const canCopyLink = (prop: Property) => {
|
||||
return !!prop.is_public && !!prop.slug
|
||||
}
|
||||
|
||||
const copyLink = async (prop: Property) => {
|
||||
if (!canCopyLink(prop)) return
|
||||
const url = `${window.location.origin}/book/${prop.slug}`
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
} else {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = url
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
copiedId.value = prop.id
|
||||
setTimeout(() => {
|
||||
if (copiedId.value === prop.id) copiedId.value = null
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProperties()
|
||||
})
|
||||
@@ -491,6 +532,24 @@ onMounted(() => {
|
||||
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon-success {
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
background: color-mix(in srgb, var(--color-success) 8%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.btn-icon:disabled:hover {
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
|
||||
@@ -47,6 +47,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Link Card -->
|
||||
<div class="public-link-card">
|
||||
<template v-if="property.is_public">
|
||||
<div class="public-link-header">
|
||||
<h3>Link public de rezervare</h3>
|
||||
</div>
|
||||
<div class="public-link-row">
|
||||
<code class="public-link-url">{{ publicBookingUrl }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="copyPublicLink">
|
||||
{{ copied ? 'Copiat ✓' : 'Copiază' }}
|
||||
</button>
|
||||
<a :href="publicBookingUrl" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
|
||||
Deschide pagina publică
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="slug-edit">
|
||||
<template v-if="!editingSlug">
|
||||
<button class="btn-link" @click="startEditSlug">Editează slug</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="slug-edit-row">
|
||||
<input
|
||||
v-model="slugInput"
|
||||
type="text"
|
||||
class="slug-input"
|
||||
placeholder="slug-proprietate"
|
||||
:disabled="savingSlug"
|
||||
/>
|
||||
<button class="btn btn-primary btn-sm" :disabled="savingSlug" @click="saveSlug">
|
||||
{{ savingSlug ? 'Se salvează...' : 'Salvează' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="savingSlug" @click="cancelEditSlug">
|
||||
Anulează
|
||||
</button>
|
||||
</div>
|
||||
<div class="slug-hint">Doar litere mici, cifre și cratime, 3–64 caractere.</div>
|
||||
<div v-if="slugError" class="error-inline">{{ slugError }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="qr-section">
|
||||
<canvas ref="qrCanvas" class="qr-canvas"></canvas>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="!qrReady" @click="downloadQr">
|
||||
Descarcă QR (PNG)
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="public-link-header">
|
||||
<h3>Link public de rezervare</h3>
|
||||
</div>
|
||||
<p class="info-msg">Această proprietate este privată. Fă-o publică pentru a obține un link de rezervare și un cod QR.</p>
|
||||
<button class="btn btn-primary btn-sm" :disabled="makingPublic" @click="makePublic">
|
||||
{{ makingPublic ? 'Se procesează...' : 'Fă-o publică' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
@@ -226,6 +285,12 @@
|
||||
Public
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group form-checkbox">
|
||||
<label>
|
||||
<input type="checkbox" v-model="editForm.list_on_landing" />
|
||||
Afișează pe pagina principală
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="editError" class="error">{{ editError }}</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="editSubmitting">Save</button>
|
||||
@@ -275,7 +340,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { propertiesApi, adminBookingsApi, spacesApi, usersApi, handleApiError } from '@/services/api'
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||
@@ -298,7 +363,27 @@ const editSubmitting = ref(false)
|
||||
const editError = ref('')
|
||||
const toast = ref('')
|
||||
|
||||
const editForm = ref({ name: '', description: '', address: '', is_public: false })
|
||||
const editForm = ref({ name: '', description: '', address: '', is_public: false, list_on_landing: false })
|
||||
|
||||
// Public link card state
|
||||
const copied = ref(false)
|
||||
const editingSlug = ref(false)
|
||||
const slugInput = ref('')
|
||||
const savingSlug = ref(false)
|
||||
const slugError = ref('')
|
||||
const makingPublic = ref(false)
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const qrReady = ref(false)
|
||||
|
||||
const publicBookingUrl = computed(() => {
|
||||
if (!property.value?.slug) return ''
|
||||
return `${window.location.origin}/book/${property.value.slug}`
|
||||
})
|
||||
|
||||
const publicBookingIdUrl = computed(() => {
|
||||
if (!property.value) return ''
|
||||
return `${window.location.origin}/book/${property.value.id}`
|
||||
})
|
||||
|
||||
// Create Space state
|
||||
const showCreateSpaceModal = ref(false)
|
||||
@@ -340,9 +425,11 @@ const loadProperty = async () => {
|
||||
name: property.value.name,
|
||||
description: property.value.description || '',
|
||||
address: property.value.address || '',
|
||||
is_public: property.value.is_public
|
||||
is_public: property.value.is_public,
|
||||
list_on_landing: property.value.list_on_landing || false
|
||||
}
|
||||
await loadTabData()
|
||||
await renderQr()
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
@@ -482,6 +569,109 @@ const confirmDeleteSpace = async (sp: Space) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Public link card
|
||||
const copyPublicLink = async () => {
|
||||
const url = publicBookingUrl.value
|
||||
if (!url) return
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
} else {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = url
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
// ignore copy failures silently
|
||||
}
|
||||
}
|
||||
|
||||
const startEditSlug = () => {
|
||||
slugInput.value = property.value?.slug || ''
|
||||
slugError.value = ''
|
||||
editingSlug.value = true
|
||||
}
|
||||
|
||||
const cancelEditSlug = () => {
|
||||
editingSlug.value = false
|
||||
slugError.value = ''
|
||||
}
|
||||
|
||||
const saveSlug = async () => {
|
||||
if (!property.value) return
|
||||
const newSlug = slugInput.value.trim()
|
||||
if (newSlug === property.value.slug) {
|
||||
editingSlug.value = false
|
||||
return
|
||||
}
|
||||
if (!confirm('Linkurile și codurile QR cu slug-ul vechi nu vor mai funcționa. Continui?')) return
|
||||
savingSlug.value = true
|
||||
slugError.value = ''
|
||||
try {
|
||||
property.value = await propertiesApi.update(propertyId.value, { slug: newSlug })
|
||||
editingSlug.value = false
|
||||
showToast('Slug actualizat!')
|
||||
await renderQr()
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 409) {
|
||||
slugError.value = 'Slug deja folosit'
|
||||
} else if (err?.response?.status === 422) {
|
||||
slugError.value = 'Format invalid: doar litere mici, cifre și cratime, 3–64 caractere'
|
||||
} else {
|
||||
slugError.value = handleApiError(err)
|
||||
}
|
||||
} finally {
|
||||
savingSlug.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const makePublic = async () => {
|
||||
if (!property.value) return
|
||||
makingPublic.value = true
|
||||
try {
|
||||
property.value = await propertiesApi.update(propertyId.value, { is_public: true })
|
||||
showToast('Proprietate făcută publică!')
|
||||
await renderQr()
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
makingPublic.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const renderQr = async () => {
|
||||
qrReady.value = false
|
||||
if (!property.value?.is_public) return
|
||||
await nextTick()
|
||||
if (!qrCanvas.value) return
|
||||
try {
|
||||
const QRCode = await import('qrcode')
|
||||
await QRCode.toCanvas(qrCanvas.value, publicBookingIdUrl.value, {
|
||||
width: 512,
|
||||
margin: 4
|
||||
})
|
||||
qrReady.value = true
|
||||
} catch {
|
||||
qrReady.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const downloadQr = () => {
|
||||
if (!qrCanvas.value || !qrReady.value) return
|
||||
const link = document.createElement('a')
|
||||
link.download = `qr-${property.value?.slug || property.value?.id}.png`
|
||||
link.href = qrCanvas.value.toDataURL('image/png')
|
||||
link.click()
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
const formatDate = (dt: string) => {
|
||||
if (!dt) return ''
|
||||
@@ -667,6 +857,107 @@ onMounted(() => loadProperty())
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Public Link Card */
|
||||
.public-link-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.public-link-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.public-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.public-link-url {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slug-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.slug-edit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.slug-input {
|
||||
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);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.slug-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.slug-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qr-canvas {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -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">✓</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">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">
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user