README.md
16,565 bytes
| 1 | # SplitApp — Vue 3 frontend |
|---|---|
| 2 | https://travel.rasmusj.com/ |
| 3 | |
| 4 | 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. |
| 5 | |
| 6 | This repo satisfies the "Separate client app" requirements of the TalTech Personal Project assignment — see [Assignment compliance](#assignment-compliance) below. |
| 7 | |
| 8 | ## Features |
| 9 | |
| 10 | - **Authentication** — register, login, logout, JWT + refresh token flow with auto-refresh on 401 |
| 11 | - **Trips** — create, view, edit, delete trips with destination, dates, currency (`views/trips`) |
| 12 | - **Members & invitations** — invite travel companions by token, accept/decline/revoke (`views/members`, `views/invitations`) |
| 13 | - **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`) |
| 14 | - **Budget categories** — categorize expenses, track spent vs planned (`views/budget-categories`) |
| 15 | - **Wishlist** — trip activities and destinations with group voting and completion tracking (`views/wishlist`) |
| 16 | - **Polls** — group decision-making with single/multi vote and close-poll workflow (`views/polls`) |
| 17 | - **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 to `Settled` once 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 yet `Completed` (`views/settlements`) |
| 18 | - **Multi-currency support** (`services/CurrencyService.ts`) |
| 19 | |
| 20 | ### Expense split methods |
| 21 | |
| 22 | The `SplitMethodSelector` component supports four validated split strategies, matching the backend's `ESplitMethod` enum: |
| 23 | |
| 24 | | Method | Behavior | |
| 25 | | -------------- | ------------------------------------------------------------------ | |
| 26 | | `EqualAll` | Total divided equally among every trip member | |
| 27 | | `EqualSubset` | User picks a subset of members; total divided equally among picked | |
| 28 | | `ExactAmounts` | Per-member fixed amount input; must sum exactly to total | |
| 29 | | `Percentages` | Per-member percentage input; must sum to exactly 100% | |
| 30 | |
| 31 | Each method is validated live and blocks the save button when invalid. |
| 32 | |
| 33 | ## Tech stack |
| 34 | |
| 35 | - **Vue 3.5** (Composition API) + **TypeScript 6** |
| 36 | - **Vite 8** (dev server + production build) |
| 37 | - **Vue Router 5** — nested routes with an auth navigation guard |
| 38 | - **Pinia 3** — auth store (JWT, refresh token, user name) with localStorage sync |
| 39 | - **Axios** — shared `httpClient.ts` with request/response interceptors |
| 40 | - **Bootstrap 5** + custom coral/teal CSS variable theme |
| 41 | - **Vitest 4** + `@vue/test-utils` + **MSW** — unit & integration tests |
| 42 | - **Playwright** — browser-based end-to-end tests |
| 43 | - **ESLint** (flat config) + **Oxlint** + **Prettier** |
| 44 | |
| 45 | ## Project structure |
| 46 | |
| 47 | ``` |
| 48 | src/ |
| 49 | ├── components/ # SplitMethodSelector, ToastContainer |
| 50 | ├── composables/ # useToast — global toast system |
| 51 | ├── directives/ # v-animate — IntersectionObserver scroll animations |
| 52 | ├── router/ # vue-router config + auth guard |
| 53 | ├── services/ # 9 axios-based API clients, one per resource |
| 54 | │ ├── httpClient.ts # shared axios instance with JWT interceptors |
| 55 | │ ├── AccountService.ts |
| 56 | │ ├── TripService.ts |
| 57 | │ ├── ExpenseService.ts |
| 58 | │ ├── BudgetCategoryService.ts |
| 59 | │ ├── CurrencyService.ts |
| 60 | │ ├── PollService.ts |
| 61 | │ ├── WishlistService.ts |
| 62 | │ ├── SettlementService.ts |
| 63 | │ └── InvitationService.ts |
| 64 | ├── stores/ |
| 65 | │ └── auth.ts # jwt, refreshToken, userName, isAuthenticated |
| 66 | ├── types/ # TypeScript contracts mirroring backend DTOs |
| 67 | ├── utils/ # formatCurrency, parseJwt |
| 68 | ├── views/ # Pages (HomeView, LoginView, RegisterView, trips/, expenses/, ...) |
| 69 | ├── App.vue # Root layout (navbar + router-view + toasts) |
| 70 | └── main.ts |
| 71 | e2e/ # Playwright tests |
| 72 | src/__tests__/ |
| 73 | ├── unit/ # Vitest unit tests |
| 74 | ├── integration/ # Vitest + MSW integration tests |
| 75 | └── vitest.setup.ts |
| 76 | ``` |
| 77 | |
| 78 | ## Requirements |
| 79 | |
| 80 | - **Node.js** `^20.19.0 || >=22.12.0` |
| 81 | - **npm** |
| 82 | - A running instance of the SplitApp backend (`splitapp-backend-clean-onion`) on the URL configured in `.env` |
| 83 | |
| 84 | ## Setup |
| 85 | |
| 86 | 1. Install dependencies: |
| 87 | |
| 88 | ```sh |
| 89 | npm install |
| 90 | ``` |
| 91 | |
| 92 | 2. Create a `.env` file at the project root: |
| 93 | |
| 94 | ```env |
| 95 | VITE_API_BASE_URL=http://localhost:90/api/v1/ |
| 96 | ``` |
| 97 | |
| 98 | Use `http://localhost:5086/api/v1/` if you run the backend locally via `dotnet run` instead of Docker. |
| 99 | |
| 100 | 3. Start the backend (separate repo): |
| 101 | |
| 102 | ```sh |
| 103 | cd ../splitapp-backend-clean-onion |
| 104 | docker compose up -d |
| 105 | ``` |
| 106 | |
| 107 | 4. Run the frontend: |
| 108 | ```sh |
| 109 | npm run dev |
| 110 | ``` |
| 111 | → http://localhost:5173 |
| 112 | |
| 113 | ## Scripts |
| 114 | |
| 115 | ```sh |
| 116 | npm run dev # Vite dev server (http://localhost:5173) |
| 117 | npm run build # Type-check + production build |
| 118 | npm run preview # Preview the production bundle |
| 119 | npm run test:unit # Vitest unit + integration tests (watch mode) |
| 120 | npm run test:unit -- --run # One-off CI-style run |
| 121 | npm run test:e2e # Playwright end-to-end tests (needs backend running) |
| 122 | npm run test:e2e:ui # Playwright interactive UI mode |
| 123 | npm run lint # Oxlint + ESLint with --fix |
| 124 | npm run type-check # vue-tsc strict type check |
| 125 | npm run format # Prettier |
| 126 | ``` |
| 127 | |
| 128 | ## Testing |
| 129 | |
| 130 | The project has three testing layers — **39 tests in 7 files**, all green against the phase 3 modular monolith backend. |
| 131 | |
| 132 | | Layer | Files | Tests | Backend? | Runtime | |
| 133 | |---|---|---|---|---| |
| 134 | | Unit | 4 | 31 | No (pure functions / isolated component) | ~0.4s | |
| 135 | | Integration | 1 | 4 | No (MSW mocks the network at the HTTP layer) | ~0.5s | |
| 136 | | E2E | 2 | 4 | **Yes** (real Chromium, real backend) | ~11s | |
| 137 | |
| 138 | ### Unit (`npm run test:unit`) |
| 139 | |
| 140 | Vitest + jsdom. Each file targets one tightly-scoped piece of the codebase: |
| 141 | |
| 142 | | File | What it covers | Why it matters | |
| 143 | |---|---|---| |
| 144 | | `formatCurrency.spec.ts` | sign / symbol / decimals / thousands separators | every page renders amounts — a regression here visually breaks the whole app | |
| 145 | | `parseJwt.spec.ts` | base64url decoding (`+/` → `-_`), ASP.NET `nameidentifier` claim with `sub` fallback | wrong user-id parsing silently mis-attributes expenses to the wrong user | |
| 146 | | `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 | |
| 147 | | `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 | |
| 148 | |
| 149 | ### Integration (`npm run test:unit`, in the same suite) |
| 150 | |
| 151 | | File | What it covers | |
| 152 | |---|---| |
| 153 | | `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. | |
| 154 | |
| 155 | ### End-to-end (`npm run test:e2e`) |
| 156 | |
| 157 | Playwright drives a real Chromium browser. The `webServer` config auto-starts `npm run dev` for you, so you only need the backend reachable. |
| 158 | |
| 159 | | File | What it covers | |
| 160 | |---|---| |
| 161 | | `auth.spec.ts` | login as seed user `alice@taltech.ee`, logout, wrong-password stays on `/login`, protected routes redirect anonymous users to `/login` | |
| 162 | | `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. | |
| 163 | |
| 164 | **How to run locally:** |
| 165 | |
| 166 | E2E hits the backend via whatever URL is in `.env`: |
| 167 | |
| 168 | ```sh |
| 169 | # Option A — against deployed phase 3 backend (no local docker needed) |
| 170 | # .env already points at https://travel.rasmusj.com/api/v1/ |
| 171 | npm run test:e2e |
| 172 | |
| 173 | # Option B — against a local backend |
| 174 | cd ../splitapp-backend-clean-onion && docker compose up -d # backend on :90 |
| 175 | # Override .env locally with VITE_API_BASE_URL=http://localhost:90/api/v1/ |
| 176 | cd ../splitapp-frontend-vue |
| 177 | npm run test:e2e # headless |
| 178 | npm run test:e2e:ui # interactive UI mode for debugging |
| 179 | ``` |
| 180 | |
| 181 | > **Note:** the `trip-crud` test 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). |
| 182 | |
| 183 | ## Docker |
| 184 | |
| 185 | The project ships with a multi-stage `Dockerfile` (Node build → Nginx serve) and a `docker-compose.yml` with two profiles. |
| 186 | |
| 187 | ### Production (Nginx) |
| 188 | |
| 189 | ```sh |
| 190 | docker compose up --build |
| 191 | ``` |
| 192 | |
| 193 | → http://localhost:91 |
| 194 | |
| 195 | The backend URL is injected as a **build arg** (`VITE_API_BASE_URL`) so the same image can target different environments: |
| 196 | |
| 197 | ```sh |
| 198 | # Override at build time for a real deploy |
| 199 | VITE_API_BASE_URL=https://api.mydomain.com/api/v1/ docker compose up --build |
| 200 | ``` |
| 201 | |
| 202 | Nginx is configured with an SPA fallback (`try_files $uri $uri/ /index.html`) so Vue Router's history mode works on direct URLs. |
| 203 | |
| 204 | ### Port allocation |
| 205 | |
| 206 | | Service | Host port | Notes | |
| 207 | | -------------------------- | ----------- | --------------------- | |
| 208 | | Frontend (prod, Nginx) | **91** | separate from backend | |
| 209 | | Backend API (Docker) | 90 | `rasmju-csweb-phase3` | |
| 210 | | Backend API (`dotnet run`) | 5086 / 7040 | http / https | |
| 211 | | PostgreSQL | 5432 | | |
| 212 | |
| 213 | ## CI/CD |
| 214 | |
| 215 | Configured via [.gitlab-ci.yml](.gitlab-ci.yml): |
| 216 | |
| 217 | - **deploy** stage — on merges to `main`, runs `docker compose -p rasmju-js-a7 up --build --remove-orphans --detach` on a shared runner (deploys the Nginx production image) |
| 218 | - **Tests run locally** before pushing — there is intentionally no test stage in the pipeline. Run `npm run test:unit -- --run` and `npm run test:e2e` on your machine before merging. |
| 219 | |
| 220 | The frontend is hosted on a separate URL from the backend and relies on the backend's permissive CORS policy (`AllowAnyOrigin` in `Program.cs`). |
| 221 | |
| 222 | ## Architecture notes |
| 223 | |
| 224 | ### Authentication flow |
| 225 | |
| 226 | 1. `LoginView` → `AccountService.loginAsync` → backend returns `{ jwt, refreshToken, firstName, lastName }` |
| 227 | 2. Auth store saves them; `watch`ers mirror values to `localStorage` |
| 228 | 3. Every outgoing request picks up `Authorization: Bearer <jwt>` from the request interceptor |
| 229 | 4. On `401`, the response interceptor: |
| 230 | - calls `AccountService.refreshTokenAsync(jwt, refreshToken)` |
| 231 | - on success: updates the store, replays the original request transparently |
| 232 | - on failure: calls `logoutAsync`, clears the store, navigates to `/login` |
| 233 | 5. `logout()` tears down store state and removes localStorage entries |
| 234 | |
| 235 | ### Nested routing |
| 236 | |
| 237 | `/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: |
| 238 | |
| 239 | - Hide organizer-only actions (budget category CRUD, finalize/reopen trip) |
| 240 | - 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 |
| 241 | - Lock expense CRUD whenever trip status is not `Active` (i.e. `Finalizing`, `Settled`, or `Archived`) |
| 242 | - Show suggested payment previews while trip is `Active`, and the real settlement plan once trip enters `Finalizing` or `Settled`; the header badge distinguishes the two states (warning hourglass vs. lock icon) |
| 243 | - 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) |
| 244 | |
| 245 | ### Currency conversion |
| 246 | |
| 247 | 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. |
| 248 | |
| 249 | ### Error handling |
| 250 | |
| 251 | Services return an `IResultObject<T>` wrapper (`{ data } | { errors: string[] }`) instead of throwing. Views inspect `.errors` and show toast messages via the `useToast` composable. |
| 252 | |
| 253 | ## Assignment compliance |
| 254 | |
| 255 | This repository targets the **"Separate client app"** portion of the TalTech Personal Project Assignment 3. |
| 256 | |
| 257 | | Requirement | Status | Where | |
| 258 | | ----------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------ | |
| 259 | | Written in a chosen technology (react/angular/vue/blazor/…) | ✅ | Vue 3 + TypeScript | |
| 260 | | Uses own backend REST API | ✅ | `VITE_API_BASE_URL=http://localhost:90/api/v1/` | |
| 261 | | JWT + refresh token authentication | ✅ | `services/httpClient.ts`, `services/AccountService.ts`, `stores/auth.ts` | |
| 262 | | Login / logout | ✅ | `LoginView.vue`, `RegisterView.vue`, `App.vue` | |
| 263 | | CRUD on ≥3 entities | ✅ (5) | Trips, Expenses, BudgetCategories, Wishlist, Polls | |
| 264 | | CI/CD deploy — client hosted on a separate URL from backend | ✅ | `.gitlab-ci.yml`, separate Docker container on port 91 | |
| 265 | | CORS handling | ✅ | Backend `CorsAllowAll` policy; frontend on separate origin | |
| 266 | | Bonus: full unit + integration + e2e test coverage | ✅ | Vitest + MSW + Playwright — 39 tests, all green against phase 3 backend | |
| 267 | |
| 268 | ### Seed users |
| 269 | |
| 270 | 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. |
| 271 | |
| 272 | 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. |
| 273 | |
| 274 | | Email | Role | |
| 275 | | -------------------- | ----- | |
| 276 | | `user@taltech.ee` | user | |
| 277 | | `alice@taltech.ee` | user | |
| 278 | | `bob@taltech.ee` | user | |
| 279 | | `charlie@taltech.ee` | user | |
| 280 | | `diana@taltech.ee` | user | |
| 281 | |
| 282 | Four example trips are also seeded (Barcelona Weekend, London Business Trip, Summer Cabin Getaway, NYC Adventure), each with expenses, polls and wishlist items. |
| 283 | |
| 284 | ## Related documentation |
| 285 | |
| 286 | - [`YLEVAADE.md`](YLEVAADE.md) — comprehensive system overview in Estonian, covering both frontend and backend |
| 287 | - `splitapp-backend-clean-onion` — backend repository (ASP.NET Core 10 + PostgreSQL) |
| 288 | |
| 289 | ## Recommended IDE |
| 290 | |
| 291 | [VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (disable Vetur). |
| 292 | |