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:
@@ -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, 3–64 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ă. Fă-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ă...' : 'Fă-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, 3–64 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;
|
||||
|
||||
Reference in New Issue
Block a user