feat: sistem de linkuri publice pentru proprietăți

Expune fluxul de rezervare anonimă printr-un link public partajabil
(/book/<slug>), cu calendar public, cod QR și proprietate demo publică.

Backend:
- slug + list_on_landing pe Property; utilitar slugify (diacritice RO,
  cuvinte rezervate, ban all-digit) + migrare idempotentă raw-DDL rulată
  din entrypoint; SQLite WAL + busy_timeout
- GET /public/properties/{slug_or_id}(+/spaces): rezolvare slug/ID, 404 uniform
- GET /public/spaces/{id}/busy: doar {start_time,end_time} (fără PII),
  pending+approved, tz-aware/interval>60z → 422, orfan/privat → 404
- availability: scrub user_name/title (fix scurgere PII)
- POST /public/bookings: overlap pending+approved, cap 5/email/spațiu/zi,
  rate-limit per-IP, suprimare notificări+email pe demo, email confirmare guest
- PATCH slug (409 duplicat / 422 format / 403 non-manager); guest_email EmailStr
- email guest la aprobare/respingere cu cod #RB-<id>
- teste noi: test_public.py + test_migrate_slug.py (33 teste)

Frontend:
- rută /book/:slug; PublicBooking rescris cu calendar grid manual
  (PublicCalendar + GuestBookingForm), 30min, 08-20, mobil day-view, RO
- card „Link public" în PropertyDetail: copy, edit slug, QR pe URL-ul cu ID
- secțiune proprietăți publice + CTA pe Landing; buton copy în Properties

Gate aprobat (U1-U3, G1 grid manual, G2 list_on_landing); G3-G5 în TODOS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-11 19:04:58 +00:00
parent e38d78ecb8
commit a15951c645
27 changed files with 3311 additions and 375 deletions

11
TODOS.md Normal file
View 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
View File

@@ -28,6 +28,9 @@ env/
# Database # Database
*.db *.db
*.db-wal
*.db-shm
*.db-journal
*.sqlite *.sqlite
*.sqlite3 *.sqlite3

View File

@@ -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.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.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.booking import Booking
from app.models.property import Property
from app.models.property_manager import PropertyManager from app.models.property_manager import PropertyManager
from app.models.settings import Settings from app.models.settings import Settings
from app.models.space import Space 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: def _verify_manager_space_access(db: Session, space: Space, current_user: User) -> None:
"""Verify that a manager has access to a space's property. """Verify that a manager has access to a space's property.
@@ -931,15 +945,15 @@ def approve_booking(
booking.user.full_name, booking.user.full_name,
None, None,
) )
elif booking.guest_email: elif booking.guest_email and not _is_demo_property_space(db, booking.space):
# Send email notification to anonymous guest # Send email notification to anonymous guest (skipped for demo property)
background_tasks.add_task( background_tasks.add_task(
send_booking_notification, send_booking_notification,
booking, booking,
"anonymous_approved", "anonymous_approved",
booking.guest_email, booking.guest_email,
booking.guest_name or "Guest", booking.guest_name or "Guest",
None, {"reference_code": f"RB-{booking.id}"},
) )
return booking return booking
@@ -1018,14 +1032,14 @@ def reject_booking(
booking.user.full_name, booking.user.full_name,
{"rejection_reason": reject_data.reason}, {"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( background_tasks.add_task(
send_booking_notification, send_booking_notification,
booking, booking,
"anonymous_rejected", "anonymous_rejected",
booking.guest_email, booking.guest_email,
booking.guest_name or "Guest", booking.guest_name or "Guest",
{"rejection_reason": reject_data.reason}, {"rejection_reason": reject_data.reason, "reference_code": f"RB-{booking.id}"},
) )
return booking return booking

View File

