Compare commits

39 Commits

Author SHA1 Message Date
bisco fb7ced584b Merge branch 'fix/frontend-font-logo-consistency' into develop 2026-05-05 12:43:22 +02:00
bisco 621d3af1f1 fix: align frontend fonts and logo layout 2026-05-05 12:40:56 +02:00
bisco b68e0a7c5d Merge branch 'feature/frontend-typography-header' into develop 2026-05-05 12:31:27 +02:00
bisco 00d09ceb8b feat: refine frontend typography and branding 2026-05-05 12:28:19 +02:00
bisco d55d2b14ba changed logo 2026-05-05 12:21:22 +02:00
bisco 92e1bc1b7c Merge branch 'fix/nginx-frontend-assets' into develop 2026-05-05 11:55:08 +02:00
bisco 185003e001 fix: serve frontend assets without spa fallback 2026-05-05 11:53:21 +02:00
bisco 6c163fd800 feat: refine frontend italian copy 2026-05-05 11:09:01 +02:00
bisco 5db0a38441 Merge branch 'feature/frontend-branding-it' into develop 2026-05-05 11:05:32 +02:00
bisco 5abf019b1a fix: use provided azionelab logo asset 2026-05-05 11:02:59 +02:00
bisco fbbb1ae5fe feat: apply azionelab branding to frontend 2026-05-05 10:46:10 +02:00
bisco cf4e9ec239 Merge branch 'feature/frontend-visual-design' into develop 2026-05-05 10:27:54 +02:00
bisco 3957987b07 feat: refresh frontend visual design 2026-05-05 10:26:03 +02:00
bisco c3a345d60b Merge branch 'fix/qr-checkin-email-config' into develop 2026-05-04 10:11:38 +02:00
bisco 978fe1a7ff fix: tighten check-in token handling 2026-05-04 09:43:03 +02:00
bisco c7c0657f6d Merge branch 'fix/frontend-checkin-csrf' into develop 2026-04-30 13:51:16 +02:00
bisco b8d2dade40 fix: send csrf token for staff check-in 2026-04-30 13:40:09 +02:00
bisco d6d83fbb07 Merge branch 'fix/frontend-staff-session-auth' into develop 2026-04-30 13:25:33 +02:00
bisco f343512ec2 fix: send staff session credentials for check-in 2026-04-30 12:18:19 +02:00
bisco 769614278c Merge branch 'fix/site-base-url-runtime' into develop 2026-04-30 11:58:59 +02:00
bisco bc6f1c3c2e fix: pass site base url to backend container 2026-04-30 11:48:50 +02:00
bisco c725fdb912 Merge branch 'fix/public-booking-csrf' into develop 2026-04-30 11:34:31 +02:00
bisco cafa9226e3 fix: allow public reservation posts without csrf 2026-04-30 11:28:49 +02:00
bisco 9a378902d5 Merge branch 'fix/checkin-frontend-url' into develop 2026-04-30 11:01:18 +02:00
bisco 099b2f10ca fix: point check-in QR links to frontend page 2026-04-30 09:50:58 +02:00
bisco 9c9acd3e1d Merge branch 'fix/local-email-console-backend' into develop 2026-04-30 01:35:25 +02:00
bisco 833d4e629c fix: show local confirmation links before email send 2026-04-30 01:29:10 +02:00
bisco 240ea3aba3 Merge branch 'fix/dev-confirmation-log-order' into develop 2026-04-30 01:20:32 +02:00
bisco 8a47740049 fix: log confirmation link before email delivery 2026-04-30 01:18:04 +02:00
bisco ff9f9f2716 Merge branch 'feature/frontend-form-polish' into develop 2026-04-30 01:01:35 +02:00
bisco 05de8c75a2 feat: polish booking and confirmation UI 2026-04-30 00:59:43 +02:00
bisco 3dca43bc5c Merge branch 'fix/admin-reservation-labels' into develop 2026-04-30 00:54:13 +02:00
bisco 1629544b76 fix: simplify reservation admin labels 2026-04-30 00:47:36 +02:00
bisco 47449ce8dd Merge branch 'feature/show-image-upload' into develop 2026-04-30 00:08:27 +02:00
bisco ded07346a6 feat(shows): add uploaded show images 2026-04-30 00:05:23 +02:00
bisco b51ca9fdbf Merge branch 'feature/admin-manual-reservation-actions' into develop 2026-04-29 23:50:17 +02:00
bisco 7c90da5884 feat(admin): add manual reservation operations 2026-04-29 23:47:22 +02:00
bisco ffbe1a5b04 Merge branch 'fix/dev-email-confirmation-logging' into develop 2026-04-29 23:40:25 +02:00
bisco 1a5e9103f6 fix(dev): log confirmation links locally 2026-04-29 23:39:12 +02:00
43 changed files with 2319 additions and 438 deletions
+1
View File
@@ -32,3 +32,4 @@ ENVIRONMENT=local
# Local convention: nginx is the public entrypoint on http://localhost. # Local convention: nginx is the public entrypoint on http://localhost.
# If you change the published nginx port, update SITE_BASE_URL and trusted origins to match. # If you change the published nginx port, update SITE_BASE_URL and trusted origins to match.
# In local/debug mode, failed or attempted reservation emails also log the confirmation URL for manual browser testing.
+4
View File
@@ -9,6 +9,7 @@ from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true" DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
ENVIRONMENT = os.environ.get("ENVIRONMENT", "production").lower()
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
if not SECRET_KEY: if not SECRET_KEY:
if DEBUG: if DEBUG:
@@ -25,6 +26,7 @@ ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS") CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS")
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS") CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/") SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/")
LOG_RESERVATION_CONFIRMATION_URLS = DEBUG or ENVIRONMENT == "local"
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
@@ -114,6 +116,8 @@ if "test" in sys.argv:
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = { REST_FRAMEWORK = {
+5
View File
@@ -1,4 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path from django.urls import include, path
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
@@ -16,3 +18,6 @@ urlpatterns = [
path("api/", include("bookings.urls")), path("api/", include("bookings.urls")),
path("api/", include("checkins.urls")), path("api/", include("checkins.urls")),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+218 -1
View File
@@ -1,8 +1,23 @@
from django import forms from django import forms
from django.contrib import admin, messages from django.contrib import admin, messages
from django.http import HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html
from .models import Reservation, ReservationToken from .models import Reservation, ReservationToken
from .services import PerformanceNotAvailable, create_pending_reservation from .services import (
AlreadyConfirmedReservation,
NotEnoughSeats,
PerformanceNotAvailable,
ReservationNotConfirmed,
ReservationNotPending,
confirm_reservation_manually,
create_pending_reservation,
issue_check_in_access_for_reservation,
)
from checkins.models import CheckIn
from checkins.services import AlreadyCheckedIn, confirm_check_in_for_reservation
class ReservationAdminForm(forms.ModelForm): class ReservationAdminForm(forms.ModelForm):
@@ -83,6 +98,8 @@ class ReservationAdmin(admin.ModelAdmin):
"venue_name", "venue_name",
"confirmation_state_display", "confirmation_state_display",
"check_in_state_display", "check_in_state_display",
"check_in_access_display",
"operational_tools",
"created_at", "created_at",
"updated_at", "updated_at",
"confirmed_at", "confirmed_at",
@@ -90,6 +107,27 @@ class ReservationAdmin(admin.ModelAdmin):
) )
autocomplete_fields = ("performance",) autocomplete_fields = ("performance",)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"<path:object_id>/manual-confirm/",
self.admin_site.admin_view(self.manual_confirm_view),
name="bookings_reservation_manual_confirm",
),
path(
"<path:object_id>/check-in-pass/",
self.admin_site.admin_view(self.check_in_pass_view),
name="bookings_reservation_check_in_pass",
),
path(
"<path:object_id>/manual-check-in/",
self.admin_site.admin_view(self.manual_check_in_view),
name="bookings_reservation_manual_check_in",
),
]
return custom_urls + urls
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).select_related( return super().get_queryset(request).select_related(
"performance", "performance",
@@ -149,6 +187,8 @@ class ReservationAdmin(admin.ModelAdmin):
"status", "status",
"confirmation_state_display", "confirmation_state_display",
"check_in_state_display", "check_in_state_display",
"check_in_access_display",
"operational_tools",
"confirmed_at", "confirmed_at",
"qr_code_generated_at", "qr_code_generated_at",
), ),
@@ -228,6 +268,183 @@ class ReservationAdmin(admin.ModelAdmin):
def check_in_state_display(self, obj): def check_in_state_display(self, obj):
return "Checked in" if hasattr(obj, "check_in") else "Not checked in" return "Checked in" if hasattr(obj, "check_in") else "Not checked in"
@admin.display(description="Check-in access")
def check_in_access_display(self, obj):
if obj.status != Reservation.Status.CONFIRMED:
return "Available after confirmation"
url = reverse("admin:bookings_reservation_check_in_pass", args=[obj.pk])
return format_html('<a href="{}">Generate operational QR / URL</a>', url)
@admin.display(description="Operational tools")
def operational_tools(self, obj):
links = []
if obj.status == Reservation.Status.PENDING:
confirm_url = reverse("admin:bookings_reservation_manual_confirm", args=[obj.pk])
links.append(f'<a href="{confirm_url}">Confirm reservation now</a>')
if obj.status == Reservation.Status.CONFIRMED and not hasattr(obj, "check_in"):
check_in_url = reverse("admin:bookings_reservation_manual_check_in", args=[obj.pk])
links.append(f'<a href="{check_in_url}">Mark as checked in</a>')
if obj.status == Reservation.Status.CONFIRMED:
pass_url = reverse("admin:bookings_reservation_check_in_pass", args=[obj.pk])
links.append(f'<a href="{pass_url}">Show QR / check-in URL</a>')
if not links:
return "-"
return format_html(" | ".join(links))
def manual_confirm_view(self, request, object_id):
reservation = self.get_object(request, object_id)
if reservation is None:
return self._redirect_to_changelist()
if request.method == "POST":
try:
result = confirm_reservation_manually(reservation_id=reservation.pk)
except AlreadyConfirmedReservation:
self.message_user(request, "Reservation is already confirmed.", level=messages.WARNING)
return HttpResponseRedirect(self._change_url(reservation.pk))
except ReservationNotPending as exc:
self.message_user(request, str(exc), level=messages.ERROR)
return HttpResponseRedirect(self._change_url(reservation.pk))
except NotEnoughSeats as exc:
self.message_user(request, str(exc), level=messages.ERROR)
return HttpResponseRedirect(self._change_url(reservation.pk))
self.message_user(
request,
"Reservation confirmed manually. A check-in token was generated for operations.",
level=messages.SUCCESS,
)
return self._render_operation_page(
request,
reservation=result.reservation,
title="Reservation confirmed manually",
heading="Reservation confirmed manually",
description=(
"Use this operational QR code or check-in URL only when the guest cannot complete the normal email flow."
),
qr_code_image=result.qr_code_image,
qr_code_url=result.qr_code_url,
)
return self._render_operation_page(
request,
reservation=reservation,
title="Confirm reservation manually",
heading="Confirm reservation manually",
description=(
"This bypasses guest email confirmation, but still rechecks booking availability and generates a check-in token."
),
submit_label="Confirm reservation",
)
def check_in_pass_view(self, request, object_id):
reservation = self.get_object(request, object_id)
if reservation is None:
return self._redirect_to_changelist()
if request.method == "POST":
try:
result = issue_check_in_access_for_reservation(reservation_id=reservation.pk)
except ReservationNotConfirmed as exc:
self.message_user(request, str(exc), level=messages.ERROR)
return HttpResponseRedirect(self._change_url(reservation.pk))
self.message_user(
request,
"Operational check-in access generated for this reservation.",
level=messages.SUCCESS,
)
return self._render_operation_page(
request,
reservation=result.reservation,
title="Operational check-in access",
heading="Operational check-in access",
description=(
"This page shows a one-time operational QR code and check-in URL for manual testing or guest support."
),
qr_code_image=result.qr_code_image,
qr_code_url=result.qr_code_url,
)
return self._render_operation_page(
request,
reservation=reservation,
title="Generate operational check-in access",
heading="Generate operational check-in access",
description=(
"Generate a fresh QR code and check-in URL without exposing token hashes in normal admin screens."
),
submit_label="Generate QR / URL",
)
def manual_check_in_view(self, request, object_id):
reservation = self.get_object(request, object_id)
if reservation is None:
return self._redirect_to_changelist()
if request.method == "POST":
try:
confirm_check_in_for_reservation(
reservation_id=reservation.pk,
staff_user=request.user,
source=CheckIn.Source.MANUAL,
)
except ReservationNotConfirmed as exc:
self.message_user(request, str(exc), level=messages.ERROR)
return HttpResponseRedirect(self._change_url(reservation.pk))
except AlreadyCheckedIn:
self.message_user(request, "Reservation has already been checked in.", level=messages.WARNING)
return HttpResponseRedirect(self._change_url(reservation.pk))
self.message_user(request, "Reservation marked as checked in.", level=messages.SUCCESS)
return HttpResponseRedirect(self._change_url(reservation.pk))
return self._render_operation_page(
request,
reservation=reservation,
title="Mark reservation as checked in",
heading="Mark reservation as checked in",
description="Use this only for staff-side emergency or desk check-in workflows.",
submit_label="Confirm check-in",
)
def _change_url(self, reservation_id):
return reverse("admin:bookings_reservation_change", args=[reservation_id])
def _redirect_to_changelist(self):
return HttpResponseRedirect(reverse("admin:bookings_reservation_changelist"))
def _render_operation_page(
self,
request,
*,
reservation,
title,
heading,
description,
submit_label=None,
qr_code_image=None,
qr_code_url=None,
):
context = {
**self.admin_site.each_context(request),
"opts": self.model._meta,
"original": reservation,
"title": title,
"heading": heading,
"description": description,
"submit_label": submit_label,
"qr_code_image": qr_code_image,
"qr_code_url": qr_code_url,
"change_url": self._change_url(reservation.pk),
}
return TemplateResponse(
request,
"admin/bookings/reservation/operation.html",
context,
)
@admin.register(ReservationToken) @admin.register(ReservationToken)
class ReservationTokenAdmin(admin.ModelAdmin): class ReservationTokenAdmin(admin.ModelAdmin):
+21
View File
@@ -13,6 +13,16 @@ def build_confirmation_link(raw_confirmation_token):
return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}" return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}"
def _log_confirmation_link_for_local_debug(*, confirmation_link):
if not settings.LOG_RESERVATION_CONFIRMATION_URLS:
return
logger.warning(
"LOCAL DEV confirmation URL: %s",
confirmation_link,
)
def send_confirmation_email(*, reservation, raw_confirmation_token): def send_confirmation_email(*, reservation, raw_confirmation_token):
confirmation_link = build_confirmation_link(raw_confirmation_token) confirmation_link = build_confirmation_link(raw_confirmation_token)
subject = f"Confirm your reservation for {reservation.performance.show.title}" subject = f"Confirm your reservation for {reservation.performance.show.title}"
@@ -23,6 +33,10 @@ def send_confirmation_email(*, reservation, raw_confirmation_token):
"If you did not request this reservation, you can ignore this email." "If you did not request this reservation, you can ignore this email."
) )
_log_confirmation_link_for_local_debug(
confirmation_link=confirmation_link,
)
try: try:
send_mail( send_mail(
subject=subject, subject=subject,
@@ -32,6 +46,13 @@ def send_confirmation_email(*, reservation, raw_confirmation_token):
fail_silently=False, fail_silently=False,
) )
except Exception: except Exception:
if settings.LOG_RESERVATION_CONFIRMATION_URLS:
logger.warning(
"Local/debug email delivery failed for reservation %s.",
reservation.id,
)
return
logger.exception( logger.exception(
"Failed to send confirmation email for reservation %s.", "Failed to send confirmation email for reservation %s.",
reservation.id, reservation.id,
+3 -1
View File
@@ -44,7 +44,9 @@ class Reservation(TimeStampedModel):
] ]
def __str__(self): def __str__(self):
return f"{self.name} ({self.party_size}) for {self.performance}" if self.party_size > 1:
return f"{self.name} ({self.party_size})"
return self.name
@property @property
def is_confirmed(self): def is_confirmed(self):
+2 -2
View File
@@ -7,11 +7,11 @@ from django.conf import settings
from .models import Reservation from .models import Reservation
CHECK_IN_PREVIEW_PATH = "/api/check-ins/preview/" CHECK_IN_PAGE_PATH = "/check-in"
def build_check_in_preview_url(raw_check_in_token): def build_check_in_preview_url(raw_check_in_token):
return f"{settings.SITE_BASE_URL}{CHECK_IN_PREVIEW_PATH}?token={raw_check_in_token}" return f"{settings.SITE_BASE_URL}{CHECK_IN_PAGE_PATH}?token={raw_check_in_token}"
def generate_check_in_qr_png(raw_check_in_token): def generate_check_in_qr_png(raw_check_in_token):
+97 -23
View File
@@ -44,6 +44,10 @@ class ReservationNotConfirmed(BookingServiceError):
pass pass
class ReservationNotPending(BookingServiceError):
pass
@dataclass(frozen=True) @dataclass(frozen=True)
class PendingReservationResult: class PendingReservationResult:
reservation: Reservation reservation: Reservation
@@ -55,7 +59,7 @@ class PendingReservationResult:
@dataclass(frozen=True) @dataclass(frozen=True)
class ConfirmedReservationResult: class ConfirmedReservationResult:
reservation: Reservation reservation: Reservation
confirmation_token: ReservationToken confirmation_token: ReservationToken | None
check_in_token: ReservationToken check_in_token: ReservationToken
raw_check_in_token: str raw_check_in_token: str
available_seats: int available_seats: int
@@ -70,6 +74,15 @@ class ReservationQRResult:
qr_code_url: str qr_code_url: str
@dataclass(frozen=True)
class ReservationCheckInAccessResult:
reservation: Reservation
check_in_token: ReservationToken
raw_check_in_token: str
qr_code_image: str
qr_code_url: str
def calculate_available_seats(performance): def calculate_available_seats(performance):
confirmed_seats = ( confirmed_seats = (
Reservation.objects.filter( Reservation.objects.filter(
@@ -171,39 +184,55 @@ def confirm_reservation_from_token(raw_token):
raise InvalidToken("Confirmation token is not valid for this reservation.") raise InvalidToken("Confirmation token is not valid for this reservation.")
if not token_was_expired: if not token_was_expired:
performance = _get_locked_bookable_performance(reservation.performance_id) result = _confirm_pending_reservation(
available_seats = calculate_available_seats(performance)
if reservation.party_size > available_seats:
raise NotEnoughSeats("Not enough seats are available for this performance.")
reservation.confirm()
confirmation_token.mark_used()
check_in_token, raw_check_in_token = ReservationToken.create_token(
reservation=reservation, reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN, confirmation_token=confirmation_token,
expires_at=performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
) )
reservation.qr_code_generated_at = timezone.now()
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
if token_was_expired: if token_was_expired:
raise ExpiredToken("Confirmation token has expired.") raise ExpiredToken("Confirmation token has expired.")
return ConfirmedReservationResult( return result
def confirm_reservation_manually(*, reservation_id):
with transaction.atomic():
reservation = Reservation.objects.select_for_update().get(pk=reservation_id)
if reservation.status == Reservation.Status.CONFIRMED:
raise AlreadyConfirmedReservation("Reservation is already confirmed.")
if reservation.status != Reservation.Status.PENDING:
raise ReservationNotPending("Only pending reservations can be confirmed manually.")
confirmation_token = (
ReservationToken.objects.select_for_update()
.filter(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
)
.order_by("-created_at")
.first()
)
return _confirm_pending_reservation(
reservation=reservation, reservation=reservation,
confirmation_token=confirmation_token, confirmation_token=confirmation_token,
check_in_token=check_in_token,
raw_check_in_token=raw_check_in_token,
available_seats=available_seats - reservation.party_size,
qr_code_image=generate_check_in_qr_base64(
reservation=reservation,
raw_check_in_token=raw_check_in_token,
),
qr_code_url=build_check_in_preview_url(raw_check_in_token),
) )
def issue_check_in_access_for_reservation(*, reservation_id):
with transaction.atomic():
reservation = (
Reservation.objects.select_for_update()
.select_related("performance__show", "performance__venue")
.get(pk=reservation_id)
)
if reservation.status != Reservation.Status.CONFIRMED:
raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.")
result = _issue_check_in_access(reservation)
return result
def retrieve_reservation_qr_from_token(raw_token): def retrieve_reservation_qr_from_token(raw_token):
try: try:
check_in_token = ReservationToken.objects.select_related("reservation").get_valid_token( check_in_token = ReservationToken.objects.select_related("reservation").get_valid_token(
@@ -237,3 +266,48 @@ def _get_locked_bookable_performance(performance_id):
raise PerformanceNotAvailable("Performance is not available for booking.") raise PerformanceNotAvailable("Performance is not available for booking.")
return performance return performance
def _confirm_pending_reservation(*, reservation, confirmation_token=None):
performance = _get_locked_bookable_performance(reservation.performance_id)
available_seats = calculate_available_seats(performance)
if reservation.party_size > available_seats:
raise NotEnoughSeats("Not enough seats are available for this performance.")
reservation.confirm()
if confirmation_token is not None and not confirmation_token.is_used:
confirmation_token.mark_used()
check_in_access = _issue_check_in_access(reservation)
return ConfirmedReservationResult(
reservation=reservation,
confirmation_token=confirmation_token,
check_in_token=check_in_access.check_in_token,
raw_check_in_token=check_in_access.raw_check_in_token,
available_seats=available_seats - reservation.party_size,
qr_code_image=check_in_access.qr_code_image,
qr_code_url=check_in_access.qr_code_url,
)
def _issue_check_in_access(reservation):
check_in_token, raw_check_in_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN,
expires_at=reservation.performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
)
if reservation.qr_code_generated_at is None:
reservation.qr_code_generated_at = timezone.now()
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
return ReservationCheckInAccessResult(
reservation=reservation,
check_in_token=check_in_token,
raw_check_in_token=raw_check_in_token,
qr_code_image=generate_check_in_qr_base64(
reservation=reservation,
raw_check_in_token=raw_check_in_token,
),
qr_code_url=build_check_in_preview_url(raw_check_in_token),
)
@@ -0,0 +1,35 @@
{% extends "admin/base_site.html" %}
{% block content %}
<div id="content-main">
<h1>{{ heading }}</h1>
<p>{{ description }}</p>
<p>
<strong>Reservation:</strong> {{ original.name }}<br>
<strong>Show:</strong> {{ original.performance.show.title }}<br>
<strong>Performance:</strong> {{ original.performance.starts_at }}<br>
<strong>Party size:</strong> {{ original.party_size }}
</p>
{% if qr_code_url %}
<p>
<strong>Check-in URL:</strong><br>
<a href="{{ qr_code_url }}">{{ qr_code_url }}</a>
</p>
{% endif %}
{% if qr_code_image %}
<p><img src="{{ qr_code_image }}" alt="Operational QR code"></p>
{% endif %}
{% if submit_label %}
<form method="post">
{% csrf_token %}
<input type="submit" value="{{ submit_label }}" class="default">
</form>
{% endif %}
<p><a href="{{ change_url }}">Back to reservation</a></p>
</div>
{% endblock %}
+68
View File
@@ -8,6 +8,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from bookings.models import Reservation, ReservationToken from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn
from shows.models import Performance, Show, Venue from shows.models import Performance, Show, Venue
@@ -109,3 +110,70 @@ class ReservationAdminTests(TestCase):
self.assertContains(change_response, token.get_purpose_display()) self.assertContains(change_response, token.get_purpose_display())
self.assertContains(change_response, "Expires at") self.assertContains(change_response, "Expires at")
self.assertContains(change_response, "Used at") self.assertContains(change_response, "Used at")
def test_admin_can_confirm_pending_reservation_manually_and_get_qr_access(self):
reservation = Reservation.objects.create(
performance=self.performance,
name="Maria Rossi",
email="maria@example.com",
party_size=2,
)
confirmation_token, _ = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
response = self.client.post(
reverse("admin:bookings_reservation_manual_confirm", args=[reservation.id]),
)
reservation.refresh_from_db()
confirmation_token.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
self.assertIsNotNone(confirmation_token.used_at)
self.assertContains(response, "Reservation confirmed manually")
self.assertContains(response, "/check-in?token=")
self.assertTrue(
ReservationToken.objects.filter(
reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN,
).exists()
)
def test_admin_can_mark_confirmed_reservation_as_checked_in(self):
reservation = Reservation.objects.create(
performance=self.performance,
name="Checked Guest",
email="checked@example.com",
party_size=1,
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
response = self.client.post(
reverse("admin:bookings_reservation_manual_check_in", args=[reservation.id]),
)
self.assertEqual(response.status_code, 302)
check_in = CheckIn.objects.get(reservation=reservation)
self.assertEqual(check_in.checked_in_by, self.admin_user)
self.assertEqual(check_in.source, CheckIn.Source.MANUAL)
def test_reservation_string_is_concise_without_performance_details(self):
single_guest_reservation = Reservation.objects.create(
performance=self.performance,
name="Maria Rossi",
email="maria@example.com",
party_size=1,
)
group_reservation = Reservation.objects.create(
performance=self.performance,
name="Luca Bianchi",
email="luca@example.com",
party_size=3,
)
self.assertEqual(str(single_guest_reservation), "Maria Rossi")
self.assertEqual(str(group_reservation), "Luca Bianchi (3)")
+48 -3
View File
@@ -1,12 +1,14 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core import mail from django.core import mail
from django.urls import reverse from django.urls import reverse
from django.test.utils import override_settings from django.test.utils import override_settings
from django.utils import timezone from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APIClient, APITestCase
from bookings.models import Reservation from bookings.models import Reservation
from bookings.services import generate_confirmation_token from bookings.services import generate_confirmation_token
@@ -16,6 +18,7 @@ from shows.models import Performance, Show, Venue
class BookingApiTests(APITestCase): class BookingApiTests(APITestCase):
def setUp(self): def setUp(self):
cache.clear()
self.show = Show.objects.create( self.show = Show.objects.create(
title="Open Stage", title="Open Stage",
slug="open-stage-api", slug="open-stage-api",
@@ -90,6 +93,48 @@ class BookingApiTests(APITestCase):
mail.outbox[0].body, mail.outbox[0].body,
) )
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_reservation_creation_allows_anonymous_post_without_csrf(self):
csrf_client = APIClient(enforce_csrf_checks=True)
with self.captureOnCommitCallbacks(execute=True):
response = csrf_client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 2,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["status"], Reservation.Status.PENDING)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_reservation_creation_ignores_session_csrf_for_public_endpoint(self):
csrf_client = APIClient(enforce_csrf_checks=True)
user = get_user_model().objects.create_user(
username="box-office",
email="staff@example.com",
password="test-pass-123",
)
csrf_client.force_login(user)
with self.captureOnCommitCallbacks(execute=True):
response = csrf_client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 2,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["status"], Reservation.Status.PENDING)
def test_reservation_creation_schedules_email_after_commit(self): def test_reservation_creation_schedules_email_after_commit(self):
with self.captureOnCommitCallbacks(execute=False) as callbacks: with self.captureOnCommitCallbacks(execute=False) as callbacks:
response = self.client.post( response = self.client.post(
@@ -165,7 +210,7 @@ class BookingApiTests(APITestCase):
self.assertEqual(response.data["party_size"], reservation.party_size) self.assertEqual(response.data["party_size"], reservation.party_size)
self.assertTrue( self.assertTrue(
response.data["qr_code_url"].startswith( response.data["qr_code_url"].startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token=" "https://tickets.azionelab.example/check-in?token="
) )
) )
self.assertNotIn(raw_token, response.data["qr_code_url"]) self.assertNotIn(raw_token, response.data["qr_code_url"])
@@ -257,7 +302,7 @@ class BookingApiTests(APITestCase):
self.assertEqual(response.data["reservation_id"], reservation.id) self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertTrue( self.assertTrue(
response.data["qr_code_url"].startswith( response.data["qr_code_url"].startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token=" "https://tickets.azionelab.example/check-in?token="
) )
) )
self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,")) self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
+146 -2
View File
@@ -15,10 +15,13 @@ from bookings.services import (
ExpiredToken, ExpiredToken,
InvalidToken, InvalidToken,
NotEnoughSeats, NotEnoughSeats,
ReservationNotConfirmed,
calculate_available_seats, calculate_available_seats,
confirm_reservation_manually,
confirm_reservation_from_token, confirm_reservation_from_token,
create_pending_reservation, create_pending_reservation,
generate_confirmation_token, generate_confirmation_token,
issue_check_in_access_for_reservation,
retrieve_reservation_qr_from_token, retrieve_reservation_qr_from_token,
) )
from shows.models import Performance, Show, Venue from shows.models import Performance, Show, Venue
@@ -96,6 +99,31 @@ class BookingServiceTests(TestCase):
self.assertEqual(len(callbacks), 1) self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 0) self.assertEqual(len(mail.outbox), 0)
@override_settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
LOG_RESERVATION_CONFIRMATION_URLS=True,
SITE_BASE_URL="https://tickets.azionelab.example",
)
def test_create_pending_reservation_logs_confirmation_link_in_local_mode(self):
with self.assertLogs("bookings.emailing", level="WARNING") as captured_logs:
with self.captureOnCommitCallbacks(execute=True):
result = create_pending_reservation(
performance_id=self.performance.id,
name="Maria Rossi",
email="maria@example.com",
party_size=1,
)
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
self.assertTrue(
any(
"LOCAL DEV confirmation URL:" in log_entry
and result.raw_confirmation_token in log_entry
for log_entry in captured_logs.output
)
)
@override_settings(LOG_RESERVATION_CONFIRMATION_URLS=False)
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down")) @patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail): def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail):
with self.assertLogs("bookings.emailing", level="ERROR") as captured_logs: with self.assertLogs("bookings.emailing", level="ERROR") as captured_logs:
@@ -116,6 +144,74 @@ class BookingServiceTests(TestCase):
for log_entry in captured_logs.output for log_entry in captured_logs.output
) )
) )
self.assertFalse(
any(result.raw_confirmation_token in log_entry for log_entry in captured_logs.output)
)
@override_settings(
LOG_RESERVATION_CONFIRMATION_URLS=True,
SITE_BASE_URL="https://tickets.azionelab.example",
)
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
def test_create_pending_reservation_logs_confirmation_link_before_email_failure_in_local_mode(
self,
mocked_send_mail,
):
with self.assertLogs("bookings.emailing", level="WARNING") as captured_logs:
with self.captureOnCommitCallbacks(execute=True):
result = create_pending_reservation(
performance_id=self.performance.id,
name="Maria Rossi",
email="maria@example.com",
party_size=1,
)
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
mocked_send_mail.assert_called_once()
confirmation_log_index = next(
index
for index, log_entry in enumerate(captured_logs.output)
if "LOCAL DEV confirmation URL:" in log_entry
and result.raw_confirmation_token in log_entry
)
failure_log_index = next(
index
for index, log_entry in enumerate(captured_logs.output)
if "Local/debug email delivery failed for reservation" in log_entry
)
self.assertLess(confirmation_log_index, failure_log_index)
self.assertEqual(
sum(
result.raw_confirmation_token in log_entry
for log_entry in captured_logs.output
),
1,
)
self.assertFalse(
any("Traceback" in log_entry for log_entry in captured_logs.output)
)
@override_settings(
LOG_RESERVATION_CONFIRMATION_URLS=False,
SITE_BASE_URL="https://tickets.azionelab.example",
)
@patch("bookings.emailing.send_mail")
def test_create_pending_reservation_does_not_log_confirmation_link_outside_local_mode(
self,
mocked_send_mail,
):
with self.assertNoLogs("bookings.emailing", level="WARNING"):
with self.captureOnCommitCallbacks(execute=True):
result = create_pending_reservation(
performance_id=self.performance.id,
name="Maria Rossi",
email="maria@example.com",
party_size=1,
)
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
mocked_send_mail.assert_called_once()
def test_generate_confirmation_token_returns_raw_token_once(self): def test_generate_confirmation_token_returns_raw_token_once(self):
reservation = self.create_reservation() reservation = self.create_reservation()
@@ -151,7 +247,7 @@ class BookingServiceTests(TestCase):
) )
self.assertTrue( self.assertTrue(
result.qr_code_url.startswith( result.qr_code_url.startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token=" "https://tickets.azionelab.example/check-in?token="
) )
) )
self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,")) self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,"))
@@ -186,7 +282,7 @@ class BookingServiceTests(TestCase):
self.assertGreater(len(qr_code_image), len("data:image/png;base64,")) self.assertGreater(len(qr_code_image), len("data:image/png;base64,"))
self.assertEqual( self.assertEqual(
build_check_in_preview_url(raw_check_in_token), build_check_in_preview_url(raw_check_in_token),
"https://tickets.azionelab.example/api/check-ins/preview/?token=opaque-check-in-token", "https://tickets.azionelab.example/check-in?token=opaque-check-in-token",
) )
def test_qr_code_is_not_generated_for_pending_reservation(self): def test_qr_code_is_not_generated_for_pending_reservation(self):
@@ -320,6 +416,54 @@ class BookingServiceTests(TestCase):
self.assertTrue(any("FOR UPDATE" in query["sql"] for query in queries)) self.assertTrue(any("FOR UPDATE" in query["sql"] for query in queries))
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_manual_confirmation_reuses_capacity_rules_and_generates_check_in_access(self):
reservation = self.create_reservation()
confirmation_token, _ = generate_confirmation_token(reservation)
result = confirm_reservation_manually(reservation_id=reservation.id)
reservation.refresh_from_db()
confirmation_token.refresh_from_db()
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
self.assertIsNotNone(confirmation_token.used_at)
self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN)
self.assertTrue(
result.qr_code_url.startswith(
"https://tickets.azionelab.example/check-in?token="
)
)
def test_manual_confirmation_rejects_non_pending_reservation(self):
reservation = self.create_reservation(
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
with self.assertRaises(AlreadyConfirmedReservation):
confirm_reservation_manually(reservation_id=reservation.id)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_issue_check_in_access_requires_confirmed_reservation(self):
reservation = self.create_reservation()
with self.assertRaises(ReservationNotConfirmed):
issue_check_in_access_for_reservation(reservation_id=reservation.id)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_issue_check_in_access_generates_qr_for_confirmed_reservation(self):
reservation = self.create_reservation(
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
result = issue_check_in_access_for_reservation(reservation_id=reservation.id)
self.assertEqual(result.reservation, reservation)
self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN)
self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,"))
self.assertIn("/check-in?token=", result.qr_code_url)
def create_reservation(self, **overrides): def create_reservation(self, **overrides):
data = { data = {
"performance": self.performance, "performance": self.performance,
+4 -1
View File
@@ -1,6 +1,7 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, throttle_classes from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle from rest_framework.throttling import AnonRateThrottle
@@ -35,6 +36,8 @@ class ReservationConfirmThrottle(AnonRateThrottle):
@api_view(["POST"]) @api_view(["POST"])
@authentication_classes([])
@permission_classes([AllowAny])
@throttle_classes([ReservationCreateThrottle]) @throttle_classes([ReservationCreateThrottle])
def create_reservation(request, performance_id): def create_reservation(request, performance_id):
get_object_or_404(Performance, pk=performance_id, show__is_published=True) get_object_or_404(Performance, pk=performance_id, show__is_published=True)
+5 -1
View File
@@ -6,7 +6,7 @@ from .models import CheckIn
@admin.register(CheckIn) @admin.register(CheckIn)
class CheckInAdmin(admin.ModelAdmin): class CheckInAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"reservation", "reservation_guest_name",
"performance", "performance",
"checked_in_at", "checked_in_at",
"checked_in_by", "checked_in_by",
@@ -25,6 +25,10 @@ class CheckInAdmin(admin.ModelAdmin):
list_select_related = ("reservation", "reservation__performance", "checked_in_by") list_select_related = ("reservation", "reservation__performance", "checked_in_by")
autocomplete_fields = ("reservation", "checked_in_by") autocomplete_fields = ("reservation", "checked_in_by")
@admin.display(description="Reservation", ordering="reservation__name")
def reservation_guest_name(self, obj):
return obj.reservation.name
@admin.display(description="Performance") @admin.display(description="Performance")
def performance(self, obj): def performance(self, obj):
return obj.reservation.performance return obj.reservation.performance
+23
View File
@@ -75,6 +75,29 @@ def confirm_check_in_from_token(raw_token, *, staff_user, source=CheckIn.Source.
return CheckInResult(check_in=check_in, preview=_build_preview(reservation)) return CheckInResult(check_in=check_in, preview=_build_preview(reservation))
def confirm_check_in_for_reservation(*, reservation_id, staff_user, source=CheckIn.Source.MANUAL):
_validate_staff_user(staff_user)
with transaction.atomic():
reservation = (
Reservation.objects.select_for_update()
.select_related("performance__show", "performance__venue")
.get(pk=reservation_id)
)
_validate_reservation_for_check_in(reservation)
try:
check_in = CheckIn.objects.create(
reservation=reservation,
checked_in_by=staff_user,
source=source,
)
except IntegrityError as exc:
raise AlreadyCheckedIn("Reservation has already been checked in.") from exc
return CheckInResult(check_in=check_in, preview=_build_preview(reservation))
def _validate_staff_user(staff_user): def _validate_staff_user(staff_user):
if staff_user is None: if staff_user is None:
raise MissingStaffUser("A staff user is required for check-in.") raise MissingStaffUser("A staff user is required for check-in.")
+56
View File
@@ -0,0 +1,56 @@
from datetime import timedelta
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from bookings.models import Reservation
from checkins.admin import CheckInAdmin
from checkins.models import CheckIn
from shows.models import Performance, Show, Venue
class CheckInAdminTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.admin_user = user_model.objects.create_superuser(
username="admin-checkins",
email="admin-checkins@example.com",
password="password123",
)
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-checkins-admin",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-checkins-admin",
address="Via Example 1",
city="Rome",
)
self.performance = Performance.objects.create(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=7),
room_capacity=20,
)
self.reservation = Reservation.objects.create(
performance=self.performance,
name="Maria Rossi",
email="maria@example.com",
party_size=3,
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
self.check_in = CheckIn.objects.create(
reservation=self.reservation,
checked_in_at=timezone.now(),
checked_in_by=self.admin_user,
source=CheckIn.Source.MANUAL,
)
self.model_admin = CheckInAdmin(CheckIn, admin.site)
def test_reservation_column_shows_guest_name_only(self):
self.assertEqual(self.model_admin.reservation_guest_name(self.check_in), "Maria Rossi")
+41
View File
@@ -11,6 +11,7 @@ from checkins.services import (
InvalidToken, InvalidToken,
MissingStaffUser, MissingStaffUser,
ReservationNotConfirmed, ReservationNotConfirmed,
confirm_check_in_for_reservation,
confirm_check_in_from_token, confirm_check_in_from_token,
preview_check_in_token, preview_check_in_token,
) )
@@ -73,6 +74,17 @@ class CheckInServiceTests(TestCase):
with self.assertRaises(InvalidToken): with self.assertRaises(InvalidToken):
preview_check_in_token(raw_token, staff_user=self.staff_user) preview_check_in_token(raw_token, staff_user=self.staff_user)
def test_preview_rejects_expired_check_in_token(self):
reservation = self.create_reservation()
_, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN,
expires_at=timezone.now() - timedelta(minutes=1),
)
with self.assertRaises(InvalidToken):
preview_check_in_token(raw_token, staff_user=self.staff_user)
def test_check_in_succeeds_for_confirmed_reservation(self): def test_check_in_succeeds_for_confirmed_reservation(self):
reservation = self.create_reservation() reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation) _, raw_token = self.create_check_in_token(reservation)
@@ -125,6 +137,27 @@ class CheckInServiceTests(TestCase):
with self.assertRaises(MissingStaffUser): with self.assertRaises(MissingStaffUser):
confirm_check_in_from_token(raw_token, staff_user=None) confirm_check_in_from_token(raw_token, staff_user=None)
def test_manual_check_in_by_reservation_id_succeeds_for_confirmed_reservation(self):
reservation = self.create_reservation()
result = confirm_check_in_for_reservation(
reservation_id=reservation.id,
staff_user=self.staff_user,
)
self.assertEqual(result.check_in.reservation, reservation)
self.assertEqual(result.check_in.checked_in_by, self.staff_user)
self.assertEqual(result.check_in.source, CheckIn.Source.MANUAL)
def test_manual_check_in_by_reservation_id_rejects_pending_reservation(self):
reservation = self.create_reservation(status=Reservation.Status.PENDING, confirmed_at=None)
with self.assertRaises(ReservationNotConfirmed):
confirm_check_in_for_reservation(
reservation_id=reservation.id,
staff_user=self.staff_user,
)
def test_check_in_rejects_confirmation_token_even_for_confirmed_reservation(self): def test_check_in_rejects_confirmation_token_even_for_confirmed_reservation(self):
reservation = self.create_reservation() reservation = self.create_reservation()
_, raw_token = ReservationToken.create_token( _, raw_token = ReservationToken.create_token(
@@ -136,6 +169,14 @@ class CheckInServiceTests(TestCase):
with self.assertRaises(InvalidToken): with self.assertRaises(InvalidToken):
confirm_check_in_from_token(raw_token, staff_user=self.staff_user) confirm_check_in_from_token(raw_token, staff_user=self.staff_user)
def test_check_in_rejects_used_check_in_token(self):
reservation = self.create_reservation()
token, raw_token = self.create_check_in_token(reservation)
token.mark_used()
with self.assertRaises(InvalidToken):
confirm_check_in_from_token(raw_token, staff_user=self.staff_user)
def create_reservation(self, **overrides): def create_reservation(self, **overrides):
data = { data = {
"performance": self.performance, "performance": self.performance,
+27 -2
View File
@@ -7,11 +7,36 @@ from .models import Performance, Show, Venue
@admin.register(Show) @admin.register(Show)
class ShowAdmin(admin.ModelAdmin): class ShowAdmin(admin.ModelAdmin):
list_display = ("title", "slug", "is_published", "created_at", "updated_at") list_display = ("title", "slug", "is_published", "image_preview", "created_at", "updated_at")
list_filter = ("is_published",) list_filter = ("is_published",)
search_fields = ("title", "slug", "summary", "description") search_fields = ("title", "slug", "summary", "description")
prepopulated_fields = {"slug": ("title",)} prepopulated_fields = {"slug": ("title",)}
readonly_fields = ("created_at", "updated_at") readonly_fields = ("image_preview", "created_at", "updated_at")
fields = (
"title",
"slug",
"summary",
"description",
"uploaded_image",
"poster_image",
"image_preview",
"is_published",
"created_at",
"updated_at",
)
@admin.display(description="Preview")
def image_preview(self, obj):
if not getattr(obj, "pk", None):
return "-"
image_url = obj.image_url()
if not image_url:
return "No image"
return format_html(
'<img src="{}" alt="{}" style="max-width: 120px; border-radius: 6px;" />',
image_url,
obj.title,
)
@admin.register(Venue) @admin.register(Venue)
@@ -0,0 +1,17 @@
# Generated by Django 5.2.3 on 2026-04-29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shows", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="show",
name="uploaded_image",
field=models.ImageField(blank=True, upload_to="shows/"),
),
]
+9
View File
@@ -16,6 +16,7 @@ class Show(TimeStampedModel):
summary = models.TextField(blank=True) summary = models.TextField(blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
poster_image = models.URLField(blank=True) poster_image = models.URLField(blank=True)
uploaded_image = models.ImageField(upload_to="shows/", blank=True)
is_published = models.BooleanField(default=False, db_index=True) is_published = models.BooleanField(default=False, db_index=True)
class Meta: class Meta:
@@ -28,6 +29,14 @@ class Show(TimeStampedModel):
def __str__(self): def __str__(self):
return self.title return self.title
def image_url(self, request=None):
if self.uploaded_image:
image_url = self.uploaded_image.url
if request is not None:
return request.build_absolute_uri(image_url)
return image_url
return self.poster_image
class Venue(TimeStampedModel): class Venue(TimeStampedModel):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
+10 -2
View File
@@ -1,7 +1,15 @@
from rest_framework import serializers from rest_framework import serializers
class PublicShowListSerializer(serializers.Serializer): class PublicShowImageMixin(serializers.Serializer):
image_url = serializers.SerializerMethodField()
def get_image_url(self, obj):
request = self.context.get("request")
return obj.image_url(request=request)
class PublicShowListSerializer(PublicShowImageMixin, serializers.Serializer):
id = serializers.IntegerField() id = serializers.IntegerField()
title = serializers.CharField() title = serializers.CharField()
slug = serializers.SlugField() slug = serializers.SlugField()
@@ -18,7 +26,7 @@ class PublicVenueDetailSerializer(PublicVenueSummarySerializer):
address = serializers.CharField() address = serializers.CharField()
class PublicShowSummarySerializer(serializers.Serializer): class PublicShowSummarySerializer(PublicShowImageMixin, serializers.Serializer):
title = serializers.CharField() title = serializers.CharField()
slug = serializers.SlugField() slug = serializers.SlugField()
summary = serializers.CharField() summary = serializers.CharField()
+77
View File
@@ -0,0 +1,77 @@
from datetime import timedelta
from tempfile import TemporaryDirectory
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from shows.models import Performance, Show, Venue
SMALL_GIF = (
b"GIF89a\x01\x00\x01\x00\x80\x00\x00"
b"\x00\x00\x00\xff\xff\xff!\xf9\x04\x01"
b"\x00\x00\x00\x00,\x00\x00\x00\x00\x01"
b"\x00\x01\x00\x00\x02\x02D\x01\x00;"
)
class ShowApiTests(APITestCase):
def setUp(self):
self.temp_media = TemporaryDirectory()
self.addCleanup(self.temp_media.cleanup)
self.settings_override = override_settings(MEDIA_ROOT=self.temp_media.name)
self.settings_override.enable()
self.addCleanup(self.settings_override.disable)
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-media",
summary="A contemporary theatre performance.",
description="Full public show description.",
poster_image="https://cdn.example.com/open-stage-poster.jpg",
is_published=True,
)
self.external_only_show = Show.objects.create(
title="External Poster Stage",
slug="external-poster-stage",
summary="External image only.",
description="External image only.",
poster_image="https://cdn.example.com/external-only.jpg",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-show-api",
address="Via Example 1",
city="Rome",
)
self.performance = Performance.objects.create(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=7),
room_capacity=20,
)
def test_show_list_prefers_uploaded_image_url_when_present(self):
self.show.uploaded_image.save(
"open-stage.gif",
SimpleUploadedFile("open-stage.gif", SMALL_GIF, content_type="image/gif"),
save=True,
)
response = self.client.get(reverse("api-show-list"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
list_item = next(item for item in response.data["results"] if item["slug"] == self.show.slug)
self.assertTrue(list_item["image_url"].endswith("/media/shows/open-stage.gif"))
self.assertEqual(list_item["poster_image"], "https://cdn.example.com/open-stage-poster.jpg")
def test_show_detail_falls_back_to_existing_external_image_url(self):
response = self.client.get(reverse("api-show-detail", kwargs={"slug": self.external_only_show.slug}))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["image_url"], "https://cdn.example.com/external-only.jpg")
+8 -4
View File
@@ -24,7 +24,7 @@ def public_performance_queryset():
@api_view(["GET"]) @api_view(["GET"])
def show_list(request): def show_list(request):
shows = Show.objects.filter(is_published=True).order_by("title") shows = Show.objects.filter(is_published=True).order_by("title")
serializer = PublicShowListSerializer(shows, many=True) serializer = PublicShowListSerializer(shows, many=True, context={"request": request})
return Response({"results": serializer.data}) return Response({"results": serializer.data})
@@ -32,7 +32,7 @@ def show_list(request):
def show_detail(request, slug): def show_detail(request, slug):
show = get_object_or_404(Show, slug=slug, is_published=True) show = get_object_or_404(Show, slug=slug, is_published=True)
show.public_performances = public_performance_queryset().filter(show=show) show.public_performances = public_performance_queryset().filter(show=show)
serializer = PublicShowDetailSerializer(show) serializer = PublicShowDetailSerializer(show, context={"request": request})
return Response(serializer.data) return Response(serializer.data)
@@ -54,12 +54,16 @@ def performance_list(request):
) )
performances = performances.filter(starts_at__gte=starts_from) performances = performances.filter(starts_at__gte=starts_from)
serializer = PublicPerformanceListSerializer(performances.order_by("starts_at"), many=True) serializer = PublicPerformanceListSerializer(
performances.order_by("starts_at"),
many=True,
context={"request": request},
)
return Response({"results": serializer.data}) return Response({"results": serializer.data})
@api_view(["GET"]) @api_view(["GET"])
def performance_detail(request, pk): def performance_detail(request, pk):
performance = get_object_or_404(public_performance_queryset(), pk=pk) performance = get_object_or_404(public_performance_queryset(), pk=pk)
serializer = PublicPerformanceDetailSerializer(performance) serializer = PublicPerformanceDetailSerializer(performance, context={"request": request})
return Response(serializer.data) return Response(serializer.data)
+8
View File
@@ -114,6 +114,14 @@ This operational flow should still follow the same backend rules as the public b
5. after the reservation transaction commits, the backend sends the standard confirmation email; 5. after the reservation transaction commits, the backend sends the standard confirmation email;
6. the guest still confirms through the email link before the reservation becomes confirmed and usable for check-in. 6. the guest still confirms through the email link before the reservation becomes confirmed and usable for check-in.
For operational testing or guest-support exceptions, Django admin also provides staff-only manual tools:
1. staff may confirm a pending reservation from the reservation admin page;
2. manual confirmation still rechecks booking availability before confirming;
3. the backend generates the same `check_in` token type used by the normal confirmation flow;
4. admin can generate a one-time operational QR code and check-in URL without showing token hashes in normal admin screens;
5. staff may mark a confirmed reservation as checked in from admin when the browser/mobile check-in flow is unavailable.
## Duplicate Check-In ## Duplicate Check-In
If the same QR code is scanned again: If the same QR code is scanned again:
+6 -2
View File
@@ -10,7 +10,7 @@ Required production changes:
- set a strong random `DJANGO_SECRET_KEY`; - set a strong random `DJANGO_SECRET_KEY`;
- set `DJANGO_ALLOWED_HOSTS` to the real public hostnames only; - set `DJANGO_ALLOWED_HOSTS` to the real public hostnames only;
- set `DJANGO_CSRF_TRUSTED_ORIGINS` to the real public HTTPS origins; - set `DJANGO_CSRF_TRUSTED_ORIGINS` to the real public HTTPS origins;
- set `SITE_BASE_URL` to the real public HTTPS base URL used for confirmation emails and QR/check-in links; - set `SITE_BASE_URL` to one real public HTTPS base URL used for confirmation emails and QR/check-in links;
- replace the console email backend with real SMTP settings and a valid sender address; - replace the console email backend with real SMTP settings and a valid sender address;
- publish only nginx and terminate HTTPS at nginx or a trusted upstream reverse proxy; - publish only nginx and terminate HTTPS at nginx or a trusted upstream reverse proxy;
- keep `collectstatic --noinput` in the deployment flow before `up -d`; - keep `collectstatic --noinput` in the deployment flow before `up -d`;
@@ -106,7 +106,7 @@ The database should not be published to the host in production.
Recommended volumes: Recommended volumes:
- `postgres_data`: PostgreSQL data directory; - `postgres_data`: PostgreSQL data directory;
- `media`: uploaded media and generated QR assets if stored on disk; - `media`: uploaded show images and generated QR assets if stored on disk;
- `static`: collected Django static files if served by nginx from a shared volume. - `static`: collected Django static files if served by nginx from a shared volume.
Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled. Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled.
@@ -134,8 +134,10 @@ Local Docker convention:
- use nginx as the public entrypoint at `http://localhost`; - use nginx as the public entrypoint at `http://localhost`;
- set `SITE_BASE_URL=http://localhost`; - set `SITE_BASE_URL=http://localhost`;
- keep `SITE_BASE_URL` as a single URL value, never a comma-separated list;
- keep `DJANGO_CSRF_TRUSTED_ORIGINS` and browser-facing `CORS_ALLOWED_ORIGINS` aligned with that public URL; - keep `DJANGO_CSRF_TRUSTED_ORIGINS` and browser-facing `CORS_ALLOWED_ORIGINS` aligned with that public URL;
- if you publish nginx on a different port, update `SITE_BASE_URL` and trusted origins to the same host and port. - if you publish nginx on a different port, update `SITE_BASE_URL` and trusted origins to the same host and port.
- local/debug reservation email sends also log the confirmation URL so browser testing can continue even if SMTP is missing or fails.
Required database configuration: Required database configuration:
@@ -149,6 +151,7 @@ Required nginx configuration:
- upstream backend service name and port; - upstream backend service name and port;
- static frontend root; - static frontend root;
- proxy rules for `/api/` and `/admin/`; - proxy rules for `/api/` and `/admin/`;
- media root for `/media/` if uploaded assets are served by nginx from a shared volume;
- TLS certificate paths for production. - TLS certificate paths for production.
Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values. Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values.
@@ -213,6 +216,7 @@ Database rollback needs special care once migrations exist. Down migrations or b
## Operational Notes ## Operational Notes
- Configure database backups before accepting real bookings. - Configure database backups before accepting real bookings.
- Back up the shared media volume together with the database if staff uploads show images.
- Monitor backend errors, email delivery failures, and check-in failures. - Monitor backend errors, email delivery failures, and check-in failures.
- Keep container images explicitly versioned; do not use `latest` tags. - Keep container images explicitly versioned; do not use `latest` tags.
- Keep the system small until operational needs justify additional services. - Keep the system small until operational needs justify additional services.
+5
View File
@@ -21,6 +21,11 @@
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
} }
], ],
"styles": [ "styles": [
+158 -52
View File
@@ -1,8 +1,6 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -11,106 +9,214 @@ import { MatToolbarModule } from '@angular/material/toolbar';
RouterOutlet, RouterOutlet,
RouterLink, RouterLink,
RouterLinkActive, RouterLinkActive,
MatToolbarModule,
MatButtonModule, MatButtonModule,
MatIconModule,
], ],
template: ` template: `
<div class="app-shell"> <div class="app-shell">
<mat-toolbar class="app-toolbar"> <div class="topline">
<a class="brand" routerLink="/"> <div class="topline-inner">
<span class="brand-mark">A</span> <span>Laboratori teatrali & produzioni audio/visive</span>
<span class="brand-text"> <span>Direzione artistica a cura di Ernesto Estatico</span>
<strong>AzioneLab</strong> </div>
<small>Theatre and reservations</small> </div>
</span>
<header class="site-header">
<div class="header-inner">
<a class="brand" routerLink="/" aria-label="AzioneLab">
<img class="brand-logo" src="assets/azione-lab.jpg" alt="AzioneLab" />
</a> </a>
<nav class="main-nav"> <nav class="main-nav" aria-label="Primary navigation">
<a mat-button routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a> <a mat-button routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Inizio</a>
<a mat-button routerLink="/shows" routerLinkActive="active">Shows</a> <a mat-button routerLink="/shows" routerLinkActive="active">Spettacoli</a>
<a mat-button routerLink="/check-in" routerLinkActive="active">Check-in</a> <a mat-button routerLink="/check-in" routerLinkActive="active">Accoglienza</a>
</nav> </nav>
</mat-toolbar>
<div class="header-actions">
<a mat-stroked-button routerLink="/shows">Prenota uno spettacolo</a>
</div>
</div>
</header>
<main class="page-shell"> <main class="page-shell">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<footer class="site-footer">
<div class="footer-inner">
<div>
<p class="footer-title">AzioneLab</p>
<p class="footer-copy">Un luogo dove ricerca, scena e relazione si incontrano per accogliere il pubblico con cura, prima ancora che si alzi il sipario.</p>
</div>
<nav class="footer-nav" aria-label="Footer navigation">
<a routerLink="/">Inizio</a>
<a routerLink="/shows">Spettacoli</a>
<a routerLink="/check-in">Accoglienza</a>
</nav>
</div>
</footer>
</div> </div>
`, `,
styles: [` styles: [`
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
display: flex;
flex-direction: column;
} }
.app-toolbar { .topline {
position: sticky; background: var(--azionelab-surface-dark);
top: 0; color: rgba(255, 247, 239, 0.84);
z-index: 10; font-size: 0.85rem;
}
.topline-inner,
.header-inner,
.footer-inner {
width: min(100%, var(--azionelab-shell-width));
margin: 0 auto;
}
.topline-inner {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
min-height: 72px; padding: 10px 20px;
padding: 0 24px; flex-wrap: wrap;
background: rgba(251, 247, 242, 0.88); }
backdrop-filter: blur(18px);
.site-header {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(16px);
background: rgba(250, 245, 238, 0.9);
border-bottom: 1px solid var(--azionelab-border); border-bottom: 1px solid var(--azionelab-border);
} }
.header-inner {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 24px;
min-height: 104px;
padding: 0 20px;
}
.brand { .brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 12px; justify-content: flex-start;
flex: 0 0 auto;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.brand-mark { .brand-logo {
display: inline-grid; width: 178px;
place-items: center; height: auto;
width: 42px; max-height: 76px;
height: 42px; display: block;
border-radius: 10px; box-shadow: 0 16px 32px rgba(111, 40, 33, 0.12);
background: linear-gradient(135deg, var(--azionelab-accent), #ca6d3b);
color: white;
font-weight: 700;
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.brand-text small {
color: var(--azionelab-muted);
font-size: 0.74rem;
} }
.main-nav { .main-nav {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; justify-content: center;
gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.main-nav .active { .main-nav .active {
background: rgba(159, 47, 40, 0.08); background: rgba(143, 51, 45, 0.1);
}
.header-actions {
display: flex;
justify-content: flex-end;
} }
.page-shell { .page-shell {
padding: 32px 20px 56px; flex: 1;
padding: 40px 20px 72px;
}
.site-footer {
border-top: 1px solid var(--azionelab-border);
background:
linear-gradient(180deg, rgba(255, 251, 246, 0.86), rgba(244, 235, 223, 0.92));
}
.footer-inner {
display: grid;
grid-template-columns: minmax(0, 1.5fr) auto;
gap: 24px;
align-items: start;
padding: 28px 20px 34px;
}
.footer-title {
margin: 0 0 8px;
font-family: var(--azionelab-serif);
font-size: 1.15rem;
font-weight: 700;
color: var(--azionelab-ink);
}
.footer-copy {
margin: 0;
max-width: 52ch;
color: var(--azionelab-muted);
line-height: 1.6;
}
.footer-nav {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.footer-nav a {
color: var(--azionelab-ink-soft);
text-decoration: none;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.app-toolbar { .header-inner {
align-items: flex-start; grid-template-columns: 1fr;
flex-direction: column; align-items: center;
padding: 16px 16px 14px; padding: 16px;
gap: 14px;
} }
.main-nav { .main-nav {
width: 100%; width: 100%;
justify-content: center;
}
.header-actions {
width: 100%;
justify-content: center;
}
.brand {
width: 100%;
justify-content: center;
}
.brand-logo {
width: 148px;
max-height: 64px;
}
.footer-inner {
grid-template-columns: 1fr;
padding: 24px 16px 28px;
}
.page-shell {
padding: 28px 16px 52px;
} }
} }
`], `],
+4 -4
View File
@@ -9,10 +9,10 @@ import { ShowListPageComponent } from './pages/show-list-page.component';
export const appRoutes: Routes = [ export const appRoutes: Routes = [
{ path: '', component: HomePageComponent, title: 'AzioneLab' }, { path: '', component: HomePageComponent, title: 'AzioneLab' },
{ path: 'shows', component: ShowListPageComponent, title: 'Shows | AzioneLab' }, { path: 'shows', component: ShowListPageComponent, title: 'Spettacoli | AzioneLab' },
{ path: 'shows/:slug', component: ShowDetailPlaceholderPageComponent, title: 'Show detail | AzioneLab' }, { path: 'shows/:slug', component: ShowDetailPlaceholderPageComponent, title: 'Scheda spettacolo | AzioneLab' },
{ path: 'performances/:id/book', component: BookingPlaceholderPageComponent, title: 'Book | AzioneLab' }, { path: 'performances/:id/book', component: BookingPlaceholderPageComponent, title: 'Prenotazione | AzioneLab' },
{ path: 'reservations/confirm', component: ReservationConfirmPageComponent, title: 'Confirm reservation | AzioneLab' }, { path: 'reservations/confirm', component: ReservationConfirmPageComponent, title: 'Conferma prenotazione | AzioneLab' },
{ path: 'check-in', component: CheckInPlaceholderPageComponent, title: 'Check-in | AzioneLab' }, { path: 'check-in', component: CheckInPlaceholderPageComponent, title: 'Check-in | AzioneLab' },
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' },
]; ];
@@ -29,119 +29,202 @@ type ApiValidationErrors = Record<string, string[]>;
template: ` template: `
<section class="page"> <section class="page">
<header class="page-header"> <header class="page-header">
<p class="eyebrow">Booking</p> <p class="eyebrow">Prenotazione</p>
<h1>Reserve seats</h1> <h1>Richiedi i tuoi posti con calma</h1>
<p class="supporting">Performance {{ performanceId }}. Complete the form and we will send a confirmation email.</p> <p class="supporting">
Replica {{ performanceId }}. Compila il modulo con i dati essenziali: ti invieremo un'email per confermare la richiesta prima che i posti vengano assegnati in modo definitivo.
</p>
</header> </header>
<div class="booking-grid">
<mat-card class="summary-card">
<mat-card-content>
<p class="summary-label">Come funziona</p>
<h2>Ti chiediamo pochi dati e ti accompagniamo fino alla conferma.</h2>
<ul class="summary-list">
<li>Riceverai un link di conferma all'indirizzo email che inserisci.</li>
<li>La disponibilita' viene controllata prima della conferma finale.</li>
<li>Dopo la conferma avrai il tuo QR code per l'ingresso.</li>
</ul>
</mat-card-content>
</mat-card>
<mat-card class="content-card"> <mat-card class="content-card">
<mat-card-content> <mat-card-content>
@if (isSuccess()) { @if (isSuccess()) {
<div class="status-copy success" aria-live="polite"> <div class="status-panel success" aria-live="polite">
<mat-icon>check_circle</mat-icon> <div class="status-icon">
<mat-icon fontSet="material-symbols-outlined">mark_email_read</mat-icon>
</div>
<div> <div>
<h2>Reservation created</h2> <h2>La tua richiesta e' partita</h2>
<p>check your email</p> <p>Controlla la tua email: con un ultimo passaggio potrai confermare la prenotazione e ricevere il QR code per l'ingresso.</p>
<div class="status-steps">
<span><mat-icon fontSet="material-symbols-outlined">mail</mat-icon> Apri l'email che ti abbiamo inviato</span>
<span><mat-icon fontSet="material-symbols-outlined">verified</mat-icon> Conferma i posti con un tocco</span>
</div>
</div> </div>
</div> </div>
} @else { } @else {
<form [formGroup]="bookingForm" (ngSubmit)="submit()" novalidate> <form [formGroup]="bookingForm" (ngSubmit)="submit()" novalidate>
<div class="intro-note">
<mat-icon fontSet="material-symbols-outlined">info</mat-icon>
<p>Ti chiediamo solo il necessario. La conferma via email ci aiuta a tenere la disponibilita' chiara per tutti.</p>
</div>
<div class="form-grid"> <div class="form-grid">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Name</mat-label> <mat-icon matPrefix fontSet="material-symbols-outlined">person</mat-icon>
<mat-label>Nome</mat-label>
<input matInput type="text" formControlName="name" autocomplete="name" /> <input matInput type="text" formControlName="name" autocomplete="name" />
@if (bookingForm.controls.name.touched && bookingForm.controls.name.hasError('required')) { @if (bookingForm.controls.name.touched && bookingForm.controls.name.hasError('required')) {
<mat-error>Name is required.</mat-error> <mat-error>Il nome e' obbligatorio.</mat-error>
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-icon matPrefix fontSet="material-symbols-outlined">mail</mat-icon>
<mat-label>Email</mat-label> <mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" /> <input matInput type="email" formControlName="email" autocomplete="email" />
<mat-hint>Qui arrivera' il link per confermare la tua richiesta.</mat-hint>
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('required')) { @if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('required')) {
<mat-error>Email is required.</mat-error> <mat-error>L'email e' obbligatoria.</mat-error>
} }
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('email')) { @if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('email')) {
<mat-error>Enter a valid email address.</mat-error> <mat-error>Inserisci un indirizzo email valido.</mat-error>
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Number of seats</mat-label> <mat-icon matPrefix fontSet="material-symbols-outlined">group</mat-icon>
<mat-label>Numero di posti</mat-label>
<input matInput type="number" min="1" step="1" formControlName="partySize" /> <input matInput type="number" min="1" step="1" formControlName="partySize" />
<mat-hint>Indica quante persone desideri includere nella prenotazione.</mat-hint>
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('required')) { @if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('required')) {
<mat-error>Number of seats is required.</mat-error> <mat-error>Il numero di posti e' obbligatorio.</mat-error>
} }
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('min')) { @if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('min')) {
<mat-error>At least 1 seat is required.</mat-error> <mat-error>Devi richiedere almeno 1 posto.</mat-error>
} }
</mat-form-field> </mat-form-field>
</div> </div>
@if (submitError()) { @if (submitError()) {
<p class="error-message" aria-live="assertive">{{ submitError() }}</p> <div class="message-panel error" aria-live="assertive">
<mat-icon fontSet="material-symbols-outlined">error</mat-icon>
<p>{{ submitError() }}</p>
</div>
} }
@if (fieldErrors().length > 0) { @if (fieldErrors().length > 0) {
<div class="field-errors" aria-live="assertive"> <div class="message-panel error field-errors" aria-live="assertive">
<mat-icon fontSet="material-symbols-outlined">warning</mat-icon>
<div>
<p class="message-title">Controlla i dati evidenziati:</p>
@for (message of fieldErrors(); track message) { @for (message of fieldErrors(); track message) {
<p>{{ message }}</p> <p>{{ message }}</p>
} }
</div> </div>
</div>
} }
<div class="actions"> <div class="actions">
<button mat-flat-button type="submit" [disabled]="isSubmitting()"> <button mat-flat-button type="submit" [disabled]="isSubmitting()">
@if (isSubmitting()) { @if (isSubmitting()) {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner> <mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Submitting...</span> <span>Invio in corso...</span>
} @else { } @else {
<span>Reserve</span> <ng-container>
<mat-icon fontSet="material-symbols-outlined">confirmation_number</mat-icon>
<span>Prenota</span>
</ng-container>
} }
</button> </button>
<a mat-button routerLink="/shows">Back to shows</a> <a mat-button routerLink="/shows">Torna agli spettacoli</a>
</div> </div>
</form> </form>
} }
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div>
</section> </section>
`, `,
styles: [` styles: [`
.page {
max-width: 760px;
margin: 0 auto;
}
.page-header { .page-header {
margin-bottom: 24px; margin-bottom: 26px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
} }
.supporting { .supporting {
color: var(--azionelab-muted);
line-height: 1.6;
max-width: 56ch; max-width: 56ch;
margin: 14px 0 0; margin: 14px 0 0;
} }
.booking-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.summary-card,
.content-card { .content-card {
border-radius: 8px; border-radius: var(--azionelab-radius-lg);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: var(--azionelab-surface-strong);
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
overflow: hidden;
}
.summary-card {
background:
linear-gradient(180deg, rgba(255, 252, 248, 0.98), rgba(247, 238, 227, 0.94));
}
mat-card-content {
padding: 28px !important;
}
.summary-label {
margin: 0 0 10px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--azionelab-accent);
}
.summary-card h2 {
margin: 0;
max-width: 14ch;
}
.summary-list {
display: grid;
gap: 12px;
margin: 18px 0 0;
padding-left: 18px;
color: var(--azionelab-ink-soft);
line-height: 1.6;
}
.intro-note {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 20px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(159, 47, 40, 0.06);
color: var(--azionelab-muted);
}
.intro-note p {
margin: 0;
line-height: 1.55;
}
.intro-note mat-icon {
color: var(--azionelab-accent);
} }
.form-grid { .form-grid {
@@ -153,18 +236,37 @@ type ApiValidationErrors = Record<string, string[]>;
width: 100%; width: 100%;
} }
.error-message, .message-panel {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 14px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid transparent;
}
.message-panel.error {
background: var(--azionelab-error-bg);
border-color: var(--azionelab-error-border);
color: var(--azionelab-error-ink);
}
.message-panel p,
.field-errors p { .field-errors p {
margin: 0; margin: 0;
color: #b3261e;
line-height: 1.4; line-height: 1.4;
font-size: 0.92rem; font-size: 0.92rem;
} }
.field-errors { .message-title {
font-weight: 700;
margin-bottom: 6px !important;
}
.field-errors > div {
display: grid; display: grid;
gap: 6px; gap: 6px;
margin-top: 10px;
} }
.actions { .actions {
@@ -183,23 +285,81 @@ type ApiValidationErrors = Record<string, string[]>;
gap: 8px; gap: 8px;
} }
.status-copy { .status-panel {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 14px; gap: 16px;
padding: 4px 0;
} }
.status-copy h2 { .status-panel h2 {
margin: 0 0 6px; margin: 0 0 6px;
} }
.status-copy p { .status-panel p {
margin: 0; margin: 0;
color: var(--azionelab-muted); color: var(--azionelab-muted);
line-height: 1.55;
} }
.status-copy.success mat-icon { .status-panel.success {
color: #2e7d32; padding: 22px;
border-radius: 18px;
background: var(--azionelab-success-bg);
border: 1px solid var(--azionelab-success-border);
}
.status-icon {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 16px;
background: rgba(46, 125, 50, 0.12);
}
.status-icon mat-icon {
color: var(--azionelab-success-ink);
}
.status-steps {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.status-steps span {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: var(--azionelab-success-ink);
font-size: 0.92rem;
}
.status-steps mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
@media (max-width: 640px) {
.booking-grid {
grid-template-columns: 1fr;
}
mat-card-content {
padding: 22px !important;
}
.status-panel,
.message-panel,
.intro-note {
border-radius: 14px;
}
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -253,15 +413,30 @@ export class BookingPlaceholderPageComponent {
this.fieldErrors.set(this.flattenValidationErrors(error.error as ApiValidationErrors)); this.fieldErrors.set(this.flattenValidationErrors(error.error as ApiValidationErrors));
return; return;
} }
this.submitError.set('Could not create reservation. Please try again.'); this.submitError.set('Non siamo riusciti a inviare la richiesta in questo momento. Riprova tra poco.');
}, },
}); });
} }
private flattenValidationErrors(errors: ApiValidationErrors): string[] { private flattenValidationErrors(errors: ApiValidationErrors): string[] {
return Object.entries(errors).flatMap(([field, messages]) => { return Object.entries(errors).flatMap(([field, messages]) => {
const label = field === 'party_size' ? 'number of seats' : field; const labelMap: Record<string, string> = {
return messages.map((message) => `${label}: ${message}`); name: 'nome',
email: 'email',
party_size: 'numero di posti',
};
const label = labelMap[field] ?? field;
return messages.map((message) => `${label}: ${this.translateValidationMessage(message)}`);
}); });
} }
private translateValidationMessage(message: string): string {
const translations: Record<string, string> = {
'This field is required.': 'questo campo e\' obbligatorio.',
'Enter a valid email address.': 'inserisci un indirizzo email valido.',
'Ensure this value is greater than or equal to 1.': 'inserisci un valore maggiore o uguale a 1.',
};
return translations[message] ?? message;
}
} }
@@ -11,7 +11,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -63,22 +63,35 @@ type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => Barc
template: ` template: `
<section class="page"> <section class="page">
<header class="page-header"> <header class="page-header">
<p class="eyebrow">Staff check-in</p> <p class="eyebrow">Accoglienza staff</p>
<h1>Token validation</h1> <h1>Controllo accessi</h1>
<p class="supporting">Enter a token manually or scan a QR code to preview admission data and confirm entrance.</p> <p class="supporting">Usa questa pagina per verificare rapidamente il QR code o il token della prenotazione e registrare l'ingresso senza incertezze.</p>
</header> </header>
<div class="checkin-grid">
<mat-card class="side-card">
<mat-card-content>
<p class="side-label">Ingresso sala</p>
<h2>Uno strumento pensato per accogliere bene, anche nei momenti piu' intensi.</h2>
<ul class="side-list">
<li>Inquadra il QR code se la fotocamera del dispositivo e' disponibile.</li>
<li>Inserisci il token a mano se la scansione non e' praticabile.</li>
<li>Conferma l'ingresso solo quando i dati a schermo corrispondono alla prenotazione del pubblico.</li>
</ul>
</mat-card-content>
</mat-card>
<mat-card class="content-card"> <mat-card class="content-card">
<mat-card-content> <mat-card-content>
<section class="scanner-panel"> <section class="scanner-panel">
<div class="scanner-copy"> <div class="scanner-copy">
<h2>Camera scan</h2> <h2>Scansione con fotocamera</h2>
<p>Optional on supported browsers. If the QR contains a full check-in URL, the token is extracted automatically.</p> <p>Nei browser compatibili il token viene letto automaticamente dal QR code, anche quando contiene l'intero link di check-in.</p>
</div> </div>
<div class="actions scanner-actions"> <div class="actions scanner-actions">
@if (cameraState() === 'active') { @if (cameraState() === 'active') {
<button mat-stroked-button type="button" (click)="stopScanner()">Stop camera</button> <button mat-stroked-button type="button" (click)="stopScanner()">Ferma fotocamera</button>
} @else { } @else {
<button <button
mat-stroked-button mat-stroked-button
@@ -88,9 +101,9 @@ type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => Barc
> >
@if (cameraState() === 'starting') { @if (cameraState() === 'starting') {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner> <mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Starting camera...</span> <span>Avvio fotocamera...</span>
} @else { } @else {
<span>Use camera</span> <span>Usa fotocamera</span>
} }
</button> </button>
} }
@@ -110,10 +123,10 @@ type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => Barc
<form [formGroup]="tokenForm" (ngSubmit)="preview()" novalidate> <form [formGroup]="tokenForm" (ngSubmit)="preview()" novalidate>
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Opaque token</mat-label> <mat-label>Token opaco</mat-label>
<input matInput formControlName="token" autocomplete="off" /> <input matInput formControlName="token" autocomplete="off" />
@if (tokenForm.controls.token.touched && tokenForm.controls.token.hasError('required')) { @if (tokenForm.controls.token.touched && tokenForm.controls.token.hasError('required')) {
<mat-error>Token is required.</mat-error> <mat-error>Il token e' obbligatorio.</mat-error>
} }
</mat-form-field> </mat-form-field>
@@ -121,34 +134,34 @@ type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => Barc
<button mat-flat-button type="submit" [disabled]="isBusy()"> <button mat-flat-button type="submit" [disabled]="isBusy()">
@if (state() === 'preview_loading') { @if (state() === 'preview_loading') {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner> <mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Validating...</span> <span>Verifica in corso...</span>
} @else { } @else {
<span>Preview check-in</span> <span>Controlla prenotazione</span>
} }
</button> </button>
<a mat-button routerLink="/">Home</a> <a mat-button routerLink="/">Inizio</a>
<a mat-button routerLink="/shows">Shows</a> <a mat-button routerLink="/shows">Spettacoli</a>
</div> </div>
</form> </form>
@if (previewData() && shouldShowPreview()) { @if (previewData() && shouldShowPreview()) {
<section class="preview-panel" aria-live="polite"> <section class="preview-panel" aria-live="polite">
<h2>Admission preview</h2> <h2>Dati per l'ingresso</h2>
<dl> <dl>
<div><dt>Show</dt><dd>{{ previewData()!.show_title }}</dd></div> <div><dt>Spettacolo</dt><dd>{{ previewData()!.show_title }}</dd></div>
<div><dt>Venue</dt><dd>{{ previewData()!.venue_name }}</dd></div> <div><dt>Spazio</dt><dd>{{ previewData()!.venue_name }}</dd></div>
<div><dt>Starts at</dt><dd>{{ previewData()!.starts_at | date: 'EEEE d MMMM, HH:mm' }}</dd></div> <div><dt>Inizio</dt><dd>{{ previewData()!.starts_at | date: 'EEEE d MMMM, HH:mm' }}</dd></div>
<div><dt>Party size</dt><dd>{{ previewData()!.party_size }}</dd></div> <div><dt>Posti</dt><dd>{{ previewData()!.party_size }}</dd></div>
<div><dt>Reservation</dt><dd>#{{ previewData()!.reservation_id }}</dd></div> <div><dt>Prenotazione</dt><dd>#{{ previewData()!.reservation_id }}</dd></div>
</dl> </dl>
<button mat-flat-button type="button" (click)="confirm()" [disabled]="isBusy() || state() === 'confirm_success'"> <button mat-flat-button type="button" (click)="confirm()" [disabled]="isBusy() || state() === 'confirm_success'">
@if (state() === 'confirm_loading') { @if (state() === 'confirm_loading') {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner> <mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Confirming...</span> <span>Registrazione in corso...</span>
} @else if (state() === 'confirm_success') { } @else if (state() === 'confirm_success') {
<span>Checked in</span> <span>Ingresso registrato</span>
} @else { } @else {
<span>Confirm check-in</span> <span>Registra ingresso</span>
} }
</button> </button>
</section> </section>
@@ -156,65 +169,81 @@ type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => Barc
@if (state() === 'confirm_success' && confirmData()) { @if (state() === 'confirm_success' && confirmData()) {
<p class="success-message" aria-live="polite"> <p class="success-message" aria-live="polite">
Check-in confirmed at {{ confirmData()!.checked_in_at | date: 'HH:mm' }}. Ingresso registrato alle {{ confirmData()!.checked_in_at | date: 'HH:mm' }}.
</p> </p>
} }
@if (state() === 'invalid_token') { @if (state() === 'invalid_token') {
<p class="error-message" aria-live="assertive">Invalid token.</p> <p class="error-message" aria-live="assertive">Il token inserito non e' valido.</p>
} }
@if (state() === 'pending_reservation') { @if (state() === 'pending_reservation') {
<p class="error-message" aria-live="assertive">Reservation is still pending confirmation.</p> <p class="error-message" aria-live="assertive">La prenotazione non e' ancora stata confermata dal pubblico.</p>
} }
@if (state() === 'already_checked_in') { @if (state() === 'already_checked_in') {
<p class="error-message" aria-live="assertive">This reservation is already checked in.</p> <p class="error-message" aria-live="assertive">Questa prenotazione risulta gia' registrata in ingresso.</p>
} }
@if (state() === 'unauthorized') { @if (state() === 'unauthorized') {
<p class="error-message" aria-live="assertive">You are not authorized. Staff login is required.</p> <p class="error-message" aria-live="assertive">Non sei autorizzato. Accedi a <code>/admin</code> con un account staff, lascia ricaricare la pagina con quella sessione e poi riprova.</p>
} }
@if (state() === 'error') { @if (state() === 'error') {
<p class="error-message" aria-live="assertive">Something went wrong. Please try again.</p> <p class="error-message" aria-live="assertive">Non siamo riusciti a completare la verifica. Riprova tra poco.</p>
} }
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div>
</section> </section>
`, `,
styles: [` styles: [`
.page {
max-width: 760px;
margin: 0 auto;
}
.page-header { .page-header {
margin-bottom: 22px; margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
} }
.supporting { .supporting {
color: var(--azionelab-muted);
line-height: 1.6;
max-width: 50ch; max-width: 50ch;
} }
.checkin-grid {
display: grid;
grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.side-card,
.content-card { .content-card {
border-radius: 8px; border-radius: var(--azionelab-radius-lg);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: var(--azionelab-surface-strong);
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
.side-card {
background:
linear-gradient(180deg, rgba(255, 252, 248, 0.98), rgba(247, 238, 227, 0.94));
}
.side-label {
margin: 0 0 10px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--azionelab-accent);
}
.side-card h2 {
margin: 0;
}
.side-list {
display: grid;
gap: 12px;
margin: 18px 0 0;
padding-left: 18px;
color: var(--azionelab-ink-soft);
line-height: 1.6;
}
.scanner-panel { .scanner-panel {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -320,12 +349,19 @@ type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => Barc
color: #b3261e; color: #b3261e;
font-weight: 500; font-weight: 500;
} }
@media (max-width: 760px) {
.checkin-grid {
grid-template-columns: 1fr;
}
}
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CheckInPlaceholderPageComponent { export class CheckInPlaceholderPageComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly showsApi = inject(ShowsApiService); private readonly showsApi = inject(ShowsApiService);
private readonly barcodeDetectorCtor = (globalThis as { BarcodeDetector?: BarcodeDetectorConstructor }).BarcodeDetector; private readonly barcodeDetectorCtor = (globalThis as { BarcodeDetector?: BarcodeDetectorConstructor }).BarcodeDetector;
private readonly scannerSupported = private readonly scannerSupported =
@@ -352,12 +388,18 @@ export class CheckInPlaceholderPageComponent {
protected readonly cameraState = signal<CameraState>(this.scannerSupported ? 'ready' : 'unsupported'); protected readonly cameraState = signal<CameraState>(this.scannerSupported ? 'ready' : 'unsupported');
protected readonly cameraMessage = signal( protected readonly cameraMessage = signal(
this.scannerSupported this.scannerSupported
? 'Open the camera to scan a QR code, or keep using manual token entry.' ? 'Apri la fotocamera per scansionare un QR code, oppure continua con l\'inserimento manuale del token.'
: 'Camera scanning is not available in this browser. Manual token entry still works.', : 'La scansione con fotocamera non e\' disponibile in questo browser. Puoi comunque inserire il token manualmente.',
); );
constructor() { constructor() {
this.destroyRef.onDestroy(() => this.stopScanner()); this.destroyRef.onDestroy(() => this.stopScanner());
const tokenFromQuery = this.route.snapshot.queryParamMap.get('token')?.trim() ?? '';
if (tokenFromQuery) {
this.tokenForm.controls.token.setValue(tokenFromQuery);
this.tokenForm.controls.token.markAsTouched();
this.preview();
}
} }
protected preview(): void { protected preview(): void {
@@ -410,13 +452,13 @@ export class CheckInPlaceholderPageComponent {
protected async startScanner(): Promise<void> { protected async startScanner(): Promise<void> {
if (!this.scannerSupported || !this.barcodeDetectorCtor) { if (!this.scannerSupported || !this.barcodeDetectorCtor) {
this.cameraState.set('unsupported'); this.cameraState.set('unsupported');
this.cameraMessage.set('Camera scanning is not available in this browser. Manual token entry still works.'); this.cameraMessage.set('La scansione con fotocamera non e\' disponibile in questo browser. Puoi comunque inserire il token manualmente.');
return; return;
} }
this.stopScanner(); this.stopScanner();
this.cameraState.set('starting'); this.cameraState.set('starting');
this.cameraMessage.set('Starting camera...'); this.cameraMessage.set('Avvio della fotocamera in corso...');
try { try {
this.scannerStream = await navigator.mediaDevices.getUserMedia({ this.scannerStream = await navigator.mediaDevices.getUserMedia({
@@ -426,19 +468,19 @@ export class CheckInPlaceholderPageComponent {
this.detector = new this.barcodeDetectorCtor({ formats: ['qr_code'] }); this.detector = new this.barcodeDetectorCtor({ formats: ['qr_code'] });
this.cameraState.set('active'); this.cameraState.set('active');
this.cameraMessage.set('Point the camera at the visitor QR code.'); this.cameraMessage.set('Inquadra il QR code del visitatore.');
this.scheduleScan(); this.scheduleScan();
} catch (error) { } catch (error) {
this.stopScanner(); this.stopScanner();
if (error instanceof DOMException && (error.name === 'NotAllowedError' || error.name === 'SecurityError')) { if (error instanceof DOMException && (error.name === 'NotAllowedError' || error.name === 'SecurityError')) {
this.cameraState.set('denied'); this.cameraState.set('denied');
this.cameraMessage.set('Camera access was denied. You can continue with manual token entry.'); this.cameraMessage.set('L\'accesso alla fotocamera e\' stato negato. Puoi continuare con l\'inserimento manuale del token.');
return; return;
} }
this.cameraState.set('error'); this.cameraState.set('error');
this.cameraMessage.set('Could not start the camera. You can continue with manual token entry.'); this.cameraMessage.set('Non siamo riusciti ad avviare la fotocamera. Puoi continuare con l\'inserimento manuale del token.');
} }
} }
@@ -465,7 +507,7 @@ export class CheckInPlaceholderPageComponent {
if (this.scannerSupported && this.cameraState() === 'active') { if (this.scannerSupported && this.cameraState() === 'active') {
this.cameraState.set('ready'); this.cameraState.set('ready');
this.cameraMessage.set('Camera stopped. You can scan again or continue with manual token entry.'); this.cameraMessage.set('Fotocamera fermata. Puoi riavviare la scansione oppure continuare con l\'inserimento manuale del token.');
} }
} }
@@ -517,7 +559,7 @@ export class CheckInPlaceholderPageComponent {
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
if (!context) { if (!context) {
this.cameraState.set('error'); this.cameraState.set('error');
this.cameraMessage.set('Camera scan is not available right now. Please enter the token manually.'); this.cameraMessage.set('La scansione non e\' disponibile in questo momento. Inserisci il token manualmente.');
this.stopScanner(); this.stopScanner();
return; return;
} }
@@ -536,14 +578,14 @@ export class CheckInPlaceholderPageComponent {
if (token) { if (token) {
this.tokenForm.controls.token.setValue(token); this.tokenForm.controls.token.setValue(token);
this.tokenForm.controls.token.markAsTouched(); this.tokenForm.controls.token.markAsTouched();
this.cameraMessage.set('QR captured. Validating token...'); this.cameraMessage.set('QR acquisito. Verifica del token in corso...');
this.stopScanner(); this.stopScanner();
this.preview(); this.preview();
return; return;
} }
} catch { } catch {
this.cameraState.set('error'); this.cameraState.set('error');
this.cameraMessage.set('Camera scan failed. Please enter the token manually.'); this.cameraMessage.set('La scansione non e\' andata a buon fine. Inserisci il token manualmente.');
this.stopScanner(); this.stopScanner();
return; return;
} finally { } finally {
+237 -43
View File
@@ -9,66 +9,103 @@ import { API_BASE_URL } from '../services/api-config.token';
standalone: true, standalone: true,
imports: [RouterLink, MatButtonModule, MatCardModule], imports: [RouterLink, MatButtonModule, MatCardModule],
template: ` template: `
<section class="hero"> <section class="hero page">
<div class="hero-copy"> <div class="hero-copy">
<p class="eyebrow">AzioneLab Theatre Company</p> <p class="eyebrow">AzioneLab</p>
<h1>Public website and booking UI foundations.</h1> <h1>Un luogo dove il teatro prende forma, voce e relazione.</h1>
<p class="supporting"> <p class="supporting">
This Angular shell is wired for the existing Django APIs and ready for the next booking-focused iterations. AzioneLab intreccia laboratori teatrali, produzioni audio/visive e momenti scenici costruiti con cura. Qui il pubblico trova una casa aperta: puo' scoprire gli spettacoli, prenotare con semplicita' e arrivare in sala con la leggerezza di chi sa gia' dove andare.
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<a mat-flat-button color="primary" routerLink="/shows">Browse shows</a> <a mat-flat-button color="primary" routerLink="/shows">Entra nella programmazione</a>
<a mat-stroked-button routerLink="/check-in">Check-in area</a> <a mat-stroked-button routerLink="/check-in">Vai all'accoglienza</a>
</div> </div>
</div> </div>
<div class="hero-panel"> <div class="hero-stage" aria-hidden="true">
<mat-card> <div class="curtain"></div>
<mat-card-title>Frontend wiring</mat-card-title> <div class="stage-glow"></div>
<div class="stage-copy">
<span>Questa sera in scena</span>
<strong>Ti aspettiamo in sala con il tempo giusto per entrare nell'atmosfera</strong>
</div>
</div>
</section>
<section class="overview page">
<div class="section-heading">
<div>
<p class="eyebrow">Perche' AzioneLab</p>
<h2>Non solo prenotazioni: un invito a entrare nel lavoro, nell'ascolto e nella scena</h2>
</div>
<p class="supporting">Ogni passaggio e' pensato per restare umano: guardi cosa c'e', scegli la data, ricevi conferma e arrivi in teatro senza attriti.</p>
</div>
<div class="feature-grid">
<mat-card class="feature-card">
<mat-card-title>Trova la serata che fa per te</mat-card-title>
<mat-card-content> <mat-card-content>
<p><strong>API base URL</strong></p> <p>Le schede tengono vicini i dettagli che contano davvero: luogo, orario, disponibilita' e respiro dello spettacolo.</p>
<code>{{ apiBaseUrl }}</code> </mat-card-content>
<p class="panel-note">Placeholders are in place for public content, booking, and staff check-in flows.</p> </mat-card>
<mat-card class="feature-card">
<mat-card-title>Una conferma semplice, senza fretta</mat-card-title>
<mat-card-content>
<p>Ricevi un'email, confermi quando vuoi e la tua prenotazione prende forma in modo chiaro e affidabile.</p>
</mat-card-content>
</mat-card>
<mat-card class="feature-card">
<mat-card-title>Accoglienza attenta all'ingresso</mat-card-title>
<mat-card-content>
<p>Lo staff puo' accompagnare l'arrivo del pubblico con uno strumento rapido, discreto e pensato per il lavoro in sala.</p>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
</section> </section>
<section class="journey-grid page">
<div class="journey-copy">
<p class="eyebrow">Il percorso del pubblico</p>
<h2>Dalla curiosita' al posto in sala, con naturalezza</h2>
<ol>
<li>Scopri la programmazione e lasciati guidare dalla scheda di ogni spettacolo.</li>
<li>Richiedi i posti per la replica che preferisci e conferma dall'email ricevuta.</li>
<li>Conserva il QR code sul telefono oppure stampalo e portalo con te all'ingresso.</li>
</ol>
</div>
<mat-card class="meta-card">
<mat-card-title>Dietro le quinte</mat-card-title>
<mat-card-content>
<p class="meta-label">Collegamento API</p>
<code>{{ apiBaseUrl }}</code>
<p class="meta-note">La parte pubblica del sito dialoga con le API Django esistenti senza cambiare il comportamento del backend.</p>
</mat-card-content>
</mat-card>
</section>
`, `,
styles: [` styles: [`
.hero { .hero {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr); grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.9fr);
gap: 28px; gap: 28px;
align-items: stretch; align-items: stretch;
max-width: 1180px; padding: 12px 0 24px;
margin: 0 auto;
} }
.hero-copy { .hero-copy {
padding: 36px 0; padding: 34px 0 20px;
}
.eyebrow {
margin: 0 0 12px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
} }
h1 { h1 {
margin: 0; margin: 0;
max-width: 10ch; max-width: 10.5ch;
font-size: clamp(2.5rem, 5vw, 4.75rem); font-size: 3.6rem;
line-height: 0.95;
} }
.supporting { .supporting {
max-width: 52ch;
color: var(--azionelab-muted);
font-size: 1.08rem;
line-height: 1.65;
margin: 20px 0 0; margin: 20px 0 0;
font-size: 1.06rem;
} }
.hero-actions { .hero-actions {
@@ -78,32 +115,189 @@ import { API_BASE_URL } from '../services/api-config.token';
margin-top: 28px; margin-top: 28px;
} }
.hero-panel mat-card { .hero-stage {
height: 100%; position: relative;
border-radius: 8px; overflow: hidden;
min-height: 420px;
border-radius: var(--azionelab-radius-lg);
border: 1px solid rgba(255, 250, 245, 0.16);
background:
linear-gradient(180deg, rgba(31, 18, 18, 0.1), rgba(27, 18, 14, 0.62)),
linear-gradient(135deg, #b04b40 0%, #7f251f 24%, #43261f 56%, #211b1a 100%);
box-shadow: var(--azionelab-shadow-strong);
}
.curtain {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0, rgba(255, 255, 255, 0) 22%, rgba(255, 255, 255, 0.08) 48%, rgba(255, 255, 255, 0) 74%, rgba(255, 255, 255, 0.08) 100%),
repeating-linear-gradient(90deg, rgba(75, 18, 14, 0.2) 0 18px, rgba(160, 60, 52, 0.08) 18px 36px);
opacity: 0.9;
}
.stage-glow {
position: absolute;
left: 50%;
bottom: -80px;
width: 340px;
height: 340px;
transform: translateX(-50%);
border-radius: 50%;
background: radial-gradient(circle, rgba(245, 214, 150, 0.9) 0, rgba(245, 214, 150, 0.22) 44%, rgba(245, 214, 150, 0) 72%);
}
.stage-copy {
position: absolute;
left: 24px;
right: 24px;
bottom: 24px;
display: grid;
gap: 8px;
padding: 18px 20px;
border-radius: 18px;
background: rgba(24, 17, 15, 0.52);
backdrop-filter: blur(8px);
color: rgba(255, 247, 239, 0.94);
border: 1px solid rgba(255, 247, 239, 0.12);
}
.stage-copy span {
text-transform: uppercase;
font-size: 0.76rem;
letter-spacing: 0.08em;
color: rgba(255, 227, 192, 0.84);
}
.stage-copy strong {
font-family: var(--azionelab-serif);
font-size: 1.4rem;
font-weight: 700;
line-height: 1.15;
}
.overview,
.journey-grid {
margin-top: 34px;
}
.section-heading {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 420px);
gap: 24px;
align-items: end;
margin-bottom: 20px;
}
.section-heading h2,
.journey-copy h2 {
margin: 0;
max-width: 18ch;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.feature-card,
.meta-card {
border-radius: var(--azionelab-radius-md);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: linear-gradient(180deg, rgba(255, 253, 250, 0.98), rgba(251, 245, 236, 0.94));
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
.feature-card {
min-height: 210px;
}
.feature-card mat-card-title,
.meta-card mat-card-title {
margin-bottom: 12px;
font-family: var(--azionelab-serif);
font-size: 1.18rem;
font-weight: 700;
}
.feature-card p,
.meta-card p {
margin: 0;
color: var(--azionelab-muted);
line-height: 1.6;
}
.journey-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.9fr);
gap: 24px;
align-items: start;
}
.journey-copy {
padding: 10px 0;
}
ol {
display: grid;
gap: 12px;
margin: 20px 0 0;
padding-left: 22px;
color: var(--azionelab-ink-soft);
line-height: 1.65;
}
.meta-label {
margin: 0 0 10px;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
color: var(--azionelab-muted);
letter-spacing: 0.08em;
}
code { code {
display: inline-block; display: inline-block;
margin-top: 8px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 12px;
background: rgba(30, 27, 24, 0.06); background: rgba(34, 28, 24, 0.06);
color: var(--azionelab-ink-soft);
word-break: break-word;
} }
.panel-note { .meta-note {
margin-top: 20px; margin: 18px 0 0;
color: var(--azionelab-muted);
line-height: 1.5;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.hero { .hero,
.section-heading,
.journey-grid,
.feature-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
h1 {
font-size: 2.8rem;
}
}
@media (max-width: 640px) {
h1 {
font-size: 2.2rem;
}
.hero-stage {
min-height: 320px;
}
.stage-copy {
left: 16px;
right: 16px;
bottom: 16px;
padding: 16px;
}
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -23,151 +23,332 @@ type ConfirmationState = 'loading' | 'success' | 'invalid' | 'expired' | 'error'
template: ` template: `
<section class="page"> <section class="page">
<header class="page-header"> <header class="page-header">
<p class="eyebrow">Reservation confirmation</p> <p class="eyebrow">Conferma prenotazione</p>
<h1>Email confirmation</h1> <h1>Ci siamo quasi</h1>
<p class="supporting">Stai completando l'ultimo passaggio: appena la conferma va a buon fine, il tuo QR code sara' pronto per accompagnarti all'ingresso.</p>
</header> </header>
<mat-card class="status-card"> <mat-card class="status-card">
<mat-card-content> <mat-card-content>
@if (state() === 'loading') { @if (state() === 'loading') {
<div class="status-copy" aria-live="polite"> <div class="status-panel loading" aria-live="polite">
<mat-progress-spinner mode="indeterminate" diameter="36"></mat-progress-spinner> <mat-progress-spinner mode="indeterminate" diameter="36"></mat-progress-spinner>
<div> <div>
<h2>Confirming reservation...</h2> <h2>Stiamo completando la tua conferma...</h2>
<p>Please wait while we validate your link.</p> <p>Un attimo ancora, stiamo verificando il link ricevuto via email.</p>
</div> </div>
</div> </div>
} }
@if (state() === 'success' && confirmation()) { @if (state() === 'success' && confirmation()) {
<div class="status-copy success" aria-live="polite"> <div class="status-panel success" aria-live="polite">
<mat-icon>check_circle</mat-icon> <div class="status-icon">
<mat-icon fontSet="material-symbols-outlined">verified</mat-icon>
</div>
<div> <div>
<h2>Reservation confirmed</h2> <h2>I tuoi posti sono confermati</h2>
<p>Your seats are confirmed. Present this QR code at check-in.</p> <p>Perfetto: la prenotazione e' andata a buon fine. Tieni questo QR code a portata di mano e mostralo all'ingresso quando arrivi.</p>
<div class="success-points">
<span><mat-icon fontSet="material-symbols-outlined">qr_code_2</mat-icon> QR pronto da mostrare</span>
<span><mat-icon fontSet="material-symbols-outlined">theater_comedy</mat-icon> Ti aspettiamo in sala</span>
</div>
</div> </div>
</div> </div>
@if (confirmation()!.qr_code_image) { @if (confirmation()!.qr_code_image) {
<div class="qr-panel"> <div class="qr-panel">
<img [src]="confirmation()!.qr_code_image" alt="Reservation QR code" /> <p class="panel-label">Il tuo QR code di ingresso</p>
<img [src]="confirmation()!.qr_code_image" alt="QR code della prenotazione" />
</div> </div>
} }
@if (confirmation()!.qr_code_url) { @if (confirmation()!.qr_code_url) {
<p class="meta">Check-in URL: <a [href]="confirmation()!.qr_code_url">{{ confirmation()!.qr_code_url }}</a></p> <div class="meta-card">
<mat-icon fontSet="material-symbols-outlined">link</mat-icon>
<p>Link di accesso: <a [href]="confirmation()!.qr_code_url">{{ confirmation()!.qr_code_url }}</a></p>
</div>
} }
<div class="next-steps">
<div>
<p class="step-label">Cosa fare adesso</p>
<p>Conserva il QR code sul telefono oppure stampalo. All'ingresso bastera' mostrarlo allo staff.</p>
</div>
<div>
<p class="step-label">Ti serve di nuovo il link?</p>
<p>Tieni l'email di conferma: potrai riaprire questa pagina in qualsiasi momento se vorrai recuperare il QR.</p>
</div>
</div>
} }
@if (state() === 'invalid') { @if (state() === 'invalid') {
<div class="status-copy" aria-live="assertive"> <div class="status-panel error" aria-live="assertive">
<mat-icon>error</mat-icon> <div class="status-icon">
<mat-icon fontSet="material-symbols-outlined">error</mat-icon>
</div>
<div> <div>
<h2>Invalid confirmation link</h2> <h2>Link di conferma non valido</h2>
<p>This token is not valid. Please use the latest email confirmation link.</p> <p>Questo link non risulta valido. Ti consigliamo di usare l'ultimo messaggio ricevuto via email.</p>
</div> </div>
</div> </div>
} }
@if (state() === 'expired') { @if (state() === 'expired') {
<div class="status-copy" aria-live="assertive"> <div class="status-panel warning" aria-live="assertive">
<mat-icon>schedule</mat-icon> <div class="status-icon">
<mat-icon fontSet="material-symbols-outlined">schedule</mat-icon>
</div>
<div> <div>
<h2>Confirmation link expired</h2> <h2>Link di conferma scaduto</h2>
<p>This link has expired. Please create a new reservation.</p> <p>Il link che hai aperto non e' piu' attivo. Ti chiediamo di creare una nuova prenotazione.</p>
</div> </div>
</div> </div>
} }
@if (state() === 'error') { @if (state() === 'error') {
<div class="status-copy" aria-live="assertive"> <div class="status-panel error" aria-live="assertive">
<mat-icon>warning</mat-icon> <div class="status-icon">
<mat-icon fontSet="material-symbols-outlined">warning</mat-icon>
</div>
<div> <div>
<h2>Could not confirm reservation</h2> <h2>Non siamo riusciti a completare la conferma</h2>
<p>Please try again in a moment.</p> <p>Riprova tra qualche istante: il tuo link potrebbe avere bisogno di un nuovo tentativo.</p>
</div> </div>
</div> </div>
} }
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions>
<a mat-button routerLink="/">Home</a> <a mat-button routerLink="/">Inizio</a>
<a mat-button routerLink="/shows">Shows</a> <a mat-button routerLink="/shows">Spettacoli</a>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
</section> </section>
`, `,
styles: [` styles: [`
.page {
max-width: 760px;
margin: 0 auto;
}
.page-header { .page-header {
margin-bottom: 24px; margin-bottom: 24px;
} }
.eyebrow { .supporting {
margin: 0 0 10px; max-width: 58ch;
color: var(--azionelab-accent); margin: 14px 0 0;
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
} }
.status-card { .status-card {
border-radius: 8px; border-radius: var(--azionelab-radius-lg);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: var(--azionelab-surface-strong);
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
overflow: hidden;
} }
.status-copy { mat-card-content {
padding: 28px !important;
}
mat-card-actions {
padding: 0 28px 24px !important;
gap: 8px;
}
.status-panel {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 14px; gap: 16px;
padding: 20px;
border-radius: 18px;
border: 1px solid transparent;
} }
.status-copy h2 { .status-panel h2 {
margin: 0 0 6px; margin: 0 0 6px;
font-size: 1.2rem;
} }
.status-copy p { .status-panel p {
margin: 0; margin: 0;
color: var(--azionelab-muted); color: var(--azionelab-muted);
line-height: 1.5; line-height: 1.5;
} }
.status-copy.success mat-icon { .status-panel.loading {
color: #2e7d32; background: rgba(159, 47, 40, 0.04);
border-color: rgba(159, 47, 40, 0.1);
}
.status-panel.success {
background: var(--azionelab-success-bg);
border-color: var(--azionelab-success-border);
}
.status-panel.warning {
background: #fff7ea;
border-color: rgba(181, 126, 0, 0.15);
}
.status-panel.error {
background: var(--azionelab-error-bg);
border-color: var(--azionelab-error-border);
}
.status-icon {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 16px;
flex: 0 0 auto;
background: rgba(30, 27, 24, 0.06);
}
.status-panel.success .status-icon {
background: rgba(46, 125, 50, 0.12);
}
.status-panel.warning .status-icon {
background: rgba(181, 126, 0, 0.14);
}
.status-panel.error .status-icon {
background: rgba(179, 38, 30, 0.12);
}
.status-panel.success .status-icon mat-icon {
color: var(--azionelab-success-ink);
}
.status-panel.warning .status-icon mat-icon {
color: #9b6c00;
}
.status-panel.error .status-icon mat-icon {
color: var(--azionelab-error-ink);
}
.success-points {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.success-points span {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: var(--azionelab-success-ink);
font-size: 0.92rem;
}
.success-points mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
} }
.qr-panel { .qr-panel {
margin-top: 18px; margin-top: 18px;
padding: 14px; padding: 16px;
border-radius: 8px; border-radius: var(--azionelab-radius-md);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
display: inline-block; display: inline-block;
background: white; background: white;
} }
.panel-label {
margin: 0 0 12px;
font-size: 0.88rem;
font-weight: 700;
color: var(--azionelab-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.qr-panel img { .qr-panel img {
width: min(280px, 100%); width: min(280px, 100%);
height: auto; height: auto;
display: block; display: block;
} }
.meta { .meta-card {
margin: 14px 0 0; display: flex;
word-break: break-word; align-items: flex-start;
gap: 10px;
margin-top: 16px;
padding: 14px 16px;
border-radius: var(--azionelab-radius-md);
background: rgba(159, 47, 40, 0.05);
color: var(--azionelab-muted); color: var(--azionelab-muted);
} }
.meta-card p {
margin: 0;
word-break: break-word;
}
.meta-card a {
color: var(--azionelab-accent-strong);
}
.next-steps {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 18px;
}
.next-steps > div {
padding: 16px;
border-radius: var(--azionelab-radius-md);
background: rgba(34, 28, 24, 0.035);
border: 1px solid var(--azionelab-border);
}
.step-label {
margin: 0 0 6px !important;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--azionelab-accent);
}
.next-steps p {
margin: 0;
line-height: 1.55;
color: var(--azionelab-muted);
}
@media (max-width: 640px) {
mat-card-content {
padding: 22px !important;
}
mat-card-actions {
padding: 0 22px 20px !important;
}
.status-panel {
padding: 18px;
border-radius: 16px;
}
.qr-panel {
width: 100%;
}
.qr-panel img {
width: min(100%, 280px);
margin: 0 auto;
}
.next-steps {
grid-template-columns: 1fr;
}
}
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
@@ -25,7 +25,7 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
@if (isLoading()) { @if (isLoading()) {
<div class="status-panel" aria-live="polite"> <div class="status-panel" aria-live="polite">
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner> <mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
<p>Loading show details...</p> <p>Caricamento dei dettagli dello spettacolo...</p>
</div> </div>
} @else if (errorMessage()) { } @else if (errorMessage()) {
<mat-card class="status-card" aria-live="assertive"> <mat-card class="status-card" aria-live="assertive">
@@ -33,30 +33,41 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
<div class="status-copy"> <div class="status-copy">
<mat-icon>error</mat-icon> <mat-icon>error</mat-icon>
<div> <div>
<h1>Could not load this show</h1> <h1>Non siamo riusciti a caricare questo spettacolo</h1>
<p>{{ errorMessage() }}</p> <p>{{ errorMessage() }}</p>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions>
<button mat-flat-button type="button" (click)="reload()">Try again</button> <button mat-flat-button type="button" (click)="reload()">Riprova</button>
<a mat-button routerLink="/shows">Back to shows</a> <a mat-button routerLink="/shows">Torna agli spettacoli</a>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
} @else if (show()) { } @else if (show()) {
<header class="page-header"> <header class="page-header">
<p class="eyebrow">Show detail</p> <div class="hero-copy">
<p class="eyebrow">Scheda spettacolo</p>
<h1>{{ show()!.title }}</h1> <h1>{{ show()!.title }}</h1>
<p class="supporting">{{ show()!.description || show()!.summary }}</p> <p class="supporting">{{ show()!.description || show()!.summary }}</p>
<div class="hero-note">
<span>Programmazione pubblica</span>
<span>Scegli una data per continuare</span>
</div>
</div>
@if (show()!.image_url) {
<div class="hero-image-wrap">
<img class="hero-image" [src]="show()!.image_url" [alt]="show()!.title" />
</div>
}
</header> </header>
<section class="section"> <section class="section">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>Upcoming performances</h2> <h2>Prossime repliche</h2>
<p>Choose a performance to continue to the booking placeholder.</p> <p>Scegli una data per proseguire verso la prenotazione.</p>
</div> </div>
<a mat-button routerLink="/shows">Back to show list</a> <a mat-button routerLink="/shows">Torna all'elenco</a>
</div> </div>
@if (performances().length === 0) { @if (performances().length === 0) {
@@ -65,8 +76,8 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
<div class="status-copy"> <div class="status-copy">
<mat-icon>theaters</mat-icon> <mat-icon>theaters</mat-icon>
<div> <div>
<h2>No performances published yet</h2> <h2>Nessuna replica pubblicata per ora</h2>
<p>This show is online, but there are no upcoming performances available right now.</p> <p>Lo spettacolo e' online, ma al momento non ci sono date disponibili.</p>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>
@@ -80,24 +91,24 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
<mat-card-content> <mat-card-content>
<dl class="performance-meta"> <dl class="performance-meta">
<div> <div>
<dt>Venue</dt> <dt>Spazio</dt>
<dd>{{ performance.venue.name }}</dd> <dd>{{ performance.venue.name }}</dd>
</div> </div>
<div> <div>
<dt>City</dt> <dt>Citta'</dt>
<dd>{{ performance.venue.city }}</dd> <dd>{{ performance.venue.city }}</dd>
</div> </div>
<div> <div>
<dt>Available seats</dt> <dt>Posti disponibili</dt>
<dd>{{ performance.available_seats }}</dd> <dd>{{ performance.available_seats }}</dd>
</div> </div>
</dl> </dl>
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions>
@if (performance.booking_enabled) { @if (performance.booking_enabled) {
<a mat-flat-button [routerLink]="['/performances', performance.id, 'book']">Book this performance</a> <a mat-flat-button [routerLink]="['/performances', performance.id, 'book']">Prenota questa data</a>
} @else { } @else {
<button mat-stroked-button type="button" disabled>Booking unavailable</button> <button mat-stroked-button type="button" disabled>Prenotazione non disponibile</button>
} }
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
@@ -109,33 +120,58 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
</section> </section>
`, `,
styles: [` styles: [`
.page {
max-width: 1080px;
margin: 0 auto;
}
.page-header { .page-header {
margin-bottom: 28px; display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 420px);
gap: 28px;
align-items: start;
margin-bottom: 32px;
} }
.eyebrow { .hero-copy {
margin: 0 0 10px; min-width: 0;
color: var(--azionelab-accent); padding: 8px 0;
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.2rem);
} }
.supporting { .supporting {
margin: 14px 0 0; margin: 14px 0 0;
color: var(--azionelab-muted); }
line-height: 1.6;
max-width: 64ch; .hero-note {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 18px;
}
.hero-note span {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 14px;
border-radius: 999px;
background: rgba(143, 51, 45, 0.08);
color: var(--azionelab-accent-strong);
font-size: 0.85rem;
font-weight: 700;
}
.hero-image-wrap {
overflow: hidden;
border-radius: var(--azionelab-radius-lg);
border: 1px solid var(--azionelab-border);
background:
linear-gradient(135deg, rgba(207, 71, 51, 0.16), rgba(15, 22, 36, 0.08)),
#f8f1ea;
box-shadow: var(--azionelab-shadow-strong);
aspect-ratio: 4 / 4.8;
}
.hero-image {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
} }
.section { .section {
@@ -152,30 +188,39 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
.section-heading h2 { .section-heading h2 {
margin: 0 0 6px; margin: 0 0 6px;
font-size: 1.4rem;
}
.section-heading p {
margin: 0;
color: var(--azionelab-muted);
} }
.performance-grid { .performance-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px; gap: 22px;
} }
.performance-card, .performance-card,
.status-card { .status-card {
border-radius: 8px; border-radius: var(--azionelab-radius-md);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: linear-gradient(180deg, rgba(255, 253, 249, 0.98), rgba(250, 243, 233, 0.94));
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
.performance-card { .performance-card {
min-height: 260px; min-height: 280px;
}
.performance-card mat-card-title,
.performance-card mat-card-subtitle,
.performance-card mat-card-content,
.performance-card mat-card-actions {
padding-left: 18px;
padding-right: 18px;
}
.performance-card mat-card-title {
margin-top: 14px;
font-family: var(--azionelab-serif);
font-size: 1.28rem;
line-height: 1.15;
} }
.performance-meta { .performance-meta {
@@ -231,6 +276,10 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
} }
@media (max-width: 860px) { @media (max-width: 860px) {
.page-header {
grid-template-columns: 1fr;
}
.section-heading { .section-heading {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
@@ -261,7 +310,7 @@ export class ShowDetailPlaceholderPageComponent {
const slug = this.route.snapshot.paramMap.get('slug'); const slug = this.route.snapshot.paramMap.get('slug');
if (!slug) { if (!slug) {
this.errorMessage.set('The requested show is missing a valid identifier.'); this.errorMessage.set('Lo spettacolo richiesto non ha un identificativo valido.');
this.show.set(null); this.show.set(null);
this.performances.set([]); this.performances.set([]);
this.isLoading.set(false); this.isLoading.set(false);
@@ -293,7 +342,7 @@ export class ShowDetailPlaceholderPageComponent {
error: () => { error: () => {
this.show.set(null); this.show.set(null);
this.performances.set([]); this.performances.set([]);
this.errorMessage.set('Please try again in a moment.'); this.errorMessage.set('Riprova tra qualche istante.');
this.isLoading.set(false); this.isLoading.set(false);
}, },
}); });
@@ -15,18 +15,18 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
<section class="page"> <section class="page">
<header class="page-header"> <header class="page-header">
<div> <div>
<p class="eyebrow">Public shows</p> <p class="eyebrow">Programmazione</p>
<h1>Shows</h1> <h1>Spettacoli</h1>
</div> </div>
<p class="supporting"> <p class="supporting">
Browse current productions published from the AzioneLab backend. Scopri le produzioni di AzioneLab, apri la scheda di ogni spettacolo e avvicinati alla prenotazione con semplicità.
</p> </p>
</header> </header>
@if (isLoading()) { @if (isLoading()) {
<div class="status-panel" aria-live="polite"> <div class="status-panel" aria-live="polite">
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner> <mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
<p>Loading shows...</p> <p>Caricamento degli spettacoli in corso...</p>
</div> </div>
} @else if (errorMessage()) { } @else if (errorMessage()) {
<mat-card class="status-card" aria-live="assertive"> <mat-card class="status-card" aria-live="assertive">
@@ -34,13 +34,13 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
<div class="status-copy"> <div class="status-copy">
<mat-icon>error</mat-icon> <mat-icon>error</mat-icon>
<div> <div>
<h2>Could not load shows</h2> <h2>Non siamo riusciti a caricare gli spettacoli</h2>
<p>{{ errorMessage() }}</p> <p>{{ errorMessage() }}</p>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions>
<button mat-flat-button type="button" (click)="reload()">Try again</button> <button mat-flat-button type="button" (click)="reload()">Riprova</button>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
} @else if (shows().length === 0) { } @else if (shows().length === 0) {
@@ -49,8 +49,8 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
<div class="status-copy"> <div class="status-copy">
<mat-icon>theaters</mat-icon> <mat-icon>theaters</mat-icon>
<div> <div>
<h2>No shows published yet</h2> <h2>Nessuno spettacolo pubblicato per ora</h2>
<p>Published productions will appear here as soon as they are available.</p> <p>Le produzioni disponibili compariranno qui non appena saranno online.</p>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>
@@ -59,12 +59,20 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
<div class="show-grid"> <div class="show-grid">
@for (show of shows(); track show.slug) { @for (show of shows(); track show.slug) {
<mat-card class="show-card"> <mat-card class="show-card">
@if (show.image_url) {
<div class="show-image-wrap">
<img class="show-image" [src]="show.image_url" [alt]="show.title" />
</div>
}
<div class="card-topline">
<span class="card-label">Spettacolo in evidenza</span>
</div>
<mat-card-title>{{ show.title }}</mat-card-title> <mat-card-title>{{ show.title }}</mat-card-title>
<mat-card-content> <mat-card-content>
<p>{{ show.summary }}</p> <p>{{ show.summary }}</p>
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions>
<a mat-button [routerLink]="['/shows', show.slug]">Open detail</a> <a mat-button [routerLink]="['/shows', show.slug]">Apri scheda</a>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
} }
@@ -73,42 +81,23 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
</section> </section>
`, `,
styles: [` styles: [`
.page {
max-width: 1180px;
margin: 0 auto;
}
.page-header { .page-header {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 380px); grid-template-columns: minmax(0, 1fr) minmax(260px, 410px);
gap: 24px; gap: 24px;
align-items: end; align-items: end;
margin-bottom: 24px; margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
} }
.supporting { .supporting {
margin: 0; margin: 0;
color: var(--azionelab-muted); max-width: 34ch;
line-height: 1.6;
} }
.show-grid { .show-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px; gap: 22px;
} }
.status-panel, .status-panel,
@@ -126,9 +115,9 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
.status-card { .status-card {
max-width: 680px; max-width: 680px;
border-radius: 8px; border-radius: var(--azionelab-radius-md);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: var(--azionelab-surface-strong);
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
@@ -150,20 +139,74 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
.show-card { .show-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 220px; min-height: 360px;
border-radius: 8px; overflow: hidden;
border-radius: var(--azionelab-radius-md);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: linear-gradient(180deg, rgba(255, 253, 249, 0.98), rgba(250, 243, 233, 0.94));
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
.show-image-wrap {
aspect-ratio: 15 / 10;
overflow: hidden;
border-bottom: 1px solid var(--azionelab-border);
background:
linear-gradient(135deg, rgba(207, 71, 51, 0.14), rgba(15, 22, 36, 0.06)),
#f8f1ea;
}
.show-image {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
transition: transform 180ms ease;
}
.show-card:hover .show-image {
transform: scale(1.03);
}
.card-topline {
padding: 18px 18px 0;
}
.card-label {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
background: rgba(143, 51, 45, 0.08);
color: var(--azionelab-accent-strong);
font-size: 0.78rem;
font-weight: 700;
}
.show-card mat-card-content { .show-card mat-card-content {
flex: 1; flex: 1;
} }
.show-card mat-card-title,
.show-card mat-card-content,
.show-card mat-card-actions {
padding-left: 18px;
padding-right: 18px;
}
.show-card mat-card-title {
margin-top: 10px;
font-family: var(--azionelab-serif);
font-size: 1.45rem;
font-weight: 700;
line-height: 1.12;
}
.show-card p { .show-card p {
color: var(--azionelab-muted); color: var(--azionelab-muted);
line-height: 1.6; line-height: 1.6;
margin: 0;
} }
@media (max-width: 860px) { @media (max-width: 860px) {
@@ -203,7 +246,7 @@ export class ShowListPageComponent {
}, },
error: () => { error: () => {
this.shows.set([]); this.shows.set([]);
this.errorMessage.set('Please try again in a moment.'); this.errorMessage.set('Riprova tra qualche istante.');
this.isLoading.set(false); this.isLoading.set(false);
}, },
}); });
+37 -3
View File
@@ -1,5 +1,5 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { API_BASE_URL } from './api-config.token'; import { API_BASE_URL } from './api-config.token';
@@ -9,6 +9,7 @@ export type ShowListItem = {
title: string; title: string;
slug: string; slug: string;
summary: string; summary: string;
image_url: string;
poster_image: string; poster_image: string;
}; };
@@ -114,10 +115,43 @@ export class ShowsApiService {
} }
previewCheckIn(token: string): Observable<CheckInPreviewResponse> { previewCheckIn(token: string): Observable<CheckInPreviewResponse> {
return this.http.post<CheckInPreviewResponse>(`${this.apiBaseUrl}/check-ins/preview/`, { token }); return this.http.post<CheckInPreviewResponse>(
`${this.apiBaseUrl}/check-ins/preview/`,
{ token },
this.buildStaffRequestOptions(),
);
} }
confirmCheckIn(token: string): Observable<CheckInConfirmResponse> { confirmCheckIn(token: string): Observable<CheckInConfirmResponse> {
return this.http.post<CheckInConfirmResponse>(`${this.apiBaseUrl}/check-ins/confirm/`, { token }); return this.http.post<CheckInConfirmResponse>(
`${this.apiBaseUrl}/check-ins/confirm/`,
{ token },
this.buildStaffRequestOptions(),
);
}
private buildStaffRequestOptions(): { headers?: HttpHeaders; withCredentials: true } {
const csrfToken = this.readCookie('csrftoken');
return {
withCredentials: true,
headers: csrfToken ? new HttpHeaders({ 'X-CSRFToken': csrfToken }) : undefined,
};
}
private readCookie(name: string): string {
if (typeof document === 'undefined' || !document.cookie) {
return '';
}
const cookiePrefix = `${name}=`;
for (const cookie of document.cookie.split(';')) {
const trimmedCookie = cookie.trim();
if (trimmedCookie.startsWith(cookiePrefix)) {
return decodeURIComponent(trimmedCookie.slice(cookiePrefix.length));
}
}
return '';
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 220" role="img" aria-labelledby="title desc">
<title id="title">AzioneLab</title>
<desc id="desc">Logo AzioneLab con payoff e direzione artistica.</desc>
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#c54f41"/>
<stop offset="100%" stop-color="#7f251f"/>
</linearGradient>
</defs>
<rect x="12" y="18" width="120" height="120" rx="28" fill="url(#bg)"/>
<path d="M46 109L74 48h17l28 61h-17l-5-12H67l-5 12H46zm27-26h18L82 60 73 83z" fill="#fff7f0"/>
<text x="160" y="78" fill="#221c18" font-size="52" font-weight="700" font-family="Georgia, 'Times New Roman', serif">AzioneLab</text>
<text x="160" y="122" fill="#5f5650" font-size="24" font-family="Arial, Helvetica, sans-serif">Laboratori teatrali &amp; produzioni audio/visive</text>
<text x="160" y="158" fill="#8f332d" font-size="20" font-family="Arial, Helvetica, sans-serif">Direzione artistica a cura di Ernesto Estatico</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+7 -1
View File
@@ -1,10 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="it">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>AzioneLab</title> <title>AzioneLab</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Manrope:wght@400;500;600;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,500,0,0"
rel="stylesheet"
>
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
+140 -13
View File
@@ -1,12 +1,36 @@
:root { :root {
--azionelab-bg: #f3eee6; --azionelab-bg: #f5efe6;
--azionelab-surface: rgba(255, 255, 255, 0.78); --azionelab-bg-strong: #efe4d4;
--azionelab-ink: #1e1b18; --azionelab-surface: rgba(255, 251, 246, 0.84);
--azionelab-muted: #645b53; --azionelab-surface-strong: rgba(255, 252, 248, 0.96);
--azionelab-accent: #9f2f28; --azionelab-surface-dark: #342924;
--azionelab-accent-strong: #7f211c; --azionelab-ink: #221c18;
--azionelab-border: rgba(30, 27, 24, 0.12); --azionelab-ink-soft: #3a302a;
--azionelab-shadow: 0 18px 48px rgba(46, 28, 18, 0.12); --azionelab-muted: #6f6258;
--azionelab-accent: #8f332d;
--azionelab-accent-strong: #6e251f;
--azionelab-accent-soft: #c88f64;
--azionelab-highlight: #c6a768;
--azionelab-border: rgba(34, 28, 24, 0.11);
--azionelab-border-strong: rgba(34, 28, 24, 0.18);
--azionelab-shadow: 0 18px 48px rgba(46, 28, 18, 0.10);
--azionelab-shadow-strong: 0 26px 64px rgba(46, 28, 18, 0.16);
--azionelab-radius-sm: 10px;
--azionelab-radius-md: 16px;
--azionelab-radius-lg: 24px;
--azionelab-shell-width: 1180px;
--azionelab-copy-width: 64ch;
--azionelab-section-gap: 28px;
--azionelab-sans: "Manrope", "Helvetica Neue", Arial, sans-serif;
--azionelab-serif: "Fraunces", Georgia, "Times New Roman", serif;
--azionelab-success-bg: #edf7ef;
--azionelab-success-ink: #1f5f2b;
--azionelab-success-border: rgba(46, 125, 50, 0.18);
--azionelab-error-bg: #fff3f1;
--azionelab-error-ink: #8b2a20;
--azionelab-error-border: rgba(179, 38, 30, 0.16);
--mdc-typography-brand-font-family: var(--azionelab-serif);
--mdc-typography-plain-font-family: var(--azionelab-sans);
} }
* { * {
@@ -16,18 +40,121 @@
html, body { html, body {
margin: 0; margin: 0;
min-height: 100%; min-height: 100%;
font-family: "Segoe UI", "Helvetica Neue", sans-serif; font-family: var(--azionelab-sans);
font-weight: 500;
color: var(--azionelab-ink); color: var(--azionelab-ink);
background: background:
radial-gradient(circle at top right, rgba(159, 47, 40, 0.12), transparent 28%), linear-gradient(180deg, rgba(143, 51, 45, 0.06), transparent 140px),
radial-gradient(circle at left center, rgba(140, 116, 86, 0.14), transparent 35%), linear-gradient(180deg, #faf5ee 0%, var(--azionelab-bg) 52%, #efe4d6 100%);
linear-gradient(180deg, #fbf7f2 0%, var(--azionelab-bg) 100%);
} }
body { body {
min-height: 100vh; min-height: 100vh;
} }
button, input, textarea { body,
p,
li,
dt,
dd,
label,
button,
input,
textarea,
select,
option,
a {
font-family: var(--azionelab-sans);
}
h1, h2, h3 {
font-family: var(--azionelab-serif);
font-weight: 600;
letter-spacing: 0;
color: var(--azionelab-ink);
}
h1 {
font-size: 2.65rem;
line-height: 1.04;
}
h2 {
font-size: 1.7rem;
line-height: 1.16;
}
h3 {
font-size: 1.2rem;
line-height: 1.24;
}
button, input, textarea, select {
font: inherit; font: inherit;
} }
.mat-mdc-button-base,
.mat-mdc-unelevated-button,
.mat-mdc-outlined-button,
.mat-mdc-button,
.mat-mdc-card,
.mat-mdc-form-field,
.mdc-button,
.mdc-text-field,
.mdc-floating-label,
.mdc-text-field__input {
font-family: var(--azionelab-sans) !important;
}
a {
color: var(--azionelab-accent-strong);
}
img {
max-width: 100%;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.page {
width: min(100%, var(--azionelab-shell-width));
margin: 0 auto;
}
.eyebrow {
margin: 0 0 12px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.12em;
}
.supporting {
color: var(--azionelab-muted);
font-size: 1.02rem;
line-height: 1.72;
max-width: var(--azionelab-copy-width);
}
@media (max-width: 900px) {
h1 {
font-size: 2.15rem;
}
h2 {
font-size: 1.45rem;
}
}
@media (max-width: 640px) {
h1 {
font-size: 1.9rem;
}
h2 {
font-size: 1.3rem;
}
}
+5
View File
@@ -10,6 +10,8 @@ services:
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS} DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS}
DJANGO_DEBUG: ${DJANGO_DEBUG:-false} DJANGO_DEBUG: ${DJANGO_DEBUG:-false}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SITE_BASE_URL: ${SITE_BASE_URL}
EMAIL_BACKEND: ${EMAIL_BACKEND}
TIME_ZONE: ${TIME_ZONE:-Europe/Rome} TIME_ZONE: ${TIME_ZONE:-Europe/Rome}
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
@@ -21,6 +23,7 @@ services:
- "${BACKEND_PORT:-8000}" - "${BACKEND_PORT:-8000}"
volumes: volumes:
- django_static:/app/staticfiles - django_static:/app/staticfiles
- django_media:/app/media
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -70,6 +73,7 @@ services:
volumes: volumes:
- ./nginx/templates:/etc/nginx/templates:ro - ./nginx/templates:/etc/nginx/templates:ro
- django_static:/var/www/static:ro - django_static:/var/www/static:ro
- django_media:/var/www/media:ro
depends_on: depends_on:
- backend - backend
- frontend - frontend
@@ -80,6 +84,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
django_static: django_static:
django_media:
networks: networks:
internal: internal:
+4
View File
@@ -5,6 +5,10 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location /assets/ {
try_files $uri =404;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
@@ -35,9 +35,9 @@ server {
} }
location /media/ { location /media/ {
proxy_pass http://azionelab_backend; alias /var/www/media/;
proxy_set_header Host $host; access_log off;
proxy_set_header X-Forwarded-Proto $scheme; expires 1d;
} }
location / { location / {