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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user