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:
11
TODOS.md
Normal file
11
TODOS.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# TODOS
|
||||
|
||||
## Din review-ul /autoplan „Linkuri publice" (2026-07-10)
|
||||
|
||||
- [ ] **Istoric/alias slug după redenumire** (P3, M) — la editarea slug-ului, linkurile text vechi mor (QR-ul e pe ID și supraviețuiește). Un tabel `slug_history` cu redirect 301 ar păstra toate linkurile partajate. Context: decizia v1 = fără alias, cu warning în UI la editare. De făcut dacă apar plângeri de linkuri moarte.
|
||||
- [ ] **Widget embed calendar public** (P3, L) — endpoint-ul busy e fundația; un `<script>` embed „adaugă calendarul pe site-ul tău" ar fi diferențiator real față de Skedda/Cal.com pe nișa SMB RO. Post-v1.
|
||||
- [ ] **Migrare la Alembic** (P3, M) — `alembic==1.13.2` e deja în requirements dar nefolosit; azi migrările sunt scripturi manuale (migrate_to_multi_property.py, migrate_add_property_slug.py). De consolidat când se acumulează a 3-a migrare manuală.
|
||||
- [ ] **N+1 space_count în list_public_properties** (P3, S) — public.py:38, un COUNT per proprietate; devine vizibil pe landing. Fix: un GROUP BY.
|
||||
- [ ] **Toggle `show_public_calendar` per proprietate** (P3, S) — unele proprietăți pot considera densitatea de ocupare sensibilă; azi calendarul public e mereu vizibil pentru proprietățile publice. Decizie gate G3 (amânat 2026-07-11).
|
||||
- [ ] **Link public per-spațiu `?space=<id>`** (P3, S) — deep-link pentru QR pe ușa fiecărei săli; busy e deja per-spațiu. Decizie gate G4.
|
||||
- [ ] **Metrică rezervări anonime per proprietate în AdminReports** (P3, S) — măsoară dacă linkurile publice produc ceva; COUNT pe is_anonymous. Decizie gate G5.
|
||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -28,6 +28,9 @@ env/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_admin, get_current_manager_or_superadmin, get_current_user, get_db
|
||||
from app.core.permissions import get_manager_property_ids, verify_property_access
|
||||
from app.core.slug import DEMO_SLUG
|
||||
from app.models.booking import Booking
|
||||
from app.models.property import Property
|
||||
from app.models.property_manager import PropertyManager
|
||||
from app.models.settings import Settings
|
||||
from app.models.space import Space
|
||||
@@ -61,6 +63,18 @@ def _verify_manager_booking_access(db: Session, booking: Booking, current_user:
|
||||
)
|
||||
|
||||
|
||||
def _is_demo_property_space(db: Session, space: Space | None) -> bool:
|
||||
"""Return True if the given space belongs to the demo property.
|
||||
|
||||
Used to suppress guest notification emails for the public demo property
|
||||
(avoids spamming real inboxes from demo/test bookings).
|
||||
"""
|
||||
if space is None or not space.property_id:
|
||||
return False
|
||||
prop = db.query(Property).filter(Property.id == space.property_id).first()
|
||||
return bool(prop and prop.slug == DEMO_SLUG)
|
||||
|
||||
|
||||
def _verify_manager_space_access(db: Session, space: Space, current_user: User) -> None:
|
||||
"""Verify that a manager has access to a space's property.
|
||||
|
||||
@@ -931,15 +945,15 @@ def approve_booking(
|
||||
booking.user.full_name,
|
||||
None,
|
||||
)
|
||||
elif booking.guest_email:
|
||||
# Send email notification to anonymous guest
|
||||
elif booking.guest_email and not _is_demo_property_space(db, booking.space):
|
||||
# Send email notification to anonymous guest (skipped for demo property)
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_approved",
|
||||
booking.guest_email,
|
||||
booking.guest_name or "Guest",
|
||||
None,
|
||||
{"reference_code": f"RB-{booking.id}"},
|
||||
)
|
||||
|
||||
return booking
|
||||
@@ -1018,14 +1032,14 @@ def reject_booking(
|
||||
booking.user.full_name,
|
||||
{"rejection_reason": reject_data.reason},
|
||||
)
|
||||
elif booking.guest_email:
|
||||
elif booking.guest_email and not _is_demo_property_space(db, booking.space):
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_rejected",
|
||||
booking.guest_email,
|
||||
booking.guest_name or "Guest",
|
||||
{"rejection_reason": reject_data.reason},
|
||||
{"rejection_reason": reject_data.reason, "reference_code": f"RB-{booking.id}"},
|
||||
)
|
||||
|
||||
return booking
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import (
|
||||
@@ -12,6 +13,7 @@ from app.core.deps import (
|
||||
get_optional_user,
|
||||
)
|
||||
from app.core.permissions import get_manager_property_ids, verify_property_access
|
||||
from app.core.slug import generate_unique_slug, validate_slug
|
||||
from app.models.organization import Organization
|
||||
from app.models.property import Property
|
||||
from app.models.property_access import PropertyAccess
|
||||
@@ -125,6 +127,8 @@ def list_properties(
|
||||
created_at=p.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, p.id),
|
||||
slug=p.slug,
|
||||
list_on_landing=p.list_on_landing,
|
||||
))
|
||||
return result
|
||||
|
||||
@@ -141,6 +145,8 @@ def get_property(
|
||||
spaces = db.query(Space).filter(Space.property_id == property_id, Space.is_active == True).all() # noqa: E712
|
||||
space_count = len(spaces)
|
||||
|
||||
settings_row = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
|
||||
|
||||
return PropertyWithSpaces(
|
||||
id=prop.id,
|
||||
name=prop.name,
|
||||
@@ -152,6 +158,9 @@ def get_property(
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
spaces=[SpaceResponse.model_validate(s) for s in spaces],
|
||||
slug=prop.slug,
|
||||
list_on_landing=prop.list_on_landing,
|
||||
require_approval=settings_row.require_approval if settings_row else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -190,8 +199,11 @@ def create_property(
|
||||
description=data.description,
|
||||
address=data.address,
|
||||
is_public=data.is_public,
|
||||
list_on_landing=data.list_on_landing,
|
||||
)
|
||||
db.add(prop)
|
||||
db.flush()
|
||||
prop.slug = generate_unique_slug(db, data.name, exclude_id=prop.id)
|
||||
db.commit()
|
||||
db.refresh(prop)
|
||||
|
||||
@@ -219,6 +231,8 @@ def create_property(
|
||||
created_at=prop.created_at,
|
||||
space_count=0,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
slug=prop.slug,
|
||||
list_on_landing=prop.list_on_landing,
|
||||
)
|
||||
|
||||
|
||||
@@ -241,10 +255,37 @@ def update_property(
|
||||
prop.address = data.address
|
||||
if data.is_public is not None:
|
||||
prop.is_public = data.is_public
|
||||
if data.list_on_landing is not None:
|
||||
prop.list_on_landing = data.list_on_landing
|
||||
|
||||
slug_changed = False
|
||||
old_slug = prop.slug
|
||||
if data.slug is not None and data.slug != prop.slug:
|
||||
if not validate_slug(data.slug):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Slug invalid. Folosește doar litere mici, cifre și cratime (3-64 caractere).",
|
||||
)
|
||||
prop.slug = data.slug
|
||||
slug_changed = True
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slug deja folosit")
|
||||
db.refresh(prop)
|
||||
|
||||
if slug_changed:
|
||||
log_action(
|
||||
db=db,
|
||||
action="property_slug_changed",
|
||||
user_id=current_user.id,
|
||||
target_type="property",
|
||||
target_id=prop.id,
|
||||
details={"old": old_slug, "new": prop.slug},
|
||||
)
|
||||
|
||||
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
|
||||
return PropertyResponse(
|
||||
id=prop.id,
|
||||
@@ -256,6 +297,8 @@ def update_property(
|
||||
created_at=prop.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
slug=prop.slug,
|
||||
list_on_landing=prop.list_on_landing,
|
||||
)
|
||||
|
||||
|
||||
@@ -284,6 +327,8 @@ def update_property_status(
|
||||
created_at=prop.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, prop.id),
|
||||
slug=prop.slug,
|
||||
list_on_landing=prop.list_on_landing,
|
||||
)
|
||||
|
||||
|
||||
@@ -517,6 +562,8 @@ def admin_list_all_properties(
|
||||
created_at=p.created_at,
|
||||
space_count=space_count,
|
||||
managers=_get_property_managers(db, p.id),
|
||||
slug=p.slug,
|
||||
list_on_landing=p.list_on_landing,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,27 +1,169 @@
|
||||
"""Public/anonymous endpoints (no auth required)."""
|
||||
from datetime import datetime
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Query as SAQuery
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_db
|
||||
from app.core.slug import DEMO_SLUG
|
||||
from app.models.booking import Booking
|
||||
from app.models.property import Property
|
||||
from app.models.property_manager import PropertyManager
|
||||
from app.models.property_settings import PropertySettings
|
||||
from app.models.space import Space
|
||||
from app.models.user import User
|
||||
from app.schemas.booking import AnonymousBookingCreate, AvailabilityCheck, BookingResponse, ConflictingBooking
|
||||
from app.schemas.booking import (
|
||||
AnonymousBookingCreate,
|
||||
AvailabilityCheck,
|
||||
BookingResponse,
|
||||
ConflictingBooking,
|
||||
)
|
||||
from app.schemas.property import PropertyResponse
|
||||
from app.schemas.space import SpaceResponse
|
||||
from app.services.booking_service import validate_booking_rules
|
||||
from app.services.email_service import send_booking_notification
|
||||
from app.services.notification_service import create_notification
|
||||
|
||||
router = APIRouter(prefix="/public", tags=["public"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def overlapping_bookings_query(
|
||||
db: Session,
|
||||
space_id: int,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
statuses: tuple[str, ...] = ("approved", "pending"),
|
||||
) -> SAQuery:
|
||||
"""Shared overlap predicate: any booking on `space_id` whose interval
|
||||
intersects [start, end), restricted to `statuses`."""
|
||||
return db.query(Booking).filter(
|
||||
Booking.space_id == space_id,
|
||||
Booking.status.in_(statuses),
|
||||
or_(
|
||||
and_(
|
||||
Booking.start_datetime <= start,
|
||||
Booking.end_datetime > start,
|
||||
),
|
||||
and_(
|
||||
Booking.start_datetime < end,
|
||||
Booking.end_datetime >= end,
|
||||
),
|
||||
and_(
|
||||
Booking.start_datetime >= start,
|
||||
Booking.end_datetime <= end,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_property(db: Session, slug_or_id: str) -> Property | None:
|
||||
"""Resolve a property by numeric id (all-digit segment) or by slug."""
|
||||
if slug_or_id.isdigit():
|
||||
return db.query(Property).filter(Property.id == int(slug_or_id)).first()
|
||||
return db.query(Property).filter(Property.slug == slug_or_id).first()
|
||||
|
||||
|
||||
def _is_visible(prop: Property | None) -> bool:
|
||||
"""Uniform visibility rule for public endpoints: must exist, be public,
|
||||
and be active. Callers turn a False result into a bare 404 (no 403
|
||||
oracle that would let clients distinguish private-vs-missing)."""
|
||||
return bool(prop and prop.is_public and prop.is_active)
|
||||
|
||||
|
||||
def _require_approval_for(db: Session, property_id: int) -> bool | None:
|
||||
settings_row = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
|
||||
return settings_row.require_approval if settings_row else None
|
||||
|
||||
|
||||
def _property_to_response(db: Session, prop: Property) -> PropertyResponse:
|
||||
space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
|
||||
return PropertyResponse(
|
||||
id=prop.id,
|
||||
name=prop.name,
|
||||
description=prop.description,
|
||||
address=prop.address,
|
||||
is_public=prop.is_public,
|
||||
is_active=prop.is_active,
|
||||
created_at=prop.created_at,
|
||||
space_count=space_count,
|
||||
slug=prop.slug,
|
||||
list_on_landing=prop.list_on_landing,
|
||||
require_approval=_require_approval_for(db, prop.id),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_visible_space_property(db: Session, space: Space) -> Property | None:
|
||||
"""Return the space's property iff it exists, is public, and active.
|
||||
Returns None (caller raises 404) for orphan spaces or private/inactive
|
||||
properties -- uniform 404, no 403 oracle."""
|
||||
if not space.property_id:
|
||||
return None
|
||||
prop = db.query(Property).filter(Property.id == space.property_id).first()
|
||||
if not _is_visible(prop):
|
||||
return None
|
||||
return prop
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process per-IP rate limiter for anonymous booking creation.
|
||||
#
|
||||
# A simple sliding-window counter is used instead of pulling in slowapi:
|
||||
# it needs no app-level wiring (this module cannot touch app/main.py under
|
||||
# the current work split) and is trivially bypassed in tests via the
|
||||
# PYTEST_CURRENT_TEST env var pytest sets automatically, or DISABLE_RATE_LIMIT.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RATE_LIMIT_WINDOW = timedelta(hours=1)
|
||||
_RATE_LIMIT_MAX = 5
|
||||
_rate_limit_buckets: dict[str, list[datetime]] = defaultdict(list)
|
||||
|
||||
|
||||
def _rate_limiting_enabled() -> bool:
|
||||
if os.getenv("PYTEST_CURRENT_TEST"):
|
||||
return False
|
||||
if os.getenv("DISABLE_RATE_LIMIT"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _enforce_booking_rate_limit(request: Request) -> None:
|
||||
if not _rate_limiting_enabled():
|
||||
return
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
now = datetime.utcnow()
|
||||
bucket = _rate_limit_buckets[ip]
|
||||
bucket[:] = [t for t in bucket if now - t < _RATE_LIMIT_WINDOW]
|
||||
if len(bucket) >= _RATE_LIMIT_MAX:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Prea multe cereri de rezervare de la această adresă IP. Încearcă din nou peste o oră.",
|
||||
)
|
||||
bucket.append(now)
|
||||
|
||||
|
||||
class BusyInterval(BaseModel):
|
||||
"""Minimal busy-slot payload for the public calendar. No title, name,
|
||||
email, or status is ever exposed here."""
|
||||
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/properties", response_model=list[PropertyResponse])
|
||||
def list_public_properties(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
@@ -33,43 +175,68 @@ def list_public_properties(
|
||||
.order_by(Property.name)
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for p in properties:
|
||||
space_count = db.query(Space).filter(Space.property_id == p.id, Space.is_active == True).count() # noqa: E712
|
||||
result.append(PropertyResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
description=p.description,
|
||||
address=p.address,
|
||||
is_public=p.is_public,
|
||||
is_active=p.is_active,
|
||||
created_at=p.created_at,
|
||||
space_count=space_count,
|
||||
))
|
||||
return result
|
||||
return [_property_to_response(db, p) for p in properties]
|
||||
|
||||
|
||||
@router.get("/properties/{property_id}/spaces", response_model=list[SpaceResponse])
|
||||
@router.get("/properties/{slug_or_id}", response_model=PropertyResponse)
|
||||
def get_public_property(
|
||||
slug_or_id: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> PropertyResponse:
|
||||
"""Resolve a public property by slug or numeric id (no auth required)."""
|
||||
prop = _resolve_property(db, slug_or_id)
|
||||
if not _is_visible(prop):
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
return _property_to_response(db, prop)
|
||||
|
||||
|
||||
@router.get("/properties/{slug_or_id}/spaces", response_model=list[SpaceResponse])
|
||||
def list_public_property_spaces(
|
||||
property_id: int,
|
||||
slug_or_id: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> list[SpaceResponse]:
|
||||
"""List spaces of a public property (no auth required)."""
|
||||
prop = db.query(Property).filter(Property.id == property_id).first()
|
||||
if not prop:
|
||||
prop = _resolve_property(db, slug_or_id)
|
||||
if not _is_visible(prop):
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
if not prop.is_public:
|
||||
raise HTTPException(status_code=403, detail="Property is private")
|
||||
|
||||
spaces = (
|
||||
db.query(Space)
|
||||
.filter(Space.property_id == property_id, Space.is_active == True) # noqa: E712
|
||||
.filter(Space.property_id == prop.id, Space.is_active == True) # noqa: E712
|
||||
.order_by(Space.name)
|
||||
.all()
|
||||
)
|
||||
return [SpaceResponse.model_validate(s) for s in spaces]
|
||||
|
||||
|
||||
@router.get("/spaces/{space_id}/busy", response_model=list[BusyInterval])
|
||||
def get_public_space_busy(
|
||||
space_id: int,
|
||||
start: Annotated[datetime, Query()],
|
||||
end: Annotated[datetime, Query()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> list[BusyInterval]:
|
||||
"""Return only the busy intervals for a space (no PII, no auth)."""
|
||||
if start.tzinfo is not None or end.tzinfo is not None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="start/end must be naive datetimes (property local time), not timezone-aware",
|
||||
)
|
||||
if end <= start:
|
||||
raise HTTPException(status_code=422, detail="end must be after start")
|
||||
if (end - start) > timedelta(days=60):
|
||||
raise HTTPException(status_code=422, detail="Range cannot exceed 60 days")
|
||||
|
||||
space = db.query(Space).filter(Space.id == space_id).first()
|
||||
if not space:
|
||||
raise HTTPException(status_code=404, detail="Space not found")
|
||||
if not _resolve_visible_space_property(db, space):
|
||||
raise HTTPException(status_code=404, detail="Space not found")
|
||||
|
||||
bookings = overlapping_bookings_query(db, space_id, start, end, statuses=("approved", "pending")).all()
|
||||
return [BusyInterval(start_time=b.start_datetime, end_time=b.end_datetime) for b in bookings]
|
||||
|
||||
|
||||
@router.get("/spaces/{space_id}/availability", response_model=AvailabilityCheck)
|
||||
def check_public_availability(
|
||||
space_id: int,
|
||||
@@ -81,36 +248,13 @@ def check_public_availability(
|
||||
space = db.query(Space).filter(Space.id == space_id).first()
|
||||
if not space:
|
||||
raise HTTPException(status_code=404, detail="Space not found")
|
||||
|
||||
# Verify space belongs to a public property
|
||||
if space.property_id:
|
||||
prop = db.query(Property).filter(Property.id == space.property_id).first()
|
||||
if prop and not prop.is_public:
|
||||
raise HTTPException(status_code=403, detail="Property is private")
|
||||
if not _resolve_visible_space_property(db, space):
|
||||
raise HTTPException(status_code=404, detail="Space not found")
|
||||
|
||||
# Find conflicting bookings
|
||||
conflicts = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.space_id == space_id,
|
||||
Booking.status.in_(["approved", "pending"]),
|
||||
or_(
|
||||
and_(
|
||||
Booking.start_datetime <= start_datetime,
|
||||
Booking.end_datetime > start_datetime,
|
||||
),
|
||||
and_(
|
||||
Booking.start_datetime < end_datetime,
|
||||
Booking.end_datetime >= end_datetime,
|
||||
),
|
||||
and_(
|
||||
Booking.start_datetime >= start_datetime,
|
||||
Booking.end_datetime <= end_datetime,
|
||||
),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
conflicts = overlapping_bookings_query(
|
||||
db, space_id, start_datetime, end_datetime, statuses=("approved", "pending")
|
||||
).all()
|
||||
|
||||
if not conflicts:
|
||||
return AvailabilityCheck(available=True, conflicts=[], message="Time slot is available")
|
||||
@@ -126,10 +270,13 @@ def check_public_availability(
|
||||
return AvailabilityCheck(
|
||||
available=approved_count == 0,
|
||||
conflicts=[
|
||||
# user_name/title are scrubbed here: this endpoint is reachable
|
||||
# by anonymous callers and must not leak PII about other
|
||||
# bookers (privacy regression fix).
|
||||
ConflictingBooking(
|
||||
id=b.id,
|
||||
user_name=b.user.full_name if b.user else (b.guest_name or "Anonymous"),
|
||||
title=b.title,
|
||||
user_name="",
|
||||
title="",
|
||||
status=b.status,
|
||||
start_datetime=b.start_datetime,
|
||||
end_datetime=b.end_datetime,
|
||||
@@ -144,9 +291,12 @@ def check_public_availability(
|
||||
def create_anonymous_booking(
|
||||
data: AnonymousBookingCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> BookingResponse:
|
||||
"""Create an anonymous/guest booking (no auth required)."""
|
||||
_enforce_booking_rate_limit(request)
|
||||
|
||||
# Validate space exists
|
||||
space = db.query(Space).filter(Space.id == data.space_id).first()
|
||||
if not space:
|
||||
@@ -164,18 +314,36 @@ def create_anonymous_booking(
|
||||
if data.end_datetime <= data.start_datetime:
|
||||
raise HTTPException(status_code=400, detail="End time must be after start time")
|
||||
|
||||
# Check for overlapping approved bookings
|
||||
overlapping = db.query(Booking).filter(
|
||||
Booking.space_id == data.space_id,
|
||||
Booking.status == "approved",
|
||||
and_(
|
||||
Booking.start_datetime < data.end_datetime,
|
||||
Booking.end_datetime > data.start_datetime,
|
||||
),
|
||||
# Reject overlap with any pending OR approved booking (previously
|
||||
# approved-only, which let guests double-book against pending requests).
|
||||
overlapping = overlapping_bookings_query(
|
||||
db, data.space_id, data.start_datetime, data.end_datetime, statuses=("approved", "pending")
|
||||
).first()
|
||||
if overlapping:
|
||||
raise HTTPException(status_code=400, detail="Time slot is already booked")
|
||||
|
||||
# Cap pending requests per guest/space/day to deter spam.
|
||||
day_start = datetime.combine(data.start_datetime.date(), time.min)
|
||||
day_end = datetime.combine(data.start_datetime.date(), time.max)
|
||||
pending_today = (
|
||||
db.query(Booking)
|
||||
.filter(
|
||||
Booking.space_id == data.space_id,
|
||||
Booking.guest_email == data.guest_email,
|
||||
Booking.status == "pending",
|
||||
Booking.start_datetime >= day_start,
|
||||
Booking.start_datetime <= day_end,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if pending_today >= 5:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Ai atins limita de 5 cereri în așteptare pentru acest spațiu în această zi.",
|
||||
)
|
||||
|
||||
is_demo = bool(prop and prop.slug == DEMO_SLUG)
|
||||
|
||||
# Create anonymous booking
|
||||
booking = Booking(
|
||||
user_id=None,
|
||||
@@ -195,8 +363,9 @@ def create_anonymous_booking(
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Notify property managers
|
||||
if space.property_id:
|
||||
# Notify property managers/superadmins -- suppressed entirely for the
|
||||
# demo property so the public playground doesn't spam real staff.
|
||||
if space.property_id and not is_demo:
|
||||
manager_ids = [
|
||||
pm.user_id
|
||||
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all()
|
||||
@@ -224,4 +393,16 @@ def create_anonymous_booking(
|
||||
{"guest_email": data.guest_email},
|
||||
)
|
||||
|
||||
# Confirmation email to the guest themselves -- also suppressed for the
|
||||
# demo property (its data is wiped every few hours; nothing to confirm).
|
||||
if not is_demo:
|
||||
background_tasks.add_task(
|
||||
send_booking_notification,
|
||||
booking,
|
||||
"anonymous_created",
|
||||
data.guest_email,
|
||||
data.guest_name,
|
||||
{"guest_email": data.guest_email},
|
||||
)
|
||||
|
||||
return BookingResponse.model_validate(booking)
|
||||
|
||||
98
backend/app/core/slug.py
Normal file
98
backend/app/core/slug.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Slug generation and validation for public property booking links.
|
||||
|
||||
Single source of truth for slug rules shared across the API, migrations,
|
||||
and the demo seed script.
|
||||
"""
|
||||
import re
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Paths/segments that must never be shadowed by a property slug.
|
||||
RESERVED_SLUGS = {
|
||||
"api", "admin", "book", "login", "register", "dashboard", "properties",
|
||||
"public", "spaces", "organization", "users", "verify", "settings",
|
||||
"reports", "confidentialitate", "termeni",
|
||||
}
|
||||
|
||||
# Stable, deterministic slug reserved for the public demo property.
|
||||
DEMO_SLUG = "proprietate-demo"
|
||||
|
||||
# Format accepted for user-supplied (PATCH) slugs: lowercase alnum + hyphen,
|
||||
# 3-64 chars total, no leading/trailing/double hyphen (single run of `-`
|
||||
# separating alnum groups), enforced by the regex plus extra checks below.
|
||||
SLUG_REGEX = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,62}[a-z0-9])$")
|
||||
|
||||
# RO diacritics -> ASCII transliteration map.
|
||||
_RO_CHAR_MAP = {
|
||||
"ă": "a", "â": "a", "î": "i", "ș": "s", "ş": "s", "ț": "t", "ţ": "t",
|
||||
"Ă": "a", "Â": "a", "Î": "i", "Ș": "s", "Ş": "s", "Ț": "t", "Ţ": "t",
|
||||
}
|
||||
|
||||
_NON_ALNUM_RUN = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Transliterate RO diacritics, lowercase, and kebab-case a name.
|
||||
|
||||
Non `[a-z0-9]` runs collapse to a single `-`; leading/trailing/double
|
||||
hyphens are stripped. Pure stdlib, no external dependency.
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
out = []
|
||||
for ch in name:
|
||||
out.append(_RO_CHAR_MAP.get(ch, ch))
|
||||
text = "".join(out).lower()
|
||||
text = _NON_ALNUM_RUN.sub("-", text)
|
||||
return text.strip("-")
|
||||
|
||||
|
||||
def _is_all_digit(slug: str) -> bool:
|
||||
return slug.isdigit()
|
||||
|
||||
|
||||
def validate_slug(slug: str) -> bool:
|
||||
"""Validate a user-supplied slug against the PATCH format rules.
|
||||
|
||||
Rules: `[a-z0-9-]{3,64}`, no leading/trailing/double hyphen, not
|
||||
all-digit (would shadow numeric property IDs), not reserved.
|
||||
"""
|
||||
if not slug:
|
||||
return False
|
||||
if len(slug) < 3 or len(slug) > 64:
|
||||
return False
|
||||
if not SLUG_REGEX.match(slug):
|
||||
return False
|
||||
if "--" in slug:
|
||||
return False
|
||||
if _is_all_digit(slug):
|
||||
return False
|
||||
if slug in RESERVED_SLUGS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def generate_unique_slug(db: Session, name: str, exclude_id: int | None = None) -> str:
|
||||
"""Generate a unique, safe slug for a property from its name.
|
||||
|
||||
Idempotent: given the same name and existing DB state, always returns
|
||||
the same result. Empty/all-digit/reserved base slugs get a `p-` prefix.
|
||||
Collisions are resolved by appending `-2`, `-3`, etc.
|
||||
"""
|
||||
from app.models.property import Property
|
||||
|
||||
base = slugify(name)
|
||||
if not base or _is_all_digit(base) or base in RESERVED_SLUGS:
|
||||
base = f"p-{base}" if base else "p"
|
||||
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while True:
|
||||
if candidate not in RESERVED_SLUGS:
|
||||
query = db.query(Property).filter(Property.slug == candidate)
|
||||
if exclude_id is not None:
|
||||
query = query.filter(Property.id != exclude_id)
|
||||
if query.first() is None:
|
||||
return candidate
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
@@ -1,16 +1,28 @@
|
||||
"""Database session management."""
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_is_sqlite = "sqlite" in settings.database_url
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {},
|
||||
connect_args={"check_same_thread": False} if _is_sqlite else {},
|
||||
)
|
||||
|
||||
if _is_sqlite:
|
||||
@event.listens_for(engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_connection, connection_record) -> None:
|
||||
"""Enable WAL mode + a busy timeout so concurrent readers/writers
|
||||
don't immediately hit 'database is locked' errors."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.close()
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -18,3 +18,5 @@ class Property(Base):
|
||||
is_public = Column(Boolean, default=True, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
slug = Column(String, unique=True, nullable=True, index=True)
|
||||
list_on_landing = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Booking schemas for request/response."""
|
||||
from datetime import datetime, date
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class BookingCalendarPublic(BaseModel):
|
||||
@@ -276,5 +276,5 @@ class AnonymousBookingCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
guest_name: str = Field(..., min_length=1)
|
||||
guest_email: str = Field(..., min_length=1)
|
||||
guest_email: EmailStr
|
||||
guest_organization: str | None = None
|
||||
|
||||
@@ -9,6 +9,7 @@ class PropertyCreate(BaseModel):
|
||||
description: str | None = None
|
||||
address: str | None = None
|
||||
is_public: bool = True
|
||||
list_on_landing: bool = False
|
||||
|
||||
|
||||
class PropertyUpdate(BaseModel):
|
||||
@@ -16,6 +17,8 @@ class PropertyUpdate(BaseModel):
|
||||
description: str | None = None
|
||||
address: str | None = None
|
||||
is_public: bool | None = None
|
||||
slug: str | None = None
|
||||
list_on_landing: bool | None = None
|
||||
|
||||
|
||||
class PropertyManagerInfo(BaseModel):
|
||||
@@ -34,6 +37,9 @@ class PropertyResponse(BaseModel):
|
||||
created_at: datetime
|
||||
space_count: int = 0
|
||||
managers: list[PropertyManagerInfo] = []
|
||||
slug: str | None = None
|
||||
list_on_landing: bool = False
|
||||
require_approval: bool | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -153,11 +153,13 @@ Sistemul de Rezervări
|
||||
"""
|
||||
|
||||
elif event_type == "anonymous_approved":
|
||||
reference_code = extra_data.get("reference_code", f"RB-{booking.id}") if extra_data else f"RB-{booking.id}"
|
||||
subject = "Rezervare Aprobată"
|
||||
body = f"""Bună ziua {user_name},
|
||||
|
||||
Rezervarea dumneavoastră a fost aprobată:
|
||||
|
||||
Cod de referință: #{reference_code}
|
||||
Spațiu: {space_name}
|
||||
Data și ora: {start_str} - {end_str}
|
||||
Titlu: {booking.title}
|
||||
@@ -170,11 +172,13 @@ Sistemul de Rezervări
|
||||
|
||||
elif event_type == "anonymous_rejected":
|
||||
reason = extra_data.get("rejection_reason", "Nu a fost specificat") if extra_data else "Nu a fost specificat"
|
||||
reference_code = extra_data.get("reference_code", f"RB-{booking.id}") if extra_data else f"RB-{booking.id}"
|
||||
subject = "Rezervare Respinsă"
|
||||
body = f"""Bună ziua {user_name},
|
||||
|
||||
Rezervarea dumneavoastră a fost respinsă:
|
||||
|
||||
Cod de referință: #{reference_code}
|
||||
Spațiu: {space_name}
|
||||
Data și ora: {start_str} - {end_str}
|
||||
Titlu: {booking.title}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "[entrypoint] Running property slug migration..."
|
||||
python migrate_add_property_slug.py
|
||||
|
||||
# Database tables are created automatically on application startup
|
||||
# (app/main.py runs Base.metadata.create_all). The first user to register
|
||||
# becomes the superadmin (the instance owner), so no admin seeding is needed.
|
||||
|
||||
89
backend/migrate_add_property_slug.py
Normal file
89
backend/migrate_add_property_slug.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Migration script to add public-booking-link slug support to properties.
|
||||
|
||||
Idempotent (safe to run multiple times):
|
||||
- Adds `slug` (VARCHAR, nullable) and `list_on_landing` (BOOLEAN NOT NULL
|
||||
DEFAULT 0) columns to `properties` if missing.
|
||||
- Creates a UNIQUE INDEX on `slug` if missing.
|
||||
- Backfills NULL slugs deterministically from each property's name.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app.core.slug import generate_unique_slug
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models.property import Property
|
||||
|
||||
SLUG_INDEX_NAME = "ix_properties_slug_unique"
|
||||
|
||||
|
||||
def migrate() -> None:
|
||||
"""Run migration to add slug + list_on_landing support to properties."""
|
||||
inspector = inspect(engine)
|
||||
columns = [col["name"] for col in inspector.get_columns("properties")]
|
||||
|
||||
print("Starting property slug migration...")
|
||||
|
||||
with engine.connect() as conn:
|
||||
if "slug" not in columns:
|
||||
print("1. Adding slug column to properties...")
|
||||
conn.execute(text("ALTER TABLE properties ADD COLUMN slug VARCHAR"))
|
||||
conn.commit()
|
||||
print(" Column added.")
|
||||
else:
|
||||
print("1. slug column already exists.")
|
||||
|
||||
if "list_on_landing" not in columns:
|
||||
print("2. Adding list_on_landing column to properties...")
|
||||
conn.execute(
|
||||
text("ALTER TABLE properties ADD COLUMN list_on_landing BOOLEAN DEFAULT 0 NOT NULL")
|
||||
)
|
||||
conn.commit()
|
||||
print(" Column added.")
|
||||
else:
|
||||
print("2. list_on_landing column already exists.")
|
||||
|
||||
# Recompute indexes after potentially adding the slug column.
|
||||
inspector = inspect(engine)
|
||||
index_names = {ix["name"] for ix in inspector.get_indexes("properties")}
|
||||
if SLUG_INDEX_NAME not in index_names:
|
||||
print("3. Creating unique index on slug...")
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(f"CREATE UNIQUE INDEX {SLUG_INDEX_NAME} ON properties (slug)")
|
||||
)
|
||||
conn.commit()
|
||||
print(" Index created.")
|
||||
else:
|
||||
print("3. Unique index on slug already exists.")
|
||||
|
||||
# Backfill: deterministic unique slug for every property missing one.
|
||||
print("4. Backfilling missing slugs...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
properties_without_slug = (
|
||||
db.query(Property)
|
||||
.filter(Property.slug.is_(None))
|
||||
.order_by(Property.id)
|
||||
.all()
|
||||
)
|
||||
for prop in properties_without_slug:
|
||||
prop.slug = generate_unique_slug(db, prop.name, exclude_id=prop.id)
|
||||
db.flush()
|
||||
db.commit()
|
||||
print(f" Backfilled {len(properties_without_slug)} propert(y/ies).")
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print("Migration completed successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -15,6 +15,7 @@ from datetime import datetime, timedelta
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.core.slug import DEMO_SLUG
|
||||
from app.db.session import Base, SessionLocal, engine
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.booking import Booking
|
||||
@@ -126,8 +127,10 @@ def _seed_demo_data(db, demo: User) -> None:
|
||||
name=DEMO_PROPERTY_NAME,
|
||||
description="Mediu de test — datele se resetează automat la fiecare 3 ore.",
|
||||
address="Str. Exemplu nr. 1",
|
||||
is_public=False, # visible only to the demo account
|
||||
is_public=True,
|
||||
is_active=True,
|
||||
list_on_landing=True,
|
||||
slug=DEMO_SLUG,
|
||||
)
|
||||
db.add(prop)
|
||||
db.flush()
|
||||
|
||||
119
backend/tests/test_migrate_slug.py
Normal file
119
backend/tests/test_migrate_slug.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for the idempotent property-slug migration script.
|
||||
|
||||
Builds a temporary SQLite DB with the OLD `properties` schema (no slug /
|
||||
list_on_landing columns) via raw DDL, points the migration module at it,
|
||||
and verifies the migration adds the columns/index and deterministically
|
||||
backfills RO-transliterated, collision-safe slugs -- twice, with no error
|
||||
and no drift the second time.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import migrate_add_property_slug as migrate_mod
|
||||
|
||||
|
||||
def _create_old_schema_db(path: str) -> None:
|
||||
"""Create a properties table matching the pre-slug schema and seed rows."""
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE properties (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR,
|
||||
address VARCHAR,
|
||||
is_public BOOLEAN NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
rows = [
|
||||
("Clădirea Centrală", "2024-01-01T00:00:00"),
|
||||
# Same (accented) name again -> must collide and get a -2 suffix.
|
||||
("Clădirea Centrală", "2024-01-02T00:00:00"),
|
||||
("Sediul Ș.ț Test", "2024-01-03T00:00:00"),
|
||||
]
|
||||
for name, created_at in rows:
|
||||
conn.execute(
|
||||
"INSERT INTO properties (name, description, address, is_public, is_active, created_at) "
|
||||
"VALUES (?, NULL, NULL, 1, 1, ?)",
|
||||
(name, created_at),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path) -> str:
|
||||
path = tmp_path / "migrate_test.db"
|
||||
_create_old_schema_db(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_engine(monkeypatch: pytest.MonkeyPatch, temp_db_path: str):
|
||||
"""Point the migration module's module-level engine/SessionLocal at the temp DB."""
|
||||
engine = create_engine(f"sqlite:///{temp_db_path}", connect_args={"check_same_thread": False})
|
||||
session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
monkeypatch.setattr(migrate_mod, "engine", engine)
|
||||
monkeypatch.setattr(migrate_mod, "SessionLocal", session_local)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _fetch_rows(engine) -> list[tuple]:
|
||||
with engine.connect() as conn:
|
||||
return conn.execute(text("SELECT id, name, slug FROM properties ORDER BY id")).fetchall()
|
||||
|
||||
|
||||
def test_migration_adds_columns_and_unique_index(patched_engine) -> None:
|
||||
migrate_mod.migrate()
|
||||
|
||||
inspector = inspect(patched_engine)
|
||||
columns = {c["name"] for c in inspector.get_columns("properties")}
|
||||
assert "slug" in columns
|
||||
assert "list_on_landing" in columns
|
||||
|
||||
indexes = inspector.get_indexes("properties")
|
||||
slug_indexes = [ix for ix in indexes if "slug" in ix["column_names"]]
|
||||
assert slug_indexes, "expected an index on slug"
|
||||
assert any(ix["unique"] for ix in slug_indexes), "slug index must be unique"
|
||||
|
||||
|
||||
def test_migration_backfills_deterministic_ro_slugs_with_collision_suffix(patched_engine) -> None:
|
||||
migrate_mod.migrate()
|
||||
|
||||
rows = _fetch_rows(patched_engine)
|
||||
assert len(rows) == 3
|
||||
slugs = [r[2] for r in rows]
|
||||
|
||||
# RO diacritic transliteration: ă->a, â->a, î->i, ș/ş->s, ț/ţ->t.
|
||||
assert slugs[0] == "cladirea-centrala"
|
||||
# Second property has the identical (transliterated) name -> collision -> -2 suffix.
|
||||
assert slugs[1] == "cladirea-centrala-2"
|
||||
assert slugs[2] == "sediul-s-t-test"
|
||||
|
||||
# All slugs non-null and unique.
|
||||
assert all(s for s in slugs)
|
||||
assert len(set(slugs)) == len(slugs)
|
||||
|
||||
|
||||
def test_migration_is_idempotent_and_slugs_stable_on_rerun(patched_engine) -> None:
|
||||
migrate_mod.migrate()
|
||||
first = _fetch_rows(patched_engine)
|
||||
|
||||
# Running a second time must not raise and must not change existing slugs
|
||||
# or column/index state.
|
||||
migrate_mod.migrate()
|
||||
second = _fetch_rows(patched_engine)
|
||||
|
||||
assert first == second
|
||||
|
||||
inspector = inspect(patched_engine)
|
||||
columns = {c["name"] for c in inspector.get_columns("properties")}
|
||||
assert "slug" in columns
|
||||
assert "list_on_landing" in columns
|
||||
492
backend/tests/test_public.py
Normal file
492
backend/tests/test_public.py
Normal file
@@ -0,0 +1,492 @@
|
||||
"""Tests for public/anonymous booking endpoints and the property-slug PATCH path."""
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# Rate limiting is env-gated (PYTEST_CURRENT_TEST / DISABLE_RATE_LIMIT); set the
|
||||
# explicit override too in case this module is ever imported outside pytest's
|
||||
# normal collection (PYTEST_CURRENT_TEST is only set during actual test runs).
|
||||
os.environ["DISABLE_RATE_LIMIT"] = "1"
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.slug import DEMO_SLUG
|
||||
from app.models.booking import Booking
|
||||
from app.models.notification import Notification
|
||||
from app.models.property import Property
|
||||
from app.models.property_manager import PropertyManager
|
||||
from app.models.property_settings import PropertySettings
|
||||
from app.models.space import Space
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_property(db: Session) -> Property:
|
||||
prop = Property(
|
||||
name="Public Test Property",
|
||||
is_public=True,
|
||||
is_active=True,
|
||||
slug="public-test-property",
|
||||
list_on_landing=False,
|
||||
)
|
||||
db.add(prop)
|
||||
db.commit()
|
||||
db.refresh(prop)
|
||||
db.add(PropertySettings(property_id=prop.id, require_approval=True))
|
||||
db.commit()
|
||||
return prop
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_space(db: Session, public_property: Property) -> Space:
|
||||
space = Space(
|
||||
name="Public Room",
|
||||
type="sala",
|
||||
capacity=8,
|
||||
is_active=True,
|
||||
property_id=public_property.id,
|
||||
)
|
||||
db.add(space)
|
||||
db.commit()
|
||||
db.refresh(space)
|
||||
return space
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def private_property(db: Session) -> Property:
|
||||
prop = Property(
|
||||
name="Private Test Property",
|
||||
is_public=False,
|
||||
is_active=True,
|
||||
slug="private-test-property",
|
||||
list_on_landing=False,
|
||||
)
|
||||
db.add(prop)
|
||||
db.commit()
|
||||
db.refresh(prop)
|
||||
return prop
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def private_space(db: Session, private_property: Property) -> Space:
|
||||
space = Space(
|
||||
name="Private Room",
|
||||
type="sala",
|
||||
capacity=4,
|
||||
is_active=True,
|
||||
property_id=private_property.id,
|
||||
)
|
||||
db.add(space)
|
||||
db.commit()
|
||||
db.refresh(space)
|
||||
return space
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orphan_space(db: Session) -> Space:
|
||||
"""A space with no property (property_id NULL)."""
|
||||
space = Space(name="Orphan Room", type="sala", capacity=2, is_active=True, property_id=None)
|
||||
db.add(space)
|
||||
db.commit()
|
||||
db.refresh(space)
|
||||
return space
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def demo_property(db: Session) -> Property:
|
||||
prop = Property(
|
||||
name="Proprietate Demo",
|
||||
is_public=True,
|
||||
is_active=True,
|
||||
slug=DEMO_SLUG,
|
||||
list_on_landing=True,
|
||||
)
|
||||
db.add(prop)
|
||||
db.commit()
|
||||
db.refresh(prop)
|
||||
db.add(PropertySettings(property_id=prop.id, require_approval=True))
|
||||
db.commit()
|
||||
return prop
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def demo_space(db: Session, demo_property: Property) -> Space:
|
||||
space = Space(
|
||||
name="Demo Room",
|
||||
type="sala",
|
||||
capacity=6,
|
||||
is_active=True,
|
||||
property_id=demo_property.id,
|
||||
)
|
||||
db.add(space)
|
||||
db.commit()
|
||||
db.refresh(space)
|
||||
return space
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def property_manager_user(db: Session, public_property: Property) -> User:
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
user = User(
|
||||
email="propmanager@example.com",
|
||||
full_name="Property Manager",
|
||||
hashed_password=get_password_hash("managerpass"),
|
||||
role="manager",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
db.add(PropertyManager(property_id=public_property.id, user_id=user.id))
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager_headers(property_manager_user: User) -> dict[str, str]:
|
||||
from app.core.security import create_access_token
|
||||
|
||||
token = create_access_token(subject=int(property_manager_user.id))
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _guest_payload(space_id: int, start: datetime, end: datetime, email: str = "guest@example.com") -> dict:
|
||||
return {
|
||||
"space_id": space_id,
|
||||
"start_datetime": start.isoformat(),
|
||||
"end_datetime": end.isoformat(),
|
||||
"title": "Guest Meeting",
|
||||
"description": None,
|
||||
"guest_name": "Guest Person",
|
||||
"guest_email": email,
|
||||
"guest_organization": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# busy endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_busy_returns_only_start_end_times(
|
||||
client: TestClient, db: Session, public_space: Space
|
||||
) -> None:
|
||||
approved = Booking(
|
||||
space_id=public_space.id,
|
||||
title="Secret Title",
|
||||
description=None,
|
||||
start_datetime=datetime(2025, 6, 2, 10, 0, 0),
|
||||
end_datetime=datetime(2025, 6, 2, 11, 0, 0),
|
||||
status="approved",
|
||||
guest_name="Secret Guest",
|
||||
guest_email="secret@example.com",
|
||||
is_anonymous=True,
|
||||
)
|
||||
pending = Booking(
|
||||
space_id=public_space.id,
|
||||
title="Pending Title",
|
||||
start_datetime=datetime(2025, 6, 2, 13, 0, 0),
|
||||
end_datetime=datetime(2025, 6, 2, 14, 0, 0),
|
||||
status="pending",
|
||||
guest_name="Pending Guest",
|
||||
guest_email="pending@example.com",
|
||||
is_anonymous=True,
|
||||
)
|
||||
cancelled = Booking(
|
||||
space_id=public_space.id,
|
||||
title="Cancelled Title",
|
||||
start_datetime=datetime(2025, 6, 2, 16, 0, 0),
|
||||
end_datetime=datetime(2025, 6, 2, 17, 0, 0),
|
||||
status="cancelled",
|
||||
guest_name="Cancelled Guest",
|
||||
guest_email="cancelled@example.com",
|
||||
is_anonymous=True,
|
||||
)
|
||||
db.add_all([approved, pending, cancelled])
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
f"/api/public/spaces/{public_space.id}/busy",
|
||||
params={"start": "2025-06-01T00:00:00", "end": "2025-06-05T00:00:00"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2 # approved + pending, cancelled excluded
|
||||
|
||||
for item in data:
|
||||
assert set(item.keys()) == {"start_time", "end_time"}
|
||||
for forbidden in ("title", "user_name", "guest_name", "guest_email", "email", "status", "id"):
|
||||
assert forbidden not in item
|
||||
|
||||
|
||||
def test_busy_orphan_space_returns_404(client: TestClient, orphan_space: Space) -> None:
|
||||
response = client.get(
|
||||
f"/api/public/spaces/{orphan_space.id}/busy",
|
||||
params={"start": "2025-06-01T00:00:00", "end": "2025-06-05T00:00:00"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_busy_private_property_returns_404(client: TestClient, private_space: Space) -> None:
|
||||
response = client.get(
|
||||
f"/api/public/spaces/{private_space.id}/busy",
|
||||
params={"start": "2025-06-01T00:00:00", "end": "2025-06-05T00:00:00"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_busy_missing_space_returns_404(client: TestClient) -> None:
|
||||
response = client.get(
|
||||
"/api/public/spaces/999999/busy",
|
||||
params={"start": "2025-06-01T00:00:00", "end": "2025-06-05T00:00:00"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_busy_rejects_timezone_aware_params(client: TestClient, public_space: Space) -> None:
|
||||
response = client.get(
|
||||
f"/api/public/spaces/{public_space.id}/busy",
|
||||
params={
|
||||
"start": "2025-06-01T00:00:00+02:00",
|
||||
"end": "2025-06-05T00:00:00+02:00",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_busy_rejects_end_before_or_equal_start(client: TestClient, public_space: Space) -> None:
|
||||
response = client.get(
|
||||
f"/api/public/spaces/{public_space.id}/busy",
|
||||
params={"start": "2025-06-05T00:00:00", "end": "2025-06-01T00:00:00"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
response_eq = client.get(
|
||||
f"/api/public/spaces/{public_space.id}/busy",
|
||||
params={"start": "2025-06-01T00:00:00", "end": "2025-06-01T00:00:00"},
|
||||
)
|
||||
assert response_eq.status_code == 422
|
||||
|
||||
|
||||
def test_busy_rejects_range_over_60_days(client: TestClient, public_space: Space) -> None:
|
||||
response = client.get(
|
||||
f"/api/public/spaces/{public_space.id}/busy",
|
||||
params={"start": "2025-01-01T00:00:00", "end": "2025-06-01T00:00:00"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# slug resolve
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_property_by_slug(client: TestClient, public_property: Property) -> None:
|
||||
response = client.get(f"/api/public/properties/{public_property.slug}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == public_property.id
|
||||
assert response.json()["slug"] == public_property.slug
|
||||
|
||||
|
||||
def test_get_property_by_numeric_id(client: TestClient, public_property: Property) -> None:
|
||||
response = client.get(f"/api/public/properties/{public_property.id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["slug"] == public_property.slug
|
||||
|
||||
|
||||
def test_get_property_wrong_slug_404(client: TestClient) -> None:
|
||||
response = client.get("/api/public/properties/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_get_property_private_returns_404_not_403(client: TestClient, private_property: Property) -> None:
|
||||
response = client.get(f"/api/public/properties/{private_property.slug}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_get_property_spaces_by_slug(client: TestClient, public_property: Property, public_space: Space) -> None:
|
||||
response = client.get(f"/api/public/properties/{public_property.slug}/spaces")
|
||||
assert response.status_code == 200
|
||||
ids = [s["id"] for s in response.json()]
|
||||
assert public_space.id in ids
|
||||
|
||||
|
||||
def test_get_property_spaces_private_returns_404(client: TestClient, private_property: Property) -> None:
|
||||
response = client.get(f"/api/public/properties/{private_property.slug}/spaces")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_get_property_spaces_missing_returns_404(client: TestClient) -> None:
|
||||
response = client.get("/api/public/properties/does-not-exist/spaces")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# availability regression: no PII leak
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_availability_scrubs_user_name_and_title(client: TestClient, db: Session, public_space: Space) -> None:
|
||||
booking = Booking(
|
||||
space_id=public_space.id,
|
||||
title="Confidential Planning Session",
|
||||
start_datetime=datetime(2025, 7, 1, 10, 0, 0),
|
||||
end_datetime=datetime(2025, 7, 1, 11, 0, 0),
|
||||
status="approved",
|
||||
guest_name="Confidential Guest",
|
||||
guest_email="confidential@example.com",
|
||||
is_anonymous=True,
|
||||
)
|
||||
db.add(booking)
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
f"/api/public/spaces/{public_space.id}/availability",
|
||||
params={"start_datetime": "2025-07-01T10:30:00", "end_datetime": "2025-07-01T10:45:00"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["available"] is False
|
||||
assert len(data["conflicts"]) == 1
|
||||
conflict = data["conflicts"][0]
|
||||
# No PII leaked...
|
||||
assert conflict["user_name"] == ""
|
||||
assert conflict["title"] == ""
|
||||
# ...but useful scheduling info is preserved.
|
||||
assert conflict["status"] == "approved"
|
||||
assert conflict["start_datetime"]
|
||||
assert conflict["end_datetime"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH slug via PUT /manager/properties/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patch_slug_duplicate_returns_409(
|
||||
client: TestClient, db: Session, public_property: Property, manager_headers: dict[str, str]
|
||||
) -> None:
|
||||
other = Property(name="Other Property", is_public=True, is_active=True, slug="other-property")
|
||||
db.add(other)
|
||||
db.commit()
|
||||
db.refresh(other)
|
||||
|
||||
response = client.put(
|
||||
f"/api/manager/properties/{public_property.id}",
|
||||
json={"slug": other.slug},
|
||||
headers=manager_headers,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_slug",
|
||||
["ab", "has space", "123456", "admin", "-bad-start", "bad-end-", "a--b"],
|
||||
)
|
||||
def test_patch_slug_invalid_format_returns_422(
|
||||
client: TestClient, public_property: Property, manager_headers: dict[str, str], bad_slug: str
|
||||
) -> None:
|
||||
response = client.put(
|
||||
f"/api/manager/properties/{public_property.id}",
|
||||
json={"slug": bad_slug},
|
||||
headers=manager_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_patch_slug_valid_succeeds(
|
||||
client: TestClient, public_property: Property, manager_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.put(
|
||||
f"/api/manager/properties/{public_property.id}",
|
||||
json={"slug": "brand-new-slug"},
|
||||
headers=manager_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["slug"] == "brand-new-slug"
|
||||
|
||||
|
||||
def test_patch_slug_non_manager_returns_403(
|
||||
client: TestClient, public_property: Property, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.put(
|
||||
f"/api/manager/properties/{public_property.id}",
|
||||
json={"slug": "some-new-slug"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /public/bookings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_anonymous_booking_happy_path(client: TestClient, public_space: Space) -> None:
|
||||
start = datetime(2025, 8, 1, 9, 0, 0)
|
||||
end = datetime(2025, 8, 1, 10, 0, 0)
|
||||
response = client.post("/api/public/bookings", json=_guest_payload(public_space.id, start, end))
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["is_anonymous"] is True
|
||||
assert data["guest_email"] == "guest@example.com"
|
||||
assert data["status"] == "pending"
|
||||
|
||||
|
||||
def test_post_anonymous_booking_pending_overlap_rejected(client: TestClient, public_space: Space) -> None:
|
||||
start = datetime(2025, 8, 2, 9, 0, 0)
|
||||
end = datetime(2025, 8, 2, 10, 0, 0)
|
||||
first = client.post("/api/public/bookings", json=_guest_payload(public_space.id, start, end, "first@example.com"))
|
||||
assert first.status_code == 201
|
||||
|
||||
overlap_start = datetime(2025, 8, 2, 9, 30, 0)
|
||||
overlap_end = datetime(2025, 8, 2, 10, 30, 0)
|
||||
second = client.post(
|
||||
"/api/public/bookings",
|
||||
json=_guest_payload(public_space.id, overlap_start, overlap_end, "second@example.com"),
|
||||
)
|
||||
assert second.status_code == 400
|
||||
|
||||
|
||||
def test_post_anonymous_booking_cap_five_pending_per_email_per_day(client: TestClient, public_space: Space) -> None:
|
||||
email = "capped@example.com"
|
||||
for hour in range(9, 14): # 5 non-overlapping slots: 9-10,10-11,...,13-14
|
||||
start = datetime(2025, 8, 3, hour, 0, 0)
|
||||
end = datetime(2025, 8, 3, hour + 1, 0, 0)
|
||||
response = client.post("/api/public/bookings", json=_guest_payload(public_space.id, start, end, email))
|
||||
assert response.status_code == 201
|
||||
|
||||
# 6th request for same guest/space/day must be rejected regardless of overlap.
|
||||
sixth_start = datetime(2025, 8, 3, 15, 0, 0)
|
||||
sixth_end = datetime(2025, 8, 3, 16, 0, 0)
|
||||
sixth = client.post("/api/public/bookings", json=_guest_payload(public_space.id, sixth_start, sixth_end, email))
|
||||
assert sixth.status_code == 400
|
||||
|
||||
|
||||
def test_post_anonymous_booking_demo_property_no_notifications(
|
||||
client: TestClient, db: Session, demo_space: Space, test_admin: User
|
||||
) -> None:
|
||||
start = datetime(2025, 8, 4, 9, 0, 0)
|
||||
end = datetime(2025, 8, 4, 10, 0, 0)
|
||||
response = client.post("/api/public/bookings", json=_guest_payload(demo_space.id, start, end, "demoguest@example.com"))
|
||||
assert response.status_code == 201
|
||||
booking_id = response.json()["id"]
|
||||
|
||||
notifications = db.query(Notification).filter(Notification.booking_id == booking_id).all()
|
||||
assert notifications == []
|
||||
|
||||
|
||||
def test_post_anonymous_booking_invalid_email_returns_422(client: TestClient, public_space: Space) -> None:
|
||||
start = datetime(2025, 8, 5, 9, 0, 0)
|
||||
end = datetime(2025, 8, 5, 10, 0, 0)
|
||||
payload = _guest_payload(public_space.id, start, end, "not-an-email")
|
||||
response = client.post("/api/public/bookings", json=payload)
|
||||
assert response.status_code == 422
|
||||
274
frontend/package-lock.json
generated
274
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
337
frontend/src/components/GuestBookingForm.vue
Normal file
337
frontend/src/components/GuestBookingForm.vue
Normal 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>
|
||||
613
frontend/src/components/PublicCalendar.vue
Normal file
613
frontend/src/components/PublicCalendar.vue
Normal 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>
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<!-- 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" />
|
||||
<!-- Resolving property -->
|
||||
<div v-else-if="phase === 'resolving'" class="state-wrap">
|
||||
<div class="loading-inline">Se încarcă pagina de rezervare...</div>
|
||||
</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 />
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Step 4: Success -->
|
||||
<div v-else-if="step === 'success'" class="success-state">
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
|
||||
<div v-if="successBooking" class="success-state card">
|
||||
<div class="success-icon">✓</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">Fă 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 = ''
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
async function resolveProperty(slugOrId: string) {
|
||||
phase.value = 'resolving'
|
||||
loadError.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'
|
||||
property.value = await publicApi.getProperty(slugOrId)
|
||||
await loadSpaces(slugOrId)
|
||||
phase.value = 'ready'
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
phase.value = 'notfound'
|
||||
} else {
|
||||
loadError.value = handleApiError(err)
|
||||
phase.value = 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const booking = await publicApi.createBooking(payload)
|
||||
successBooking.value = booking
|
||||
selection.value = null
|
||||
} catch (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;
|
||||
@media (max-width: 900px) {
|
||||
.booking-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
.form-pane {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user