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

@@ -14,10 +14,12 @@
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.0",
"@fullcalendar/vue3": "^6.1.0",
"@types/qrcode": "^1.5.6",
"axios": "^1.6.0",
"chart.js": "^4.5.1",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.1.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
@@ -1090,6 +1092,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
@@ -1570,7 +1590,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1580,7 +1599,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -1686,6 +1704,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1715,11 +1742,21 @@
"pnpm": ">=8"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -1732,7 +1769,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -1813,6 +1849,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1829,6 +1874,12 @@
"node": ">=0.4.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -1869,6 +1920,12 @@
"node": ">= 0.4"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -2375,6 +2432,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2652,6 +2718,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2980,6 +3055,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3004,7 +3088,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3081,6 +3164,15 @@
}
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -3159,6 +3251,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3180,6 +3289,21 @@
],
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3300,6 +3424,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3342,11 +3472,24 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -3454,6 +3597,12 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3658,6 +3807,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -3668,6 +3823,20 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -3685,6 +3854,99 @@
"node": ">=12"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -16,10 +16,12 @@
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.0",
"@fullcalendar/vue3": "^6.1.0",
"@types/qrcode": "^1.5.6",
"axios": "^1.6.0",
"chart.js": "^4.5.1",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.1.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},

View File

@@ -0,0 +1,337 @@
<template>
<form class="guest-form" @submit.prevent="handleSubmit">
<h3 class="form-title">Detalii rezervare</h3>
<div class="form-group">
<label for="guest_name">Nume *</label>
<input id="guest_name" v-model="form.guest_name" type="text" required placeholder="Popescu Ion" />
</div>
<div class="form-group">
<label for="guest_email">Email *</label>
<input id="guest_email" v-model="form.guest_email" type="email" required placeholder="ion@exemplu.ro" />
</div>
<div class="form-group">
<label for="guest_organization">Organizație (opțional)</label>
<input id="guest_organization" v-model="form.guest_organization" type="text" placeholder="Numele companiei" />
</div>
<div class="form-group">
<label for="title">Titlu rezervare *</label>
<input id="title" v-model="form.title" type="text" required placeholder="Ședință echipă" />
</div>
<div class="form-group">
<label for="description">Descriere (opțional)</label>
<textarea id="description" v-model="form.description" rows="2" placeholder="Detalii suplimentare..."></textarea>
</div>
<div class="datetime-block">
<div v-if="selection && !manualMode" class="datetime-mirror">
<div class="mirror-row">
<span class="mirror-label">Data</span>
<span class="mirror-value">{{ selection.date }}</span>
</div>
<div class="mirror-row">
<span class="mirror-label">Interval</span>
<span class="mirror-value">{{ selection.start }} {{ selection.end }}</span>
</div>
<p class="mirror-hint">
Selecție din calendar. <button type="button" class="btn-link" @click="goManual">Introdu manual</button>
</p>
</div>
<div v-else class="form-row">
<div class="form-group">
<label for="date">Data *</label>
<input id="date" v-model="manualDate" type="date" required :min="minDate" />
</div>
<div class="form-group">
<label for="start_time">Ora start *</label>
<input id="start_time" v-model="manualStart" type="time" required />
</div>
<div class="form-group">
<label for="end_time">Ora sfârșit *</label>
<input id="end_time" v-model="manualEnd" type="time" required />
</div>
<p v-if="selection" class="mirror-hint">
<button type="button" class="btn-link" @click="goCalendar">« schimbă din calendar</button>
</p>
</div>
</div>
<p v-if="requireApproval" class="notice-approval">
Această proprietate necesită aprobare manuală vei primi un răspuns după validare.
</p>
<div v-if="validationError" class="error">{{ validationError }}</div>
<div v-if="submitError" class="error">{{ submitError }}</div>
<button type="submit" class="btn btn-primary btn-block" :disabled="submitting || !canSubmit">
{{ submitting ? 'Se trimite...' : 'Trimite cererea de rezervare' }}
</button>
<p v-if="!selection && !manualMode" class="hint-select">Selectează un interval din calendar pentru a continua.</p>
</form>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { AnonymousBookingCreate } from '@/types'
const props = defineProps<{
spaceId: number | null
selection: { date: string; start: string; end: string } | null
submitting: boolean
submitError: string
requireApproval: boolean
busyLoaded: boolean
}>()
const emit = defineEmits<{
submit: [payload: AnonymousBookingCreate]
}>()
const form = ref({
guest_name: '',
guest_email: '',
guest_organization: '',
title: '',
description: ''
})
const manualMode = ref(false)
const manualDate = ref('')
const manualStart = ref('')
const manualEnd = ref('')
const validationError = ref('')
function pad(n: number): string {
return n < 10 ? '0' + n : '' + n
}
const minDate = computed(() => {
const d = new Date()
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
})
watch(
() => props.selection,
(sel) => {
if (sel) {
manualMode.value = false
}
}
)
function goManual() {
if (props.selection) {
manualDate.value = props.selection.date
manualStart.value = props.selection.start
manualEnd.value = props.selection.end
}
manualMode.value = true
}
function goCalendar() {
manualMode.value = false
}
const effectiveDate = computed(() => (manualMode.value || !props.selection ? manualDate.value : props.selection.date))
const effectiveStart = computed(() =>
manualMode.value || !props.selection ? manualStart.value : props.selection.start
)
const effectiveEnd = computed(() => (manualMode.value || !props.selection ? manualEnd.value : props.selection.end))
const canSubmit = computed(() => {
return props.busyLoaded && !!effectiveDate.value && !!effectiveStart.value && !!effectiveEnd.value
})
function handleSubmit() {
validationError.value = ''
if (!props.spaceId) return
if (!effectiveDate.value || !effectiveStart.value || !effectiveEnd.value) {
validationError.value = 'Selectează data și intervalul orar.'
return
}
if (effectiveStart.value >= effectiveEnd.value) {
validationError.value = 'Ora de sfârșit trebuie să fie după ora de start.'
return
}
emit('submit', {
space_id: props.spaceId,
start_datetime: `${effectiveDate.value}T${effectiveStart.value}:00`,
end_datetime: `${effectiveDate.value}T${effectiveEnd.value}:00`,
title: form.value.title,
description: form.value.description || undefined,
guest_name: form.value.guest_name,
guest_email: form.value.guest_email,
guest_organization: form.value.guest_organization || undefined
})
}
</script>
<style scoped>
.guest-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-weight: 500;
font-size: 14px;
color: var(--color-text-primary);
}
.form-group input,
.form-group textarea {
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);
font-family: inherit;
min-height: 44px;
}
.form-group textarea {
min-height: unset;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.datetime-block {
border-top: 1px solid var(--color-border-light);
padding-top: 12px;
}
.datetime-mirror {
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.mirror-row {
display: flex;
justify-content: space-between;
font-size: 14px;
}
.mirror-label {
color: var(--color-text-secondary);
}
.mirror-value {
font-weight: 600;
color: var(--color-text-primary);
}
.mirror-hint {
font-size: 12px;
color: var(--color-text-muted);
margin: 4px 0 0;
}
.btn-link {
background: none;
border: none;
color: var(--color-accent);
cursor: pointer;
font-size: 12px;
text-decoration: underline;
padding: 0;
}
.notice-approval {
font-size: 13px;
padding: 8px 12px;
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
border-left: 3px solid var(--color-warning);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
margin: 0;
}
.error {
padding: 10px 14px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-left: 3px solid var(--color-danger);
border-radius: var(--radius-sm);
color: var(--color-danger);
font-size: 14px;
}
.hint-select {
font-size: 12px;
color: var(--color-text-muted);
text-align: center;
margin: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
min-height: 44px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-block {
width: 100%;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,613 @@
<template>
<div class="public-calendar">
<div class="cal-header">
<div class="week-nav">
<button type="button" class="nav-btn" @click="prevWeek" aria-label="Săptămâna anterioară"></button>
<span class="week-label">{{ weekRangeLabel }}</span>
<button type="button" class="nav-btn" @click="nextWeek" aria-label="Săptămâna următoare"></button>
<button type="button" class="btn-today" @click="goToday">Azi</button>
</div>
</div>
<div v-if="isMobile" class="day-picker" role="tablist">
<button
v-for="(d, i) in weekDays"
:key="i"
type="button"
class="day-chip"
:class="{ active: i === mobileDayIndex, today: isSameDay(d, today) }"
@click="mobileDayIndex = i"
>
<span class="day-name">{{ dayName(d) }}</span>
<span class="day-num">{{ d.getDate() }}</span>
</button>
</div>
<div v-if="error" class="cal-error">
<span>{{ error }}</span>
<button type="button" class="btn-retry" @click="fetchBusy">Reîncearcă</button>
</div>
<div v-else-if="loading" class="cal-skeleton">
<div v-for="n in (isMobile ? 12 : 24)" :key="n" class="skeleton-row"></div>
</div>
<template v-else>
<div v-if="isEmpty" class="cal-empty">
Toate intervalele sunt ocupate vezi săptămâna următoare
<button type="button" class="btn-link" @click="nextWeek">Săptămâna următoare »</button>
</div>
<div class="cal-grid" :class="{ mobile: isMobile }">
<div class="cal-times">
<div class="cal-corner"></div>
<div v-for="(s, i) in slots" :key="i" class="time-label">
{{ i % 2 === 0 ? slotLabel(s.h, s.m) : '' }}
</div>
</div>
<div
v-for="(day, di) in visibleDays"
:key="di"
class="cal-day-col"
>
<div class="cal-day-head" :class="{ today: isSameDay(day, today) }">
<template v-if="!isMobile">{{ dayName(day) }} {{ day.getDate() }}</template>
</div>
<button
v-for="(s, si) in slots"
:key="si"
type="button"
class="slot"
:class="{
busy: isBusy(day, s.h, s.m),
selected: isSelected(day, s.h, s.m),
'slot-hour': s.m === 0
}"
:disabled="isBusy(day, s.h, s.m)"
:aria-label="isBusy(day, s.h, s.m) ? 'Ocupat' : `Liber ${slotLabel(s.h, s.m)}`"
@click="onSlotClick(day, s.h, s.m)"
>
<span v-if="isBusy(day, s.h, s.m)" class="slot-tag">Ocupat</span>
</button>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { publicApi, handleApiError } from '@/services/api'
import type { BusyInterval } from '@/types'
const props = defineProps<{
spaceId: number
workingHoursStart?: number | null
workingHoursEnd?: number | null
}>()
const selection = defineModel<{ date: string; start: string; end: string } | null>('selection', {
default: null
})
const today = new Date()
const loading = ref(false)
const error = ref('')
const busy = ref<BusyInterval[]>([])
const weekOffset = ref(0)
const isMobile = ref(typeof window !== 'undefined' ? window.innerWidth < 768 : false)
const mobileDayIndex = ref(0)
let requestSeq = 0
const startHour = computed(() => props.workingHoursStart ?? 8)
const endHour = computed(() => props.workingHoursEnd ?? 20)
function pad(n: number): string {
return n < 10 ? '0' + n : '' + n
}
function fmtDate(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function slotLabel(h: number, m: number): string {
return `${pad(h)}:${pad(m)}`
}
function minutesToLabel(min: number): string {
return slotLabel(Math.floor(min / 60), min % 60)
}
function labelToMinutes(label: string): number {
const [h, m] = label.split(':').map(Number)
return h * 60 + m
}
function startOfWeek(d: Date): Date {
const day = d.getDay()
const diff = (day === 0 ? -6 : 1) - day
const monday = new Date(d)
monday.setHours(0, 0, 0, 0)
monday.setDate(d.getDate() + diff)
return monday
}
const weekStartDate = computed(() => {
const base = startOfWeek(today)
base.setDate(base.getDate() + weekOffset.value * 7)
return base
})
const weekDays = computed(() => {
const days: Date[] = []
for (let i = 0; i < 7; i++) {
const d = new Date(weekStartDate.value)
d.setDate(d.getDate() + i)
days.push(d)
}
return days
})
const visibleDays = computed(() => (isMobile.value ? [weekDays.value[mobileDayIndex.value]] : weekDays.value))
const slots = computed(() => {
const list: { h: number; m: number }[] = []
const totalMinutes = (endHour.value - startHour.value) * 60
for (let i = 0; i < totalMinutes / 30; i++) {
const min = startHour.value * 60 + i * 30
list.push({ h: Math.floor(min / 60), m: min % 60 })
}
return list
})
const DAY_NAMES = ['Dum', 'Lun', 'Mar', 'Mie', 'Joi', 'Vin', 'Sâm']
function dayName(d: Date): string {
return DAY_NAMES[d.getDay()]
}
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
const weekRangeLabel = computed(() => {
const start = weekDays.value[0]
const end = weekDays.value[6]
const MONTHS = ['ian', 'feb', 'mar', 'apr', 'mai', 'iun', 'iul', 'aug', 'sep', 'oct', 'nov', 'dec']
if (start.getMonth() === end.getMonth()) {
return `${start.getDate()} - ${end.getDate()} ${MONTHS[end.getMonth()]} ${end.getFullYear()}`
}
return `${start.getDate()} ${MONTHS[start.getMonth()]} - ${end.getDate()} ${MONTHS[end.getMonth()]} ${end.getFullYear()}`
})
function isBusy(day: Date, h: number, m: number): boolean {
const slotStart = new Date(day)
slotStart.setHours(h, m, 0, 0)
const slotEnd = new Date(slotStart.getTime() + 30 * 60000)
return busy.value.some((b) => {
const bs = new Date(b.start_time)
const be = new Date(b.end_time)
return slotStart < be && slotEnd > bs
})
}
function isSelected(day: Date, h: number, m: number): boolean {
if (!selection.value) return false
if (selection.value.date !== fmtDate(day)) return false
const min = h * 60 + m
const start = labelToMinutes(selection.value.start)
const end = labelToMinutes(selection.value.end)
return min >= start && min < end
}
const isEmpty = computed(() => {
if (!visibleDays.value.length) return false
return visibleDays.value.every((day) => slots.value.every((s) => isBusy(day, s.h, s.m)))
})
function clampEndForBusy(day: Date, startMin: number, desiredEndMin: number): number {
let end = startMin + 30
for (let cur = startMin; cur < desiredEndMin; cur += 30) {
const h = Math.floor(cur / 60)
const m = cur % 60
if (isBusy(day, h, m)) break
end = cur + 30
}
return Math.max(end, startMin + 30)
}
function onSlotClick(day: Date, h: number, m: number) {
if (isBusy(day, h, m)) return
const dateStr = fmtDate(day)
const startLabel = slotLabel(h, m)
const startMin = h * 60 + m
const maxMin = endHour.value * 60
if (!selection.value || selection.value.date !== dateStr) {
const desiredEnd = Math.min(startMin + 60, maxMin)
const end = clampEndForBusy(day, startMin, desiredEnd)
selection.value = { date: dateStr, start: startLabel, end: minutesToLabel(end) }
return
}
if (selection.value.start === startLabel) {
selection.value = null
return
}
const curStartMin = labelToMinutes(selection.value.start)
if (startMin < curStartMin) {
const desiredEnd = Math.min(startMin + 60, maxMin)
const end = clampEndForBusy(day, startMin, desiredEnd)
selection.value = { date: dateStr, start: startLabel, end: minutesToLabel(end) }
} else {
const desiredEnd = Math.min(startMin + 30, maxMin)
const end = clampEndForBusy(day, curStartMin, desiredEnd)
selection.value = { ...selection.value, end: minutesToLabel(end) }
}
}
async function fetchBusy() {
const reqId = ++requestSeq
loading.value = true
error.value = ''
try {
const start = `${fmtDate(weekStartDate.value)}T00:00:00`
const endDate = new Date(weekStartDate.value)
endDate.setDate(endDate.getDate() + 7)
const end = `${fmtDate(endDate)}T00:00:00`
const result = await publicApi.getBusy(props.spaceId, start, end)
if (reqId !== requestSeq) return
busy.value = result
} catch (err) {
if (reqId !== requestSeq) return
error.value = handleApiError(err)
} finally {
if (reqId === requestSeq) loading.value = false
}
}
function prevWeek() {
weekOffset.value -= 1
selection.value = null
fetchBusy()
}
function nextWeek() {
weekOffset.value += 1
selection.value = null
fetchBusy()
}
function goToday() {
weekOffset.value = 0
mobileDayIndex.value = weekDays.value.findIndex((d) => isSameDay(d, today))
if (mobileDayIndex.value < 0) mobileDayIndex.value = 0
selection.value = null
fetchBusy()
}
function markOccupied(sel: { date: string; start: string; end: string } | null) {
if (!sel) return
busy.value = [...busy.value, { start_time: `${sel.date}T${sel.start}:00`, end_time: `${sel.date}T${sel.end}:00` }]
}
function handleResize() {
isMobile.value = window.innerWidth < 768
}
watch(
() => props.spaceId,
() => {
selection.value = null
fetchBusy()
}
)
onMounted(() => {
mobileDayIndex.value = weekDays.value.findIndex((d) => isSameDay(d, today))
if (mobileDayIndex.value < 0) mobileDayIndex.value = 0
window.addEventListener('resize', handleResize)
fetchBusy()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
requestSeq++
})
defineExpose({
refreshBusy: fetchBusy,
markOccupied,
clearSelection: () => {
selection.value = null
}
})
</script>
<style scoped>
.public-calendar {
display: flex;
flex-direction: column;
gap: 12px;
}
.cal-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.week-nav {
display: flex;
align-items: center;
gap: 10px;
}
.nav-btn {
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 16px;
color: var(--color-text-primary);
}
.nav-btn:hover {
border-color: var(--color-accent);
}
.week-label {
font-weight: 600;
color: var(--color-text-primary);
min-width: 160px;
text-align: center;
}
.btn-today {
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: var(--radius-sm);
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
color: var(--color-text-secondary);
}
.btn-today:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.day-picker {
display: flex;
gap: 6px;
overflow-x: auto;
padding-bottom: 4px;
}
.day-chip {
min-width: 52px;
min-height: 44px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: var(--radius-md);
cursor: pointer;
flex-shrink: 0;
}
.day-chip.today {
border-color: var(--color-accent);
}
.day-chip.active {
background: var(--color-accent);
border-color: var(--color-accent);
}
.day-chip.active .day-name,
.day-chip.active .day-num {
color: white;
}
.day-name {
font-size: 11px;
color: var(--color-text-muted);
text-transform: uppercase;
}
.day-num {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
}
.cal-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-left: 3px solid var(--color-danger);
border-radius: var(--radius-sm);
color: var(--color-danger);
font-size: 14px;
}
.btn-retry {
background: var(--color-danger);
color: white;
border: none;
border-radius: var(--radius-sm);
padding: 6px 14px;
cursor: pointer;
flex-shrink: 0;
}
.cal-empty {
text-align: center;
padding: 20px;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 8px;
}
.btn-link {
background: none;
border: none;
color: var(--color-accent);
cursor: pointer;
font-size: 13px;
text-decoration: underline;
}
.cal-skeleton {
display: flex;
flex-direction: column;
gap: 4px;
}
.skeleton-row {
height: 20px;
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-secondary) 25%,
var(--color-bg-tertiary) 37%,
var(--color-bg-secondary) 63%
);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
}
@keyframes skeleton-loading {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.cal-grid {
display: grid;
grid-template-columns: 56px repeat(7, 1fr);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: auto;
max-height: 560px;
}
.cal-grid.mobile {
grid-template-columns: 56px 1fr;
}
.cal-times {
display: flex;
flex-direction: column;
border-right: 1px solid var(--color-border-light);
background: var(--color-bg-secondary);
position: sticky;
left: 0;
z-index: 1;
}
.cal-corner {
height: 32px;
border-bottom: 1px solid var(--color-border-light);
}
.time-label {
height: 28px;
font-size: 11px;
color: var(--color-text-muted);
text-align: right;
padding-right: 6px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.cal-day-col {
display: flex;
flex-direction: column;
border-right: 1px solid var(--color-border-light);
}
.cal-day-head {
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-light);
position: sticky;
top: 0;
background: var(--color-surface);
z-index: 1;
}
.cal-day-head.today {
color: var(--color-accent);
}
.slot {
height: 28px;
min-height: 28px;
border: none;
border-bottom: 1px solid var(--color-border-light);
background: var(--color-surface);
cursor: pointer;
padding: 0;
position: relative;
}
.slot.slot-hour {
border-top: 1px solid var(--color-border-light);
}
.slot:hover:not(.busy) {
background: var(--color-accent-light);
}
.slot.busy {
background: var(--color-bg-tertiary);
cursor: not-allowed;
}
.slot-tag {
font-size: 9px;
color: var(--color-text-muted);
}
.slot.selected {
background: var(--color-accent);
}
@media (max-width: 767px) {
.slot {
height: 44px;
min-height: 44px;
}
.time-label {
height: 44px;
}
.cal-grid {
max-height: 70vh;
}
}
</style>

View File

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

View File

@@ -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<Property>('/manager/properties', data)
return response.data
},
update: async (id: number, data: { name?: string; description?: string; address?: string; is_public?: boolean }): Promise<Property> => {
update: async (id: number, data: { name?: string; description?: string; address?: string; is_public?: boolean; slug?: string; list_on_landing?: boolean }): Promise<Property> => {
const response = await api.put<Property>(`/manager/properties/${id}`, data)
return response.data
},
@@ -513,8 +514,18 @@ export const publicApi = {
const response = await publicApiInstance.get<Property[]>('/public/properties')
return response.data
},
getPropertySpaces: async (propertyId: number): Promise<Space[]> => {
const response = await publicApiInstance.get<Space[]>(`/public/properties/${propertyId}/spaces`)
getProperty: async (slugOrId: string | number): Promise<Property> => {
const response = await publicApiInstance.get<Property>(`/public/properties/${slugOrId}`)
return response.data
},
getPropertySpaces: async (slugOrId: string | number): Promise<Space[]> => {
const response = await publicApiInstance.get<Space[]>(`/public/properties/${slugOrId}/spaces`)
return response.data
},
getBusy: async (spaceId: number, start: string, end: string): Promise<BusyInterval[]> => {
const response = await publicApiInstance.get<BusyInterval[]>(`/public/spaces/${spaceId}/busy`, {
params: { start, end }
})
return response.data
},
getSpaceAvailability: async (spaceId: number, start: string, end: string) => {

View File

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

View File

@@ -46,6 +46,7 @@
<div class="hero-cta">
<router-link to="/register" class="btn btn-primary btn-lg">{{ ctaText }}</router-link>
<a href="#cum" class="btn btn-secondary btn-lg">Vezi cum arată</a>
<router-link to="/book" class="btn btn-ghost btn-lg">Rezervă fără cont</router-link>
</div>
<p class="hero-note">Cont gratuit, gata în câteva secunde.</p>
</div>
@@ -232,6 +233,39 @@
</div>
</section>
<!-- PUBLIC PROPERTIES -->
<section v-if="showLandingProperties" class="landing-properties">
<h2>Spații deschise rezervării acum</h2>
<p class="landing-properties-sub">Alege un spațiu și rezervă fără cont, în câteva minute.</p>
<div class="lp-grid">
<template v-if="loadingProperties">
<div v-for="i in 3" :key="`sk-${i}`" class="lp-card lp-skeleton">
<div class="lp-skel-line lp-skel-title"></div>
<div class="lp-skel-line lp-skel-sub"></div>
<div class="lp-skel-line lp-skel-sub short"></div>
</div>
</template>
<template v-else>
<router-link
v-for="p in landingProperties"
:key="p.id"
:to="`/book/${p.slug || p.id}`"
class="lp-card"
>
<div class="lp-card-head">
<h3>{{ p.name }}</h3>
<span v-if="p.slug === 'proprietate-demo'" class="lp-badge">Demo</span>
</div>
<p v-if="p.address" class="lp-address"><MapPin :size="14" />{{ p.address }}</p>
<p class="lp-count">{{ p.space_count ?? 0 }} {{ (p.space_count ?? 0) === 1 ? 'spațiu' : 'spații' }}</p>
<p v-if="p.slug === 'proprietate-demo'" class="lp-demo-note">se resetează la 3 ore</p>
<span class="lp-cta">Rezervă acum </span>
</router-link>
</template>
</div>
</section>
<!-- CTA BAND -->
<section id="start" class="cta-band">
<div class="cta-inner">
@@ -262,8 +296,10 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useLandingTheme } from '@/composables/useLandingTheme'
import { publicApi } from '@/services/api'
import type { Property } from '@/types'
import {
LayoutDashboard,
Building2,
@@ -350,6 +386,29 @@ const selectedLabel = computed(() => {
const s = slots[selected.value]
return s.booked ? `Booked · ${s.time}` : `Selected · ${s.time}`
})
/* ── Public properties open for booking ── */
const loadingProperties = ref(true)
const landingProperties = ref<Property[]>([])
const propertiesFetchFailed = ref(false)
const showLandingProperties = computed(() => {
if (loadingProperties.value) return true
if (propertiesFetchFailed.value) return false
return landingProperties.value.length > 0
})
onMounted(async () => {
try {
const props = await publicApi.getProperties()
landingProperties.value = (props || []).filter((p) => p.list_on_landing === true)
} catch {
propertiesFetchFailed.value = true
landingProperties.value = []
} finally {
loadingProperties.value = false
}
})
</script>
<style scoped>
@@ -473,6 +532,12 @@ const selectedLabel = computed(() => {
border-color: var(--border-default);
}
.btn-secondary:hover { border-color: var(--brand); color: var(--brand); }
.btn-ghost {
background: transparent;
color: var(--text-muted);
border-color: transparent;
}
.btn-ghost:hover { color: var(--brand); border-color: var(--border-default); }
/* ── Nav ── */
.nav {
@@ -630,6 +695,116 @@ const selectedLabel = computed(() => {
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
/* ── Public properties open for booking ── */
.landing-properties {
max-width: var(--container);
margin: 0 auto;
padding: 8px 32px 64px;
}
.landing-properties h2 {
font-size: 1.875rem;
letter-spacing: -0.02em;
margin-bottom: 8px;
}
.landing-properties-sub {
font-size: 1rem;
color: var(--text-muted);
margin: 0 0 28px;
}
.lp-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.lp-card {
display: flex;
flex-direction: column;
gap: 6px;
background: var(--surface-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 22px 22px 20px;
text-decoration: none;
color: var(--text-body);
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.lp-card:hover {
border-color: var(--brand);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.lp-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.lp-card-head h3 {
font-family: var(--font-display);
font-size: 1.125rem;
font-weight: 700;
color: var(--text-strong);
margin: 0;
}
.lp-badge {
flex: none;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
padding: 3px 9px;
border-radius: 10px;
background: color-mix(in srgb, var(--brand) 16%, var(--surface-card));
color: var(--brand);
}
.lp-address {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
color: var(--text-muted);
margin: 0;
}
.lp-address svg { flex: none; color: var(--text-subtle); }
.lp-count {
font-size: 0.8125rem;
color: var(--text-subtle);
margin: 0;
}
.lp-demo-note {
font-size: 0.75rem;
font-style: italic;
color: var(--text-subtle);
margin: 0;
}
.lp-cta {
margin-top: 10px;
font-size: 0.875rem;
font-weight: 600;
color: var(--brand);
}
.lp-skeleton {
gap: 10px;
cursor: default;
}
.lp-skel-line {
height: 14px;
border-radius: 6px;
background: var(--border-subtle);
animation: lp-pulse 1.4s ease-in-out infinite;
}
.lp-skel-title { width: 60%; height: 20px; }
.lp-skel-sub { width: 85%; }
.lp-skel-sub.short { width: 40%; }
@keyframes lp-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
@media (max-width: 900px) {
.lp-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 620px) {
.lp-grid { grid-template-columns: 1fr; }
}
/* ── CTA band (subtle accent-tinted surface — accent stays on the button) ── */
.cta-band {
background: color-mix(in srgb, var(--accent) 7%, var(--card));

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;

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;

View File

@@ -1,296 +1,336 @@
<template>
<div class="public-booking-container">
<div class="public-booking-card card">
<h2>Book a Space</h2>
<p class="subtitle">Reserve a meeting room or workspace without an account</p>
<!-- Step 1: Select Property -->
<div v-if="step === 'property'">
<div v-if="loadingProperties" class="loading-inline">Loading properties...</div>
<div v-else-if="properties.length === 0" class="empty-msg">No public properties available.</div>
<div class="public-booking-page">
<!-- Property picker (no slug in route) -->
<div v-if="phase === 'picker'" class="picker-wrap">
<div class="picker-card card">
<h2>Rezervă un spațiu</h2>
<p class="subtitle">Alege o proprietate pentru a continua rezervarea, fără cont</p>
<div v-if="loadingPicker" class="loading-inline">Se încarcă proprietățile...</div>
<div v-else-if="pickerError" class="error-banner">
{{ pickerError }}
<button class="btn-retry" @click="loadPicker">Reîncearcă</button>
</div>
<div v-else-if="pickerProperties.length === 0" class="empty-msg">
Nu există proprietăți publice disponibile momentan.
</div>
<div v-else class="property-list">
<div
v-for="prop in properties"
v-for="prop in pickerProperties"
:key="prop.id"
class="selectable-card"
@click="selectProperty(prop)"
@click="goToProperty(prop)"
>
<h4>{{ prop.name }}</h4>
<p v-if="prop.description" class="card-desc">{{ prop.description }}</p>
<p v-if="prop.address" class="card-meta">{{ prop.address }}</p>
<span class="card-count">{{ prop.space_count || 0 }} spaces</span>
<span class="card-count">{{ prop.space_count || 0 }} spații</span>
</div>
</div>
<p class="login-hint">
Ai deja cont? <router-link to="/login">Autentifică-te</router-link>
</p>
</div>
</div>
<!-- Resolving property -->
<div v-else-if="phase === 'resolving'" class="state-wrap">
<div class="loading-inline">Se încarcă pagina de rezervare...</div>
</div>
<!-- Not found / private -->
<div v-else-if="phase === 'notfound'" class="state-wrap">
<div class="state-card card">
<h3>Pagina de rezervare nu mai e disponibilă</h3>
<p>Linkul folosit nu (mai) corespunde unei proprietăți publice.</p>
<router-link to="/book" class="btn btn-primary">Vezi proprietăți disponibile</router-link>
</div>
</div>
<!-- Load error -->
<div v-else-if="phase === 'error'" class="state-wrap">
<div class="state-card card">
<h3>A apărut o eroare</h3>
<p>{{ loadError }}</p>
<button class="btn btn-primary" @click="init">Reîncearcă</button>
</div>
</div>
<!-- Ready: calendar booking -->
<div v-else-if="phase === 'ready'" class="booking-wrap">
<div v-if="isDemoProperty" class="demo-banner">
Mediu demo rezervările se șterg automat la 3 ore.
</div>
<!-- Step 2: Select Space -->
<div v-else-if="step === 'space'">
<button class="btn-back" @click="step = 'property'">Back to properties</button>
<h3 class="step-title">{{ selectedProperty?.name }} - Choose a Space</h3>
<div v-if="loadingSpaces" class="loading-inline">Loading spaces...</div>
<div v-else-if="spaces.length === 0" class="empty-msg">No spaces available.</div>
<div v-else class="space-list">
<div
v-for="sp in spaces"
:key="sp.id"
class="selectable-card"
@click="selectSpace(sp)"
>
<h4>{{ sp.name }}</h4>
<div class="card-meta-row">
<span>{{ formatType(sp.type) }}</span>
<span>Capacity: {{ sp.capacity }}</span>
</div>
</div>
</div>
</div>
<header class="booking-header">
<h2>{{ property?.name }}</h2>
<p v-if="property?.address" class="address">{{ property.address }}</p>
<p v-if="property?.require_approval" class="approval-note">Necesită aprobare</p>
</header>
<!-- Step 3: Booking Form -->
<div v-else-if="step === 'form'">
<button class="btn-back" @click="step = 'space'">Back to spaces</button>
<h3 class="step-title">Book {{ selectedSpace?.name }}</h3>
<form @submit.prevent="handleSubmit" class="booking-form">
<div class="form-group">
<label for="guest_name">Your Name *</label>
<input id="guest_name" v-model="form.guest_name" type="text" required placeholder="John Doe" />
</div>
<div class="form-group">
<label for="guest_email">Your Email *</label>
<input id="guest_email" v-model="form.guest_email" type="email" required placeholder="john@example.com" />
</div>
<div class="form-group">
<label for="guest_organization">Organization (optional)</label>
<input id="guest_organization" v-model="form.guest_organization" type="text" placeholder="Company name" />
</div>
<div class="form-group">
<label for="title">Booking Title *</label>
<input id="title" v-model="form.title" type="text" required placeholder="Team meeting" />
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea id="description" v-model="form.description" rows="2" placeholder="Additional details..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="date">Date *</label>
<input id="date" v-model="form.date" type="date" required :min="minDate" />
</div>
<div class="form-group">
<label for="start_time">Start Time *</label>
<input id="start_time" v-model="form.start_time" type="time" required />
</div>
<div class="form-group">
<label for="end_time">End Time *</label>
<input id="end_time" v-model="form.end_time" type="time" required />
</div>
</div>
<div v-if="error" class="error">{{ error }}</div>
<button type="submit" class="btn btn-primary btn-block" :disabled="submitting">
{{ submitting ? 'Submitting...' : 'Submit Booking Request' }}
</button>
</form>
</div>
<!-- Step 4: Success -->
<div v-else-if="step === 'success'" class="success-state">
<div v-if="successBooking" class="success-state card">
<div class="success-icon">&#10003;</div>
<h3>Booking Request Sent!</h3>
<p>Your booking request has been submitted. You will receive updates at <strong>{{ form.guest_email }}</strong>.</p>
<button class="btn btn-primary" @click="resetForm">Book Another</button>
<h3>Cererea a fost trimisă!</h3>
<p>
Vei primi actualizări la <strong>{{ successBooking.guest_email }}</strong>.
</p>
<p class="ref-code">Cod de referință: <strong>#RB-{{ successBooking.id }}</strong></p>
<button class="btn btn-primary" @click="bookAnother"> o altă rezervare</button>
</div>
<template v-else>
<div v-if="loadingSpaces" class="loading-inline">Se încarcă spațiile...</div>
<div v-else-if="spaces.length === 0" class="empty-msg">
Această proprietate nu are spații disponibile pentru rezervare.
</div>
<template v-else>
<div class="space-tabs-row">
<div class="space-tabs">
<button
v-for="sp in visibleTabs"
:key="sp.id"
type="button"
class="space-tab"
:class="{ active: selectedSpace?.id === sp.id }"
@click="setSpace(sp)"
>
{{ sp.name }}
</button>
</div>
<select
v-if="spaces.length > 6"
class="space-select"
:value="selectedSpace?.id"
@change="onSelectChange"
>
<option v-for="sp in spaces" :key="sp.id" :value="sp.id">{{ sp.name }}</option>
</select>
</div>
<div class="booking-layout" :class="{ 'has-selection': !!selection }">
<div class="calendar-pane">
<PublicCalendar
v-if="selectedSpace"
ref="calendarRef"
v-model:selection="selection"
:space-id="selectedSpace.id"
:working-hours-start="selectedSpace.working_hours_start"
:working-hours-end="selectedSpace.working_hours_end"
/>
</div>
<div class="form-pane" ref="formPaneRef">
<GuestBookingForm
v-if="selectedSpace"
:space-id="selectedSpace.id"
:selection="selection"
:submitting="submitting"
:submit-error="submitError"
:require-approval="!!property?.require_approval"
:busy-loaded="true"
@submit="handleSubmit"
/>
</div>
</div>
</template>
</template>
<p class="login-hint">
Already have an account? <router-link to="/login">Sign in</router-link>
Ai deja cont? <router-link to="/login">Autentifică-te</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ref, computed, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { AxiosError } from 'axios'
import { publicApi, handleApiError } from '@/services/api'
import type { Property, Space } from '@/types'
import type { Property, Space, Booking, AnonymousBookingCreate } from '@/types'
import PublicCalendar from '@/components/PublicCalendar.vue'
import GuestBookingForm from '@/components/GuestBookingForm.vue'
const route = useRoute()
const router = useRouter()
const step = ref<'property' | 'space' | 'form' | 'success'>('property')
const loadingProperties = ref(false)
const loadingSpaces = ref(false)
const submitting = ref(false)
const error = ref('')
const properties = ref<Property[]>([])
type Phase = 'picker' | 'resolving' | 'notfound' | 'error' | 'ready'
const phase = ref<Phase>('resolving')
const loadError = ref('')
const loadingPicker = ref(false)
const pickerError = ref('')
const pickerProperties = ref<Property[]>([])
const property = ref<Property | null>(null)
const spaces = ref<Space[]>([])
const selectedProperty = ref<Property | null>(null)
const loadingSpaces = ref(false)
const selectedSpace = ref<Space | null>(null)
const form = ref({
guest_name: '',
guest_email: '',
guest_organization: '',
title: '',
description: '',
date: '',
start_time: '',
end_time: ''
})
const selection = ref<{ date: string; start: string; end: string } | null>(null)
const submitting = ref(false)
const submitError = ref('')
const successBooking = ref<Booking | null>(null)
const minDate = computed(() => new Date().toISOString().split('T')[0])
const calendarRef = ref<InstanceType<typeof PublicCalendar> | null>(null)
const formPaneRef = ref<HTMLElement | null>(null)
const formatType = (type: string): string => {
const map: Record<string, string> = {
desk: 'Desk', meeting_room: 'Meeting Room', conference_room: 'Conference Room',
sala: 'Sala', birou: 'Birou'
}
return map[type] || type
}
const isDemoProperty = computed(() => property.value?.slug === 'proprietate-demo')
const visibleTabs = computed(() => spaces.value.slice(0, 6))
const loadProperties = async () => {
loadingProperties.value = true
async function loadPicker() {
loadingPicker.value = true
pickerError.value = ''
try {
properties.value = await publicApi.getProperties()
// If propertyId in route, auto-select
const pid = route.params.propertyId
if (pid) {
const prop = properties.value.find(p => p.id === Number(pid))
if (prop) {
selectProperty(prop)
}
}
pickerProperties.value = await publicApi.getProperties()
} catch (err) {
error.value = handleApiError(err)
pickerError.value = handleApiError(err)
} finally {
loadingProperties.value = false
loadingPicker.value = false
}
}
const selectProperty = async (prop: Property) => {
selectedProperty.value = prop
step.value = 'space'
function goToProperty(prop: Property) {
router.push(`/book/${prop.slug || prop.id}`)
}
async function loadSpaces(slugOrId: string) {
loadingSpaces.value = true
try {
spaces.value = await publicApi.getPropertySpaces(prop.id)
spaces.value = await publicApi.getPropertySpaces(slugOrId)
selectedSpace.value = spaces.value[0] ?? null
} catch (err) {
error.value = handleApiError(err)
spaces.value = []
} finally {
loadingSpaces.value = false
}
}
const selectSpace = (sp: Space) => {
selectedSpace.value = sp
step.value = 'form'
error.value = ''
async function resolveProperty(slugOrId: string) {
phase.value = 'resolving'
loadError.value = ''
try {
property.value = await publicApi.getProperty(slugOrId)
await loadSpaces(slugOrId)
phase.value = 'ready'
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 404) {
phase.value = 'notfound'
} else {
loadError.value = handleApiError(err)
phase.value = 'error'
}
}
}
const handleSubmit = async () => {
error.value = ''
if (!selectedSpace.value) return
if (form.value.start_time >= form.value.end_time) {
error.value = 'End time must be after start time'
return
function init() {
const slug = route.params.slug as string | undefined
if (slug) {
resolveProperty(slug)
} else {
phase.value = 'picker'
loadPicker()
}
}
function setSpace(sp: Space) {
selectedSpace.value = sp
selection.value = null
submitError.value = ''
}
function onSelectChange(e: Event) {
const id = Number((e.target as HTMLSelectElement).value)
const sp = spaces.value.find((s) => s.id === id)
if (sp) setSpace(sp)
}
async function handleSubmit(payload: AnonymousBookingCreate) {
submitting.value = true
submitError.value = ''
try {
await publicApi.createBooking({
space_id: selectedSpace.value.id,
start_datetime: `${form.value.date}T${form.value.start_time}:00`,
end_datetime: `${form.value.date}T${form.value.end_time}:00`,
title: form.value.title,
description: form.value.description || undefined,
guest_name: form.value.guest_name,
guest_email: form.value.guest_email,
guest_organization: form.value.guest_organization || undefined
})
step.value = 'success'
const booking = await publicApi.createBooking(payload)
successBooking.value = booking
selection.value = null
} catch (err) {
error.value = handleApiError(err)
if (err instanceof AxiosError && (err.response?.status === 400 || err.response?.status === 409)) {
submitError.value = 'Slotul tocmai a fost ocupat, alege altul'
calendarRef.value?.markOccupied(selection.value)
selection.value = null
calendarRef.value?.refreshBusy()
} else {
submitError.value = handleApiError(err)
}
} finally {
submitting.value = false
}
}
const resetForm = () => {
step.value = 'property'
selectedProperty.value = null
selectedSpace.value = null
form.value = {
guest_name: '',
guest_email: '',
guest_organization: '',
title: '',
description: '',
date: '',
start_time: '',
end_time: ''
}
error.value = ''
function bookAnother() {
successBooking.value = null
selection.value = null
submitError.value = ''
}
onMounted(() => {
loadProperties()
watch(selection, (sel) => {
if (sel && window.innerWidth < 768) {
nextTick(() => {
formPaneRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
})
watch(
() => route.params.slug,
() => init()
)
init()
</script>
<style scoped>
.public-booking-container {
display: flex;
justify-content: center;
align-items: flex-start;
.public-booking-page {
min-height: 100vh;
padding: 2rem 1rem;
background: var(--color-bg-primary);
padding: 1.5rem clamp(1rem, 3vw, 2.5rem) 3rem;
}
.public-booking-card {
.picker-wrap {
display: flex;
justify-content: center;
padding-top: 1.5rem;
}
.picker-card {
width: 100%;
max-width: 560px;
}
h2 {
.state-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.state-card {
max-width: 480px;
text-align: center;
margin-bottom: 0.25rem;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
h2 {
color: var(--color-text-primary);
margin: 0 0 0.25rem;
}
.subtitle {
text-align: center;
color: var(--color-text-secondary);
margin-bottom: 1.5rem;
}
.step-title {
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 16px;
}
.btn-back {
background: none;
border: none;
color: var(--color-accent);
font-size: 14px;
cursor: pointer;
padding: 0;
margin-bottom: 12px;
font-weight: 500;
}
.btn-back:hover {
text-decoration: underline;
}
.loading-inline {
text-align: center;
padding: 24px;
@@ -303,7 +343,27 @@ h2 {
color: var(--color-text-muted);
}
.property-list, .space-list {
.error-banner {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
padding: 16px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-radius: var(--radius-sm);
color: var(--color-danger);
}
.btn-retry {
background: var(--color-danger);
color: white;
border: none;
border-radius: var(--radius-sm);
padding: 6px 14px;
cursor: pointer;
}
.property-list {
display: flex;
flex-direction: column;
gap: 12px;
@@ -341,107 +401,123 @@ h2 {
margin: 0 0 4px;
}
.card-meta-row {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--color-text-secondary);
}
.card-count {
font-size: 12px;
font-weight: 500;
color: var(--color-accent);
}
.booking-form {
.booking-wrap {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
gap: 1.25rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
.demo-banner {
padding: 10px 16px;
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
border-left: 3px solid var(--color-warning);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font-size: 14px;
font-weight: 500;
}
.booking-header .address {
color: var(--color-text-secondary);
margin: 2px 0;
}
.approval-note {
display: inline-block;
font-size: 12px;
font-weight: 600;
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
padding: 3px 10px;
border-radius: 999px;
margin-top: 4px;
}
.space-tabs-row {
display: flex;
align-items: center;
gap: 12px;
}
.form-group {
.space-tabs {
display: flex;
flex-direction: column;
gap: 4px;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
flex: 1;
}
.form-group label {
font-weight: 500;
.space-tab {
flex-shrink: 0;
padding: 10px 16px;
min-height: 44px;
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 14px;
color: var(--color-text-primary);
color: var(--color-text-secondary);
white-space: nowrap;
}
.form-group input,
.form-group textarea,
.form-group select {
.space-tab.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
font-weight: 600;
}
.space-select {
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);
font-family: inherit;
min-height: 44px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
.booking-layout {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 1.5rem;
align-items: start;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
.calendar-pane {
min-width: 0;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
.form-pane {
position: sticky;
top: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-block {
width: 100%;
margin-top: 0.5rem;
}
.error {
padding: 10px 14px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-left: 3px solid var(--color-danger);
border-radius: var(--radius-sm);
color: var(--color-danger);
font-size: 14px;
@media (max-width: 900px) {
.booking-layout {
grid-template-columns: 1fr;
}
.form-pane {
position: static;
}
}
.success-state {
text-align: center;
padding: 24px 0;
padding: 32px 24px;
max-width: 480px;
margin: 0 auto;
}
.success-icon {
@@ -462,9 +538,9 @@ h2 {
margin-bottom: 8px;
}
.success-state p {
.ref-code {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: 20px;
}
.login-hint {
@@ -485,9 +561,27 @@ h2 {
text-decoration: underline;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover {
background: var(--color-accent-hover);
}
</style>