From a15951c645c0ce894d0b2a0947acf5b4994fc979 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sat, 11 Jul 2026 19:04:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20sistem=20de=20linkuri=20publice=20pentr?= =?UTF-8?q?u=20propriet=C4=83=C8=9Bi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expune fluxul de rezervare anonimă printr-un link public partajabil (/book/), 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- - 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 --- TODOS.md | 11 + backend/.gitignore | 3 + backend/app/api/bookings.py | 24 +- backend/app/api/properties.py | 49 +- backend/app/api/public.py | 311 +++++++-- backend/app/core/slug.py | 98 +++ backend/app/db/session.py | 16 +- backend/app/models/property.py | 2 + backend/app/schemas/booking.py | 6 +- backend/app/schemas/property.py | 6 + backend/app/services/email_service.py | 4 + backend/entrypoint.sh | 3 + backend/migrate_add_property_slug.py | 89 +++ backend/reset_demo.py | 5 +- backend/tests/test_migrate_slug.py | 119 ++++ backend/tests/test_public.py | 492 ++++++++++++++ frontend/package-lock.json | 274 +++++++- frontend/package.json | 2 + frontend/src/components/GuestBookingForm.vue | 337 ++++++++++ frontend/src/components/PublicCalendar.vue | 613 +++++++++++++++++ frontend/src/router/index.ts | 2 +- frontend/src/services/api.ts | 19 +- frontend/src/types/index.ts | 8 + frontend/src/views/Landing.vue | 177 ++++- frontend/src/views/Properties.vue | 61 +- frontend/src/views/PropertyDetail.vue | 297 ++++++++- frontend/src/views/PublicBooking.vue | 658 +++++++++++-------- 27 files changed, 3311 insertions(+), 375 deletions(-) create mode 100644 TODOS.md create mode 100644 backend/app/core/slug.py create mode 100644 backend/migrate_add_property_slug.py create mode 100644 backend/tests/test_migrate_slug.py create mode 100644 backend/tests/test_public.py create mode 100644 frontend/src/components/GuestBookingForm.vue create mode 100644 frontend/src/components/PublicCalendar.vue diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..b0de4ce --- /dev/null +++ b/TODOS.md @@ -0,0 +1,11 @@ +# TODOS + +## Din review-ul /autoplan „Linkuri publice" (2026-07-10) + +- [ ] **Istoric/alias slug după redenumire** (P3, M) — la editarea slug-ului, linkurile text vechi mor (QR-ul e pe ID și supraviețuiește). Un tabel `slug_history` cu redirect 301 ar păstra toate linkurile partajate. Context: decizia v1 = fără alias, cu warning în UI la editare. De făcut dacă apar plângeri de linkuri moarte. +- [ ] **Widget embed calendar public** (P3, L) — endpoint-ul busy e fundația; un ` + + diff --git a/frontend/src/components/PublicCalendar.vue b/frontend/src/components/PublicCalendar.vue new file mode 100644 index 0000000..3f673a5 --- /dev/null +++ b/frontend/src/components/PublicCalendar.vue @@ -0,0 +1,613 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index f04b107..5b238e4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -43,7 +43,7 @@ const router = createRouter({ meta: { requiresAuth: false } }, { - path: '/book/:propertyId?', + path: '/book/:slug?', name: 'PublicBooking', component: () => import('@/views/PublicBooking.vue'), meta: { requiresAuth: false, isPublic: true } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6513717..069c887 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -28,7 +28,8 @@ import type { PropertyAccess, Organization, OrganizationMember, - AnonymousBookingCreate + AnonymousBookingCreate, + BusyInterval } from '@/types' const api = axios.create({ @@ -435,7 +436,7 @@ export const propertiesApi = { const response = await api.post('/manager/properties', data) return response.data }, - update: async (id: number, data: { name?: string; description?: string; address?: string; is_public?: boolean }): Promise => { + update: async (id: number, data: { name?: string; description?: string; address?: string; is_public?: boolean; slug?: string; list_on_landing?: boolean }): Promise => { const response = await api.put(`/manager/properties/${id}`, data) return response.data }, @@ -513,8 +514,18 @@ export const publicApi = { const response = await publicApiInstance.get('/public/properties') return response.data }, - getPropertySpaces: async (propertyId: number): Promise => { - const response = await publicApiInstance.get(`/public/properties/${propertyId}/spaces`) + getProperty: async (slugOrId: string | number): Promise => { + const response = await publicApiInstance.get(`/public/properties/${slugOrId}`) + return response.data + }, + getPropertySpaces: async (slugOrId: string | number): Promise => { + const response = await publicApiInstance.get(`/public/properties/${slugOrId}/spaces`) + return response.data + }, + getBusy: async (spaceId: number, start: string, end: string): Promise => { + const response = await publicApiInstance.get(`/public/spaces/${spaceId}/busy`, { + params: { start, end } + }) return response.data }, getSpaceAvailability: async (spaceId: number, start: string, end: string) => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f88843b..9653be0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -253,6 +253,14 @@ export interface Property { created_at: string space_count?: number managers?: PropertyManagerInfo[] + slug?: string + list_on_landing?: boolean + require_approval?: boolean +} + +export interface BusyInterval { + start_time: string + end_time: string } export interface PropertyWithSpaces extends Property { diff --git a/frontend/src/views/Landing.vue b/frontend/src/views/Landing.vue index a12f33e..2b4b751 100644 --- a/frontend/src/views/Landing.vue +++ b/frontend/src/views/Landing.vue @@ -46,6 +46,7 @@
{{ ctaText }} Vezi cum arată + Rezervă fără cont

Cont gratuit, gata în câteva secunde.

@@ -232,6 +233,39 @@ + +
+

Spații deschise rezervării acum

+

Alege un spațiu și rezervă fără cont, în câteva minute.

+ +
+ + +
+
+
@@ -262,8 +296,10 @@