Jagoda Kuczkowska
Back to projects
2026Fullstack Developer

Cinema Booking System

A full-stack cinema reservation platform with a multi-step booking flow and automated seat-lock expiry, built with Angular 21 and Spring Boot.

Cinema Booking System
7 screenshots

Overview

Every cinema needs two things to work: a reliable way to sell tickets, and a guarantee that no two people end up in the same seat. This project started as a university assignment but quickly grew into a proper fullstack application that I wanted to build right - clean architecture, real state management, and all the edge cases that a real booking system has to handle.

The result is a Spring Boot REST API paired with an Angular 21 SPA that walks the user through a 4-step booking wizard: pick seats -> choose ticket types -> fill in contact details -> confirm. Behind the scenes, a scheduled cleanup job watches for abandoned reservations and releases the locked seats automatically after 15 minutes.


The Architecture & Tech Stack

Why Angular 21 + NgRx Signals?

I chose Angular for its strong typing story and the structure it forces on large-scale apps. For state management I went with NgRx Signals (the new signal-based store) instead of the classic NgRx. The booking flow involves a lot of derived state - total price, seat map with selected status, expiration countdown - and computed signals handle this elegantly without writing a single reducer or selector.

// Derived state that "just works" with computed()
isExpired: computed((): boolean => {
  const exp = expirationTime();
  return exp ? new Date().getTime() > new Date(exp).getTime() : false;
}),

Why Spring Boot?

The Java ecosystem gave me everything I needed out of the box: Spring Security for auth, Spring Data JPA for persistence, Bean Validation for DTO constraints, and @Scheduled for the cleanup job. No extra infrastructure required - the seat-lock expiry logic runs entirely inside the application process.

UI: PrimeNG + Tailwind CSS

PrimeNG 21 handled all the heavy UI components (data tables, dialogs, dropdowns), while Tailwind CSS handled layout and custom styling. The two coexist via the tailwindcss-primeui adapter, which prevents layer conflicts.

i18n: ngx-translate

The app supports both English and Polish out of the box via @ngx-translate/core with JSON translation files loaded lazily by the HTTP loader.


Key Features

Booking Flow

  • 4-step reservation wizard - Seats -> Ticket Types -> Contact Details -> Summary & Confirmation
  • Each step is a separate lazy-loaded route, guarded so users can't skip ahead
  • The booking store tracks the full state across all steps without any prop drilling

Seat Map

  • Visual, interactive seat grid grouped by rows
  • Real-time seat status: available / selected / already taken
  • Instant computed price updates as the user selects or deselects seats

Reservation Expiry

  • A 15-minute countdown starts the moment seats are locked
  • The Angular store exposes an isExpired computed signal - the UI reacts instantly when the time runs out
  • The Spring Boot backend runs a @Scheduled job every 60 seconds that finds all OCZEKUJE (pending) bookings past their expirationTime and flips them to ANULOWANA (cancelled), releasing the seats

Authentication & Authorization

  • Spring Security with session-based auth
  • Role-based access: USER can book and view their history; ADMIN gets a full management panel
  • Angular route guards (auth.guard.ts, admin.guard.ts) and an active-account guard that blocks suspended users before they can waste a reservation attempt

Admin Panel

  • Manage movies, screenings, and theater rooms
  • Browse all bookings with pagination and filters
  • Audit log with LogType categorization
  • Statistics dashboard powered by Chart.js

Ticket Pricing

  • Configurable ticket types (standard, reduced, etc.) with dynamic price lookup
  • Prices are fetched once and stored in the signal store - the total price recomputes reactively on every selection change

The Challenge & Solution

The Problem: Seats That Never Come Back

The hardest part wasn't building the booking form - it was making sure the system stayed consistent when people abandon their reservations halfway through.

Imagine this: a user picks 3 seats, gets distracted, closes the tab. Those seats are now "locked" in the database with status OCZEKUJE. Without cleanup, they'd stay blocked forever, making the cinema lose revenue and frustrating other users.

The tricky part was handling this on both sides of the stack simultaneously:

  1. Frontend - the user sees a countdown timer. When isExpired flips to true, the UI needs to immediately block further progress and show an appropriate message - without a page reload.

  2. Backend - the authoritative cleanup can't rely on the frontend. A user could manipulate the timer or simply lose connectivity. The backend has to be the source of truth.

The Solution: Dual-Layer Expiry

On the backend, I implemented BookingCleanupService - a @Scheduled Spring service that runs every 60 seconds and cancels all bookings where status = OCZEKUJE AND expirationTime < NOW(). It's transactional, logged, and completely independent of the frontend.

@Scheduled(fixedRate = 60000)
@Transactional
public void cleanupExpiredBookings() {
    List<Booking> expired = bookingRepository
        .findExpiredBookings(BookingStatus.OCZEKUJE, LocalDateTime.now());

    expired.forEach(booking -> {
        booking.setStatus(BookingStatus.ANULOWANA);
        bookingRepository.save(booking);
    });
}

On the frontend, the BookingStore stores the expirationTime and exposes an isExpired computed signal. Angular's change detection picks up the signal automatically - no polling, no setInterval hacks.

The beauty of this approach: even if the frontend timer had a bug, the backend cleanup would still kick in within a minute. Defense in depth, applied to a booking system.

The Multi-Step Flow

Splitting the booking into 4 steps introduced a different kind of complexity: state that needs to survive navigation. I solved this with a single BookingStore provided at the router level. Each step reads from and writes to the same store instance, so moving between steps is seamless and the store can be reset cleanly when the booking is completed or cancelled.


Lessons Learned

I love NgRx Signals. Computed signals replacing selectors, rxMethod handling side effects - much less boilerplate for the same guarantees.

Scheduled jobs need to be tested separately. The @Scheduled cleanup service was easy to write but tricky to verify - I had to write dedicated service tests with mocked repositories to make sure the query and the status update logic were correct, rather than relying on integration tests alone.

Defensive architecture > trusting the client. The biggest architectural lesson: never let the frontend be the only thing enforcing a business rule. The 15-minute expiry is a perfect example. The UI timer is UX; the scheduled job is correctness.

Monorepo structure for a split repo project. Keeping the frontend and backend in separate repositories felt natural at the start, but over time it made it harder to track. Next time, I'd consider a monorepo from the start.

Tech Stack

AngularSpring BootNgRx SignalsPrimeNGTailwind CSSSpring SecurityJavaTypeScript