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

@@ -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>

View 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>