@@ -2,6 +2,7 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.deps import ( from app.core.deps import (
@@ -12,6 +13,7 @@ from app.core.deps import (
get_optional_user, get_optional_user,
) )
from app.core.permissions import get_manager_property_ids, verify_property_access 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.organization import Organization
from app.models.property import Property from app.models.property import Property
from app.models.property_access import PropertyAccess from app.models.property_access import PropertyAccess
@@ -125,6 +127,8 @@ def list_properties(
created_at=p.created_at, created_at=p.created_at,
space_count=space_count, space_count=space_count,
managers=_get_property_managers(db, p.id), managers=_get_property_managers(db, p.id),
slug=p.slug,
list_on_landing=p.list_on_landing,
)) ))
return result 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 spaces = db.query(Space).filter(Space.property_id == property_id, Space.is_active == True).all() # noqa: E712
space_count = len(spaces) space_count = len(spaces)
settings_row = db.query(PropertySettings).filter(PropertySettings.property_id == property_id).first()
return PropertyWithSpaces( return PropertyWithSpaces(
id=prop.id, id=prop.id,
name=prop.name, name=prop.name,
@@ -152,6 +158,9 @@ def get_property(
space_count=space_count, space_count=space_count,
managers=_get_property_managers(db, prop.id), managers=_get_property_managers(db, prop.id),
spaces=[SpaceResponse.model_validate(s) for s in spaces], 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, description=data.description,
address=data.address, address=data.address,
is_public=data.is_public, is_public=data.is_public,
list_on_landing=data.list_on_landing,
) )
db.add(prop) db.add(prop)
db.flush()
prop.slug = generate_unique_slug(db, data.name, exclude_id=prop.id)
db.commit() db.commit()
db.refresh(prop) db.refresh(prop)
@@ -219,6 +231,8 @@ def create_property(
created_at=prop.created_at, created_at=prop.created_at,
space_count=0, space_count=0,
managers=_get_property_managers(db, prop.id), 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 prop.address = data.address
if data.is_public is not None: if data.is_public is not None:
prop.is_public = data.is_public prop.is_public = data.is_public
if data.list_on_landing is not None:
prop.list_on_landing = data.list_on_landing
db.commit() 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) 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 space_count = db.query(Space).filter(Space.property_id == prop.id, Space.is_active == True).count() # noqa: E712
return PropertyResponse( return PropertyResponse(
id=prop.id, id=prop.id,
@@ -256,6 +297,8 @@ def update_property(
created_at=prop.created_at, created_at=prop.created_at,
space_count=space_count, space_count=space_count,
managers=_get_property_managers(db, prop.id), 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, created_at=prop.created_at,
space_count=space_count, space_count=space_count,
managers=_get_property_managers(db, prop.id), 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, created_at=p.created_at,
space_count=space_count, space_count=space_count,
managers=_get_property_managers(db, p.id), managers=_get_property_managers(db, p.id),
slug=p.slug,
list_on_landing=p.list_on_landing,
)) ))
return result return result

View File

