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

@@ -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, 364 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ă. -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ă...' : '-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, 364 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;