SplitApp — Vue 3 frontend
Frontend (separate client app) for the SplitApp travel planning and shared expense management system. Built with Vue 3 + TypeScript + Vite, talks to the ASP.NET Core backend over REST with JWT + refresh token authentication.
This repo satisfies the "Separate client app" requirements of the TalTech Personal Project assignment — see Assignment compliance below.
Features
- Authentication — register, login, logout, JWT + refresh token flow with auto-refresh on 401
- Trips — create, view, edit, delete trips with destination, dates, currency (
views/trips) - Members & invitations — invite travel companions by token, accept/decline/revoke (
views/members,views/invitations) - Expenses — record shared costs with four split methods (see below), select any trip member as the payer (not just the logged-in user), and see totals summed in the trip's default currency even when individual expenses were entered in other currencies (
views/expenses,components/SplitMethodSelector.vue) - Budget categories — categorize expenses, track spent vs planned (
views/budget-categories) - Wishlist — trip activities and destinations with group voting and completion tracking (
views/wishlist) - Polls — group decision-making with single/multi vote and close-poll workflow (
views/polls) - Settlements — real-time balance tracking with suggested payments preview; organizer finalizes the trip (
Active → Finalizing) to lock in the settlement plan, then participants complete the mark-paid / confirm-paid workflow — trip auto-advances toSettledonce every recipient has confirmed. The Mark Paid button appears only for the payer of a row and Confirm only for the payee; other viewers see an "Awaiting Confirmation" / "Pending" badge. Reopen is available to the organizer while the plan is not yetCompleted(views/settlements) - Multi-currency support (
services/CurrencyService.ts)
Expense split methods
The SplitMethodSelector component supports four validated split strategies, matching the backend's ESplitMethod enum:
| Method | Behavior |
|---|---|
EqualAll |
Total divided equally among every trip member |
EqualSubset |
User picks a subset of members; total divided equally among picked |
ExactAmounts |
Per-member fixed amount input; must sum exactly to total |
Percentages |
Per-member percentage input; must sum to exactly 100% |
Each method is validated live and blocks the save button when invalid.
Tech stack
- Vue 3.5 (Composition API) + TypeScript 6
- Vite 8 (dev server + production build)
- Vue Router 5 — nested routes with an auth navigation guard
- Pinia 3 — auth store (JWT, refresh token, user name) with localStorage sync
- Axios — shared
httpClient.tswith request/response interceptors - Bootstrap 5 + custom coral/teal CSS variable theme
- Vitest 4 +
@vue/test-utils+ MSW — unit & integration tests - Playwright — browser-based end-to-end tests
- ESLint (flat config) + Oxlint + Prettier
Project structure
src/
├── components/ # SplitMethodSelector, ToastContainer
├── composables/ # useToast — global toast system
├── directives/ # v-animate — IntersectionObserver scroll animations
├── router/ # vue-router config + auth guard
├── services/ # 9 axios-based API clients, one per resource
│ ├── httpClient.ts # shared axios instance with JWT interceptors
│ ├── AccountService.ts
│ ├── TripService.ts
│ ├── ExpenseService.ts
│ ├── BudgetCategoryService.ts
│ ├── CurrencyService.ts
│ ├── PollService.ts
│ ├── WishlistService.ts
│ ├── SettlementService.ts
│ └── InvitationService.ts
├── stores/
│ └── auth.ts # jwt, refreshToken, userName, isAuthenticated
├── types/ # TypeScript contracts mirroring backend DTOs
├── utils/ # formatCurrency, parseJwt
├── views/ # Pages (HomeView, LoginView, RegisterView, trips/, expenses/, ...)
├── App.vue # Root layout (navbar + router-view + toasts)
└── main.ts
e2e/ # Playwright tests
src/__tests__/
├── unit/ # Vitest unit tests
├── integration/ # Vitest + MSW integration tests
└── vitest.setup.ts
Requirements
- Node.js
^20.19.0 || >=22.12.0 - npm
- A running instance of the SplitApp backend (
splitapp-backend-clean-onion) on the URL configured in.env
Setup
Install dependencies:
npm installCreate a
.envfile at the project root:VITE_API_BASE_URL=http://localhost:90/api/v1/Use
http://localhost:5086/api/v1/if you run the backend locally viadotnet runinstead of Docker.Start the backend (separate repo):
cd ../splitapp-backend-clean-onion docker compose up -dRun the frontend:
npm run dev
Scripts
npm run dev # Vite dev server (http://localhost:5173)
npm run build # Type-check + production build
npm run preview # Preview the production bundle
npm run test:unit # Vitest unit + integration tests (watch mode)
npm run test:unit -- --run # One-off CI-style run
npm run test:e2e # Playwright end-to-end tests (needs backend running)
npm run test:e2e:ui # Playwright interactive UI mode
npm run lint # Oxlint + ESLint with --fix
npm run type-check # vue-tsc strict type check
npm run format # Prettier
Testing
The project has three testing layers — 39 tests in 7 files, all green against the phase 3 modular monolith backend.
| Layer | Files | Tests | Backend? | Runtime |
|---|---|---|---|---|
| Unit | 4 | 31 | No (pure functions / isolated component) | ~0.4s |
| Integration | 1 | 4 | No (MSW mocks the network at the HTTP layer) | ~0.5s |
| E2E | 2 | 4 | Yes (real Chromium, real backend) | ~11s |
Unit (npm run test:unit)
Vitest + jsdom. Each file targets one tightly-scoped piece of the codebase:
| File | What it covers | Why it matters |
|---|---|---|
formatCurrency.spec.ts |
sign / symbol / decimals / thousands separators | every page renders amounts — a regression here visually breaks the whole app |
parseJwt.spec.ts |
base64url decoding (+/ → -_), ASP.NET nameidentifier claim with sub fallback |
wrong user-id parsing silently mis-attributes expenses to the wrong user |
auth-store.spec.ts |
Pinia store: localStorage hydration, isAuthenticated reactivity, logout(), watcher-based sync |
broken sync drifts store and localStorage apart → user sees stale identity after reload |
SplitMethodSelector.spec.ts |
all four split methods, live validation, edit-mode existingSplits pre-load |
most complex business logic on the front — invalid splits would let the user save a malformed expense |
Integration (npm run test:unit, in the same suite)
| File | What it covers |
|---|---|
token-refresh.spec.ts |
the most security-sensitive glue in the app — httpClient's 401 → refresh → retry pipeline, end to end. MSW intercepts at the network layer so axios behaves exactly as in production. Three scenarios: (1) successful refresh + replay, (2) refresh fails → logout + redirect to /login, (3) no refresh token present → straight to logout. |
End-to-end (npm run test:e2e)
Playwright drives a real Chromium browser. The webServer config auto-starts npm run dev for you, so you only need the backend reachable.
| File | What it covers |
|---|---|
auth.spec.ts |
login as seed user alice@taltech.ee, logout, wrong-password stays on /login, protected routes redirect anonymous users to /login |
trip-crud.spec.ts |
positive happy flow — login → create a uniquely-named trip → see it in the list → open Edit → rename → verify the rename persists. Delete is intentionally skipped because backend DeleteBehavior.Restrict blocks deleting a trip that has any participant (even the auto-added Organizer) — that's a backend issue, not a frontend one. |
How to run locally:
E2E hits the backend via whatever URL is in .env:
# Option A — against deployed phase 3 backend (no local docker needed)
# .env already points at https://travel.rasmusj.com/api/v1/
npm run test:e2e
# Option B — against a local backend
cd ../splitapp-backend-clean-onion && docker compose up -d # backend on :90
# Override .env locally with VITE_API_BASE_URL=http://localhost:90/api/v1/
cd ../splitapp-frontend-vue
npm run test:e2e # headless
npm run test:e2e:ui # interactive UI mode for debugging
Note: the
trip-crudtest creates a real trip in whichever backend you point at. Against prod, that's a real row in the deployed database (it does not clean up).
Docker
The project ships with a multi-stage Dockerfile (Node build → Nginx serve) and a docker-compose.yml with two profiles.
Production (Nginx)
docker compose up --build
The backend URL is injected as a build arg (VITE_API_BASE_URL) so the same image can target different environments:
# Override at build time for a real deploy
VITE_API_BASE_URL=https://api.mydomain.com/api/v1/ docker compose up --build
Nginx is configured with an SPA fallback (try_files $uri $uri/ /index.html) so Vue Router's history mode works on direct URLs.
Port allocation
| Service | Host port | Notes |
|---|---|---|
| Frontend (prod, Nginx) | 91 | separate from backend |
| Backend API (Docker) | 90 | rasmju-csweb-phase3 |
Backend API (dotnet run) |
5086 / 7040 | http / https |
| PostgreSQL | 5432 |
CI/CD
Configured via .gitlab-ci.yml:
- deploy stage — on merges to
main, runsdocker compose -p rasmju-js-a7 up --build --remove-orphans --detachon a shared runner (deploys the Nginx production image) - Tests run locally before pushing — there is intentionally no test stage in the pipeline. Run
npm run test:unit -- --runandnpm run test:e2eon your machine before merging.
The frontend is hosted on a separate URL from the backend and relies on the backend's permissive CORS policy (AllowAnyOrigin in Program.cs).
Architecture notes
Authentication flow
LoginView→AccountService.loginAsync→ backend returns{ jwt, refreshToken, firstName, lastName }- Auth store saves them;
watchers mirror values tolocalStorage - Every outgoing request picks up
Authorization: Bearer <jwt>from the request interceptor - On
401, the response interceptor:- calls
AccountService.refreshTokenAsync(jwt, refreshToken) - on success: updates the store, replays the original request transparently
- on failure: calls
logoutAsync, clears the store, navigates to/login
- calls
logout()tears down store state and removes localStorage entries
Nested routing
/trips/:tripId acts as a parent route whose DetailView provides isOrganizer, currentUserId, tripStatus, and tripCurrencySymbol to child routes via Vue's provide/inject. Children use these to:
- Hide organizer-only actions (budget category CRUD, finalize/reopen trip)
- Hide creator-only actions (edit/delete own wishlist items and expenses); the edit/delete slot in the expense list reserves layout space even when hidden so amounts stay aligned in a single column
- Lock expense CRUD whenever trip status is not
Active(i.e.Finalizing,Settled, orArchived) - Show suggested payment previews while trip is
Active, and the real settlement plan once trip entersFinalizingorSettled; the header badge distinguishes the two states (warning hourglass vs. lock icon) - Hide payment action buttons from users who cannot act on a row — Mark Paid only for the payer, Confirm only for the payee (matching the BLL guards so the UI never surfaces a click that would silently 403)
Currency conversion
Expenses can be recorded in any currency (EUR, USD, GBP, SEK, NOK). To keep conversion rules in a single place, the frontend does not implement any currency math — it relies on the backend. The ExpenseDto returned by GET /api/v1/expenses/trip/{tripId} includes an amountInTripCurrency field that the backend pre-computes (SplitApp.Modules.Expenses.Application.CurrencyConverter in the modular monolith). List totals (expenses/IndexView.vue, trip dashboard) sum this field with e.amount as a fallback. Per-expense rows still display the original amount + currency symbol so the user sees what was actually entered.
Error handling
Services return an IResultObject<T> wrapper ({ data } | { errors: string[] }) instead of throwing. Views inspect .errors and show toast messages via the useToast composable.
Assignment compliance
This repository targets the "Separate client app" portion of the TalTech Personal Project Assignment 3.
| Requirement | Status | Where |
|---|---|---|
| Written in a chosen technology (react/angular/vue/blazor/…) | ✅ | Vue 3 + TypeScript |
| Uses own backend REST API | ✅ | VITE_API_BASE_URL=http://localhost:90/api/v1/ |
| JWT + refresh token authentication | ✅ | services/httpClient.ts, services/AccountService.ts, stores/auth.ts |
| Login / logout | ✅ | LoginView.vue, RegisterView.vue, App.vue |
| CRUD on ≥3 entities | ✅ (5) | Trips, Expenses, BudgetCategories, Wishlist, Polls |
| CI/CD deploy — client hosted on a separate URL from backend | ✅ | .gitlab-ci.yml, separate Docker container on port 91 |
| CORS handling | ✅ | Backend CorsAllowAll policy; frontend on separate origin |
| Bonus: full unit + integration + e2e test coverage | ✅ | Vitest + MSW + Playwright — 39 tests, all green against phase 3 backend |
Seed users
The backend seeds five demo users on first startup (if DataInitialization:SeedIdentity=true). Password for all of them is Kala.12345, which is in the source on purpose: this is a demo and the data is invented.
The administrator is separate. It is seeded only when SEED_ADMIN_PASSWORD is set on the backend, and there is no default, so without that variable there is no admin account at all.
| Role | |
|---|---|
user@taltech.ee |
user |
alice@taltech.ee |
user |
bob@taltech.ee |
user |
charlie@taltech.ee |
user |
diana@taltech.ee |
user |
Four example trips are also seeded (Barcelona Weekend, London Business Trip, Summer Cabin Getaway, NYC Adventure), each with expenses, polls and wishlist items.
Related documentation
YLEVAADE.md— comprehensive system overview in Estonian, covering both frontend and backendsplitapp-backend-clean-onion— backend repository (ASP.NET Core 10 + PostgreSQL)
Recommended IDE
VS Code + Vue (Official) (disable Vetur).