@@ -1,27 +1,169 @@
"""Public/anonymous endpoints (no auth required).""" """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 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 import and_, or_
from sqlalchemy.orm import Query as SAQuery
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.deps import get_db from app.core.deps import get_db
from app.core.slug import DEMO_SLUG
from app.models.booking import Booking from app.models.booking import Booking
from app.models.property import Property from app.models.property import Property
from app.models.property_manager import PropertyManager from app.models.property_manager import PropertyManager
from app.models.property_settings import PropertySettings
from app.models.space import Space from app.models.space import Space
from app.models.user import User 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.property import PropertyResponse
from app.schemas.space import SpaceResponse 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.email_service import send_booking_notification
from app.services.notification_service import create_notification from app.services.notification_service import create_notification
router = APIRouter(prefix="/public", tags=["public"]) 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]) @router.get("/properties", response_model=list[PropertyResponse])
def list_public_properties( def list_public_properties(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
@@ -33,43 +175,68 @@ def list_public_properties(
.order_by(Property.name) .order_by(Property.name)
.all() .all()
) )
result = [] return [_property_to_response(db, p) for p in properties]
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
@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( def list_public_property_spaces(
property_id: int, slug_or_id: str,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> list[SpaceResponse]: ) -> list[SpaceResponse]:
"""List spaces of a public property (no auth required).""" """List spaces of a public property (no auth required)."""
prop = db.query(Property).filter(Property.id == property_id).first() prop = _resolve_property(db, slug_or_id)
if not prop: if not _is_visible(prop):
raise HTTPException(status_code=404, detail="Property not found") raise HTTPException(status_code=404, detail="Property not found")
if not prop.is_public:
raise HTTPException(status_code=403, detail="Property is private")
spaces = ( spaces = (
db.query(Space) 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) .order_by(Space.name)
.all() .all()
) )
return [SpaceResponse.model_validate(s) for s in spaces] 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) @router.get("/spaces/{space_id}/availability", response_model=AvailabilityCheck)
def check_public_availability( def check_public_availability(
space_id: int, space_id: int,
@@ -81,36 +248,13 @@ def check_public_availability(
space = db.query(Space).filter(Space.id == space_id).first() space = db.query(Space).filter(Space.id == space_id).first()
if not space: if not space:
raise HTTPException(status_code=404, detail="Space not found") raise HTTPException(status_code=404, detail="Space not found")
if not _resolve_visible_space_property(db, space):
# Verify space belongs to a public property raise HTTPException(status_code=404, detail="Space not found")
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")
# Find conflicting bookings # Find conflicting bookings
conflicts = ( conflicts = overlapping_bookings_query(
db.query(Booking) db, space_id, start_datetime, end_datetime, statuses=("approved", "pending")
.filter( ).all()
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()
)
if not conflicts: if not conflicts:
return AvailabilityCheck(available=True, conflicts=[], message="Time slot is available") return AvailabilityCheck(available=True, conflicts=[], message="Time slot is available")
@@ -126,10 +270,13 @@ def check_public_availability(
return AvailabilityCheck( return AvailabilityCheck(
available=approved_count == 0, available=approved_count == 0,
conflicts=[ 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( ConflictingBooking(
id=b.id, id=b.id,
user_name=b.user.full_name if b.user else (b.guest_name or "Anonymous"), user_name="",
title=b.title, title="",
status=b.status, status=b.status,
start_datetime=b.start_datetime, start_datetime=b.start_datetime,
end_datetime=b.end_datetime, end_datetime=b.end_datetime,
@@ -144,9 +291,12 @@ def check_public_availability(
def create_anonymous_booking( def create_anonymous_booking(
data: AnonymousBookingCreate, data: AnonymousBookingCreate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
request: Request,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> BookingResponse: ) -> BookingResponse:
"""Create an anonymous/guest booking (no auth required).""" """Create an anonymous/guest booking (no auth required)."""
_enforce_booking_rate_limit(request)
# Validate space exists # Validate space exists
space = db.query(Space).filter(Space.id == data.space_id).first() space = db.query(Space).filter(Space.id == data.space_id).first()
if not space: if not space:
@@ -164,18 +314,36 @@ def create_anonymous_booking(
if data.end_datetime <= data.start_datetime: if data.end_datetime <= data.start_datetime:
raise HTTPException(status_code=400, detail="End time must be after start time") raise HTTPException(status_code=400, detail="End time must be after start time")
# Check for overlapping approved bookings # Reject overlap with any pending OR approved booking (previously
overlapping = db.query(Booking).filter( # approved-only, which let guests double-book against pending requests).
Booking.space_id == data.space_id, overlapping = overlapping_bookings_query(
Booking.status == "approved", db, data.space_id, data.start_datetime, data.end_datetime, statuses=("approved", "pending")
and_(
Booking.start_datetime < data.end_datetime,
Booking.end_datetime > data.start_datetime,
),
).first() ).first()
if overlapping: if overlapping:
raise HTTPException(status_code=400, detail="Time slot is already booked") 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 # Create anonymous booking
booking = Booking( booking = Booking(
user_id=None, user_id=None,
@@ -195,8 +363,9 @@ def create_anonymous_booking(
db.commit() db.commit()
db.refresh(booking) db.refresh(booking)
# Notify property managers # Notify property managers/superadmins -- suppressed entirely for the
if space.property_id: # demo property so the public playground doesn't spam real staff.
if space.property_id and not is_demo:
manager_ids = [ manager_ids = [
pm.user_id pm.user_id
for pm in db.query(PropertyManager).filter(PropertyManager.property_id == space.property_id).all() 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}, {"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) return BookingResponse.model_validate(booking)

98
backend/app/core/slug.py Normal file
View 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

View File

@@ -1,16 +1,28 @@
"""Database session management.""" """Database session management."""
from collections.abc import Generator 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 sqlalchemy.orm import Session, declarative_base, sessionmaker
from app.core.config import settings from app.core.config import settings
_is_sqlite = "sqlite" in settings.database_url
engine = create_engine( engine = create_engine(
settings.database_url, 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) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()

View File

@@ -18,3 +18,5 @@ class Property(Base):
is_public = Column(Boolean, default=True, nullable=False) is_public = Column(Boolean, default=True, nullable=False)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) 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)

View File

@@ -1,8 +1,8 @@
"""Booking schemas for request/response.""" """Booking schemas for request/response."""
from datetime import datetime, date from datetime import date, datetime
from typing import Any, Optional 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): class BookingCalendarPublic(BaseModel):
@@ -276,5 +276,5 @@ class AnonymousBookingCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200) title: str = Field(..., min_length=1, max_length=200)
description: str | None = None description: str | None = None
guest_name: str = Field(..., min_length=1) guest_name: str = Field(..., min_length=1)
guest_email: str = Field(..., min_length=1) guest_email: EmailStr
guest_organization: str | None = None guest_organization: str | None = None

View File

@@ -9,6 +9,7 @@ class PropertyCreate(BaseModel):
description: str | None = None description: str | None = None
address: str | None = None address: str | None = None
is_public: bool = True is_public: bool = True
list_on_landing: bool = False
class PropertyUpdate(BaseModel): class PropertyUpdate(BaseModel):
@@ -16,6 +17,8 @@ class PropertyUpdate(BaseModel):
description: str | None = None description: str | None = None
address: str | None = None address: str | None = None
is_public: bool | None = None is_public: bool | None = None
slug: str | None = None
list_on_landing: bool | None = None
class PropertyManagerInfo(BaseModel): class PropertyManagerInfo(BaseModel):
@@ -34,6 +37,9 @@ class PropertyResponse(BaseModel):
created_at: datetime created_at: datetime
space_count: int = 0 space_count: int = 0
managers: list[PropertyManagerInfo] = [] managers: list[PropertyManagerInfo] = []
slug: str | None = None
list_on_landing: bool = False
require_approval: bool | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -153,11 +153,13 @@ Sistemul de Rezervări
""" """
elif event_type == "anonymous_approved": 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ă" subject = "Rezervare Aprobată"
body = f"""Bună ziua {user_name}, body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost aprobată: Rezervarea dumneavoastră a fost aprobată:
Cod de referință: #{reference_code}
Spațiu: {space_name} Spațiu: {space_name}
Data și ora: {start_str} - {end_str} Data și ora: {start_str} - {end_str}
Titlu: {booking.title} Titlu: {booking.title}
@@ -170,11 +172,13 @@ Sistemul de Rezervări
elif event_type == "anonymous_rejected": elif event_type == "anonymous_rejected":
reason = extra_data.get("rejection_reason", "Nu a fost specificat") if extra_data else "Nu a fost specificat" 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ă" subject = "Rezervare Respinsă"
body = f"""Bună ziua {user_name}, body = f"""Bună ziua {user_name},
Rezervarea dumneavoastră a fost respinsă: Rezervarea dumneavoastră a fost respinsă:
Cod de referință: #{reference_code}
Spațiu: {space_name} Spațiu: {space_name}
Data și ora: {start_str} - {end_str} Data și ora: {start_str} - {end_str}
Titlu: {booking.title} Titlu: {booking.title}

