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

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