View File

@@ -1,6 +1,9 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "[entrypoint] Running property slug migration..."
python migrate_add_property_slug.py
# Database tables are created automatically on application startup # Database tables are created automatically on application startup
# (app/main.py runs Base.metadata.create_all). The first user to register # (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. # becomes the superadmin (the instance owner), so no admin seeding is needed.

View 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()

View File

@@ -15,6 +15,7 @@ from datetime import datetime, timedelta
from app.core.config import settings from app.core.config import settings
from app.core.security import get_password_hash 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.db.session import Base, SessionLocal, engine
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.booking import Booking from app.models.booking import Booking
@@ -126,8 +127,10 @@ def _seed_demo_data(db, demo: User) -> None:
name=DEMO_PROPERTY_NAME, name=DEMO_PROPERTY_NAME,
description="Mediu de test — datele se resetează automat la fiecare 3 ore.", description="Mediu de test — datele se resetează automat la fiecare 3 ore.",
address="Str. Exemplu nr. 1", address="Str. Exemplu nr. 1",
is_public=False, # visible only to the demo account is_public=True,
is_active=True, is_active=True,
list_on_landing=True,
slug=DEMO_SLUG,
) )
db.add(prop) db.add(prop)
db.flush() db.flush()

View 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

View 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

View File

@@ -14,10 +14,12 @@
"@fullcalendar/list": "^6.1.20", "@fullcalendar/list": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.0", "@fullcalendar/timegrid": "^6.1.0",
"@fullcalendar/vue3": "^6.1.0", "@fullcalendar/vue3": "^6.1.0",
"@types/qrcode": "^1.5.6",
"axios": "^1.6.0", "axios": "^1.6.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"lucide-vue-next": "^0.563.0", "lucide-vue-next": "^0.563.0",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"qrcode": "^1.5.4",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"
}, },
@@ -1090,6 +1092,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/semver": {
"version": "7.7.1", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
@@ -1570,7 +1590,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -1580,7 +1599,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -1686,6 +1704,15 @@
"node": ">=6" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1715,11 +1742,21 @@
"pnpm": ">=8" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -1732,7 +1769,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": { "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1829,6 +1874,12 @@
"node": ">=0.4.0" "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": { "node_modules/dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -1869,6 +1920,12 @@
"node": ">= 0.4" "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": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -2375,6 +2432,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2652,6 +2718,15 @@
"node": ">=0.10.0" "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": { "node_modules/is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2980,6 +3055,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3004,7 +3088,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "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": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -3159,6 +3251,23 @@
"node": ">=6" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3180,6 +3289,21 @@
], ],
"license": "MIT" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3300,6 +3424,12 @@
"node": ">=10" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3342,11 +3472,24 @@
"node": ">=0.10.0" "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": { "node_modules/strip-ansi": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -3454,6 +3597,12 @@
"node": ">=14.17" "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": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3658,6 +3807,12 @@
"node": ">= 8" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -3668,6 +3823,20 @@
"node": ">=0.10.0" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -3685,6 +3854,99 @@
"node": ">=12" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ const router = createRouter({
meta: { requiresAuth: false } meta: { requiresAuth: false }
}, },
{ {
path: '/book/:propertyId?', path: '/book/:slug?',
name: 'PublicBooking', name: 'PublicBooking',
component: () => import('@/views/PublicBooking.vue'), component: () => import('@/views/PublicBooking.vue'),
meta: { requiresAuth: false, isPublic: true } meta: { requiresAuth: false, isPublic: true }

View File

@@ -28,7 +28,8 @@ import type {
PropertyAccess, PropertyAccess,
Organization, Organization,
OrganizationMember, OrganizationMember,
AnonymousBookingCreate AnonymousBookingCreate,
BusyInterval
} from '@/types' } from '@/types'
const api = axios.create({ const api = axios.create({
@@ -435,7 +436,7 @@ export const propertiesApi = {
const response = await api.post<Property>('/manager/properties', data) const response = await api.post<Property>('/manager/properties', data)
return response.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) const response = await api.put<Property>(`/manager/properties/${id}`, data)
return response.data return response.data
}, },
@@ -513,8 +514,18 @@ export const publicApi = {
const response = await publicApiInstance.get<Property[]>('/public/properties') const response = await publicApiInstance.get<Property[]>('/public/properties')
return response.data return response.data
}, },
getPropertySpaces: async (propertyId: number): Promise<Space[]> => { getProperty: async (slugOrId: string | number): Promise<Property> => {
const response = await publicApiInstance.get<Space[]>(`/public/properties/${propertyId}/spaces`) 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 return response.data
}, },
getSpaceAvailability: async (spaceId: number, start: string, end: string) => { getSpaceAvailability: async (spaceId: number, start: string, end: string) => {

View File

@@ -253,6 +253,14 @@ export interface Property {
created_at: string created_at: string
space_count?: number space_count?: number
managers?: PropertyManagerInfo[] 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 { export interface PropertyWithSpaces extends Property {

View File

@@ -46,6 +46,7 @@
<div class="hero-cta"> <div class="hero-cta">
<router-link to="/register" class="btn btn-primary btn-lg">{{ ctaText }}</router-link> <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> <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> </div>
<p class="hero-note">Cont gratuit, gata în câteva secunde.</p> <p class="hero-note">Cont gratuit, gata în câteva secunde.</p>
</div> </div>
@@ -232,6 +233,39 @@
</div> </div>
</section> </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 --> <!-- CTA BAND -->
<section id="start" class="cta-band"> <section id="start" class="cta-band">
<div class="cta-inner"> <div class="cta-inner">
@@ -262,8 +296,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useLandingTheme } from '@/composables/useLandingTheme' import { useLandingTheme } from '@/composables/useLandingTheme'
import { publicApi } from '@/services/api'
import type { Property } from '@/types'
import { import {
LayoutDashboard, LayoutDashboard,
Building2, Building2,
@@ -350,6 +386,29 @@ const selectedLabel = computed(() => {
const s = slots[selected.value] const s = slots[selected.value]
return s.booked ? `Booked · ${s.time}` : `Selected · ${s.time}` 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> </script>
<style scoped> <style scoped>
@@ -473,6 +532,12 @@ const selectedLabel = computed(() => {
border-color: var(--border-default); border-color: var(--border-default);
} }
.btn-secondary:hover { border-color: var(--brand); color: var(--brand); } .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 ── */
.nav { .nav {
@@ -630,6 +695,116 @@ const selectedLabel = computed(() => {
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } @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 (subtle accent-tinted surface — accent stays on the button) ── */
.cta-band { .cta-band {
background: color-mix(in srgb, var(--accent) 7%, var(--card)); background: color-mix(in srgb, var(--accent) 7%, var(--card));

View File

@@ -55,6 +55,16 @@
<div class="property-footer"> <div class="property-footer">
<span class="space-count">{{ prop.space_count || 0 }} spaces</span> <span class="space-count">{{ prop.space_count || 0 }} spaces</span>
<div class="property-actions" @click.stop> <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 <button
class="btn-icon" class="btn-icon"
:title="prop.is_active ? 'Deactivate' : 'Activate'" :title="prop.is_active ? 'Deactivate' : 'Activate'"
@@ -136,7 +146,7 @@ import { useRouter } from 'vue-router'
import { propertiesApi, handleApiError } from '@/services/api' import { propertiesApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import Breadcrumb from '@/components/Breadcrumb.vue' 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' import type { Property } from '@/types'
const router = useRouter() const router = useRouter()
@@ -260,6 +270,37 @@ const goToProperty = (id: number) => {
router.push(`/properties/${id}`) 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(() => { onMounted(() => {
loadProperties() loadProperties()
}) })
@@ -491,6 +532,24 @@ onMounted(() => {
background: color-mix(in srgb, var(--color-danger) 8%, transparent); 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 */
.modal { .modal {
position: fixed; position: fixed;

View File

@@ -47,6 +47,65 @@
</div> </div>
</div> </div>
<!-- Public Link Card -->
<div class="public-link-card">
<template v-if="property.is_public">
<div class="public-link-header">
<h3>Link public de rezervare</h3>
</div>
<div class="public-link-row">
<code class="public-link-url">{{ publicBookingUrl }}</code>
<button class="btn btn-secondary btn-sm" @click="copyPublicLink">
{{ copied ? 'Copiat ' : 'Copiază' }}
</button>
<a :href="publicBookingUrl" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
Deschide pagina publică
</a>
</div>
<div class="slug-edit">
<template v-if="!editingSlug">
<button class="btn-link" @click="startEditSlug">Editează slug</button>
</template>
<template v-else>
<div class="slug-edit-row">
<input
v-model="slugInput"
type="text"
class="slug-input"
placeholder="slug-proprietate"
:disabled="savingSlug"
/>
<button class="btn btn-primary btn-sm" :disabled="savingSlug" @click="saveSlug">
{{ savingSlug ? 'Se salvează...' : 'Salvează' }}
</button>
<button class="btn btn-secondary btn-sm" :disabled="savingSlug" @click="cancelEditSlug">
Anulează
</button>
</div>
<div class="slug-hint">Doar litere mici, cifre și cratime, 364 caractere.</div>
<div v-if="slugError" class="error-inline">{{ slugError }}</div>
</template>
</div>
<div class="qr-section">
<canvas ref="qrCanvas" class="qr-canvas"></canvas>
<button class="btn btn-secondary btn-sm" :disabled="!qrReady" @click="downloadQr">
Descarcă QR (PNG)
</button>
</div>
</template>
<template v-else>
<div class="public-link-header">
<h3>Link public de rezervare</h3>
</div>
<p class="info-msg">Această proprietate este privată. -o publică pentru a obține un link de rezervare și un cod QR.</p>
<button class="btn btn-primary btn-sm" :disabled="makingPublic" @click="makePublic">
{{ makingPublic ? 'Se procesează...' : '-o publică' }}
</button>
</template>
</div>
<!-- Tabs --> <!-- Tabs -->
<div class="tabs"> <div class="tabs">
<button <button
@@ -226,6 +285,12 @@
Public Public
</label> </label>
</div> </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 v-if="editError" class="error">{{ editError }}</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="editSubmitting">Save</button> <button type="submit" class="btn btn-primary" :disabled="editSubmitting">Save</button>
@@ -275,7 +340,7 @@
</template> </template>
<script setup lang="ts"> <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 { useRoute } from 'vue-router'
import { propertiesApi, adminBookingsApi, spacesApi, usersApi, handleApiError } from '@/services/api' import { propertiesApi, adminBookingsApi, spacesApi, usersApi, handleApiError } from '@/services/api'
import Breadcrumb from '@/components/Breadcrumb.vue' import Breadcrumb from '@/components/Breadcrumb.vue'
@@ -298,7 +363,27 @@ const editSubmitting = ref(false)
const editError = ref('') const editError = ref('')
const toast = 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 // Create Space state
const showCreateSpaceModal = ref(false) const showCreateSpaceModal = ref(false)
@@ -340,9 +425,11 @@ const loadProperty = async () => {
name: property.value.name, name: property.value.name,
description: property.value.description || '', description: property.value.description || '',
address: property.value.address || '', 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 loadTabData()
await renderQr()
} catch (err) { } catch (err) {
error.value = handleApiError(err) error.value = handleApiError(err)
} finally { } finally {
@@ -482,6 +569,109 @@ const confirmDeleteSpace = async (sp: Space) => {
} }
} }
// Public link card
const copyPublicLink = async () => {
const url = publicBookingUrl.value
if (!url) return
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(url)
} else {
const textarea = document.createElement('textarea')
textarea.value = url
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
// ignore copy failures silently
}
}
const startEditSlug = () => {
slugInput.value = property.value?.slug || ''
slugError.value = ''
editingSlug.value = true
}
const cancelEditSlug = () => {
editingSlug.value = false
slugError.value = ''
}
const saveSlug = async () => {
if (!property.value) return
const newSlug = slugInput.value.trim()
if (newSlug === property.value.slug) {
editingSlug.value = false
return
}
if (!confirm('Linkurile și codurile QR cu slug-ul vechi nu vor mai funcționa. Continui?')) return
savingSlug.value = true
slugError.value = ''
try {
property.value = await propertiesApi.update(propertyId.value, { slug: newSlug })
editingSlug.value = false
showToast('Slug actualizat!')
await renderQr()
} catch (err: any) {
if (err?.response?.status === 409) {
slugError.value = 'Slug deja folosit'
} else if (err?.response?.status === 422) {
slugError.value = 'Format invalid: doar litere mici, cifre și cratime, 364 caractere'
} else {
slugError.value = handleApiError(err)
}
} finally {
savingSlug.value = false
}
}
const makePublic = async () => {
if (!property.value) return
makingPublic.value = true
try {
property.value = await propertiesApi.update(propertyId.value, { is_public: true })
showToast('Proprietate făcută publică!')
await renderQr()
} catch (err) {
error.value = handleApiError(err)
} finally {
makingPublic.value = false
}
}
const renderQr = async () => {
qrReady.value = false
if (!property.value?.is_public) return
await nextTick()
if (!qrCanvas.value) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(qrCanvas.value, publicBookingIdUrl.value, {
width: 512,
margin: 4
})
qrReady.value = true
} catch {
qrReady.value = false
}
}
const downloadQr = () => {
if (!qrCanvas.value || !qrReady.value) return
const link = document.createElement('a')
link.download = `qr-${property.value?.slug || property.value?.id}.png`
link.href = qrCanvas.value.toDataURL('image/png')
link.click()
}
// Date formatting // Date formatting
const formatDate = (dt: string) => { const formatDate = (dt: string) => {
if (!dt) return '' if (!dt) return ''
@@ -667,6 +857,107 @@ onMounted(() => loadProperty())
color: var(--color-text-muted); 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 */
.tabs { .tabs {
display: flex; display: flex;

View File

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