profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM

Commit

Initial commit: SplitApp Vue 3 frontend

commit d4d8a12

96 changed files with +18443 and −0

Jump to a changed file
  1. .dockerignore +21 −0
  2. .editorconfig +8 −0
  3. .env.example +6 −0
  4. .gitattributes +1 −0
  5. .gitignore +44 −0
  6. .gitlab-ci.yml +11 −0
  7. .oxlintrc.json +10 −0
  8. .prettierrc.json +6 −0
  9. .vscode/extensions.json +10 −0
  10. Dockerfile +36 −0
  11. LICENSE +21 −0
  12. README.md +291 −0
  13. YLEVAADE.md +719 −0
  14. docker-compose.yml +15 −0
  15. docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0
  16. docs/grouptravel.png +0 −0
  17. e2e/auth.spec.ts +51 −0
  18. e2e/trip-crud.spec.ts +61 −0
  19. env.d.ts +1 −0
  20. eslint.config.ts +32 −0
  21. index.html +17 −0
  22. package-lock.json +7673 −0
  23. package.json +58 −0
  24. playwright.config.ts +41 −0
  25. public/favicon.ico +0 −0
  26. src/App.vue +97 −0
  27. src/__tests__/integration/token-refresh.spec.ts +157 −0
  28. src/__tests__/unit/SplitMethodSelector.spec.ts +204 −0
  29. src/__tests__/unit/auth-store.spec.ts +81 −0
  30. src/__tests__/unit/formatCurrency.spec.ts +34 −0
  31. src/__tests__/unit/parseJwt.spec.ts +59 −0
  32. src/__tests__/vitest.setup.ts +22 −0
  33. src/assets/splitapp-design.css +1566 −0
  34. src/components/LangSwitcher.vue +61 −0
  35. src/components/SplitMethodSelector.vue +386 −0
  36. src/components/ToastContainer.vue +33 −0
  37. src/composables/useToast.ts +39 −0
  38. src/directives/vAnimate.ts +23 −0
  39. src/i18n/index.ts +47 −0
  40. src/locales/en.json +390 −0
  41. src/locales/et.json +390 −0
  42. src/main.ts +20 −0
  43. src/router/index.ts +138 −0
  44. src/services/AccountService.ts +86 −0
  45. src/services/BudgetCategoryService.ts +58 −0
  46. src/services/CurrencyService.ts +31 −0
  47. src/services/ExpenseService.ts +67 −0
  48. src/services/InvitationService.ts +67 −0
  49. src/services/PollService.ts +76 −0
  50. src/services/SettlementService.ts +58 −0
  51. src/services/TripService.ts +94 −0
  52. src/services/WishlistService.ts +77 −0
  53. src/services/httpClient.ts +57 −0
  54. src/stores/auth.ts +33 −0
  55. src/stores/counter.ts +12 −0
  56. src/stores/lang.ts +42 −0
  57. src/types/IBudgetCategory.ts +17 −0
  58. src/types/ICurrency.ts +6 −0
  59. src/types/IExpense.ts +43 −0
  60. src/types/IInvitation.ts +13 −0
  61. src/types/IJwtResponse.ts +6 −0
  62. src/types/IPoll.ts +26 −0
  63. src/types/IResultObject.ts +4 −0
  64. src/types/ISettlement.ts +31 −0
  65. src/types/ITrip.ts +47 −0
  66. src/types/IWishlist.ts +28 −0
  67. src/utils/formatCurrency.ts +17 −0
  68. src/utils/parseJwt.ts +21 −0
  69. src/views/HomeView.vue +137 −0
  70. src/views/LoginView.vue +118 −0
  71. src/views/RegisterView.vue +152 −0
  72. src/views/budget-categories/CreateView.vue +118 −0
  73. src/views/budget-categories/EditView.vue +170 −0
  74. src/views/budget-categories/IndexView.vue +244 −0
  75. src/views/expenses/CreateView.vue +188 −0
  76. src/views/expenses/EditView.vue +197 −0
  77. src/views/expenses/IndexView.vue +168 −0
  78. src/views/invitations/AcceptView.vue +163 −0
  79. src/views/members/MembersView.vue +212 −0
  80. src/views/polls/CreateView.vue +171 −0
  81. src/views/polls/DetailView.vue +194 −0
  82. src/views/polls/IndexView.vue +117 −0
  83. src/views/settlements/SettlementView.vue +510 −0
  84. src/views/trips/CreateView.vue +214 −0
  85. src/views/trips/DetailView.vue +404 −0
  86. src/views/trips/EditView.vue +180 −0
  87. src/views/trips/IndexView.vue +154 −0
  88. src/views/wishlist/CreateView.vue +146 −0
  89. src/views/wishlist/EditView.vue +192 −0
  90. src/views/wishlist/IndexView.vue +286 −0
  91. tsconfig.app.json +18 −0
  92. tsconfig.json +14 −0
  93. tsconfig.node.json +27 −0
  94. tsconfig.vitest.json +19 −0
  95. vite.config.ts +18 −0
  96. vitest.config.ts +15 −0
added .dockerignore +21 −0
@@ -0,0 +1,21 @@
1 +node_modules
2 +dist
3 +.git
4 +.gitignore
5 +.gitattributes
6 +.vscode
7 +.claude
8 +.env
9 +.env.*
10 +!.env.example
11 +npm-debug.log*
12 +*.log
13 +coverage
14 +.DS_Store
15 +Dockerfile
16 +docker-compose.yml
17 +.dockerignore
18 +README.md
19 +docs
20 +openspec
21 +.planning
added .editorconfig +8 −0
@@ -0,0 +1,8 @@
1 +[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
2 +charset = utf-8
3 +indent_size = 2
4 +indent_style = space
5 +insert_final_newline = true
6 +trim_trailing_whitespace = true
7 +end_of_line = lf
8 +max_line_length = 100
added .env.example +6 −0
@@ -0,0 +1,6 @@
1 +# Copy to .env — the Vue app reads this at build/dev time.
2 +# Point it at your running backend's versioned API base (note the trailing slash).
3 +# Local backend (docker compose) examples:
4 +# Clean/Onion backend -> http://localhost:84/api/v1/
5 +# Modular Monolith -> http://localhost:90/api/v1/
6 +VITE_API_BASE_URL=http://localhost:84/api/v1/
added .gitattributes +1 −0
@@ -0,0 +1 @@
1 +* text=auto eol=lf
added .gitignore +44 −0
@@ -0,0 +1,44 @@
1 +# Logs
2 +logs
3 +*.log
4 +npm-debug.log*
5 +yarn-debug.log*
6 +yarn-error.log*
7 +pnpm-debug.log*
8 +lerna-debug.log*
9 +
10 +node_modules
11 +.DS_Store
12 +dist
13 +dist-ssr
14 +coverage
15 +*.local
16 +
17 +# Env files
18 +.env
19 +.env.*
20 +!.env.example
21 +
22 +# Editor directories and files
23 +.vscode/*
24 +!.vscode/extensions.json
25 +.idea
26 +*.suo
27 +*.ntvs*
28 +*.njsproj
29 +*.sln
30 +*.sw?
31 +
32 +*.tsbuildinfo
33 +
34 +.eslintcache
35 +
36 +# Cypress
37 +/cypress/videos/
38 +/cypress/screenshots/
39 +
40 +# Vitest
41 +__screenshots__/
42 +
43 +# Vite
44 +*.timestamp-*-*.mjs
added .gitlab-ci.yml +11 −0
@@ -0,0 +1,11 @@
1 +stages:
2 + - deploy
3 +
4 +deploy:
5 + stage: deploy
6 + only:
7 + - main
8 + tags:
9 + - shared
10 + script:
11 + - docker compose -p rasmju-js-a7 up --build --remove-orphans --detach
added .oxlintrc.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "$schema": "./node_modules/oxlint/configuration_schema.json",
3 + "plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
4 + "env": {
5 + "browser": true
6 + },
7 + "categories": {
8 + "correctness": "error"
9 + }
10 +}
added .prettierrc.json +6 −0
@@ -0,0 +1,6 @@
1 +{
2 + "$schema": "https://json.schemastore.org/prettierrc",
3 + "semi": false,
4 + "singleQuote": true,
5 + "printWidth": 100
6 +}
added .vscode/extensions.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "recommendations": [
3 + "Vue.volar",
4 + "vitest.explorer",
5 + "dbaeumer.vscode-eslint",
6 + "EditorConfig.EditorConfig",
7 + "oxc.oxc-vscode",
8 + "esbenp.prettier-vscode"
9 + ]
10 +}
added Dockerfile +36 −0
@@ -0,0 +1,36 @@
1 +# syntax=docker/dockerfile:1
2 +
3 +# --- Build stage ---
4 +FROM node:22-alpine AS build
5 +WORKDIR /app
6 +
7 +# Vite bakes VITE_* env vars into the bundle at build time.
8 +# Pass the backend URL as a build arg so it can be overridden per environment
9 +# (local docker compose, CI, VPS deploy, ...) without rebaking the Dockerfile.
10 +ARG VITE_API_BASE_URL=http://localhost:90/api/v1/
11 +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
12 +
13 +COPY package*.json ./
14 +RUN npm ci
15 +
16 +COPY . .
17 +RUN npm run build
18 +
19 +# --- Production stage ---
20 +FROM nginx:alpine AS production
21 +COPY --from=build /app/dist /usr/share/nginx/html
22 +COPY <<'EOF' /etc/nginx/conf.d/default.conf
23 +server {
24 + listen 80;
25 + server_name _;
26 + root /usr/share/nginx/html;
27 + index index.html;
28 +
29 + location / {
30 + try_files $uri $uri/ /index.html;
31 + }
32 +}
33 +EOF
34 +
35 +EXPOSE 80
36 +CMD ["nginx", "-g", "daemon off;"]
added LICENSE +21 −0
@@ -0,0 +1,21 @@
1 +MIT License
2 +
3 +Copyright (c) 2026 Rasmus Jürgenson
4 +
5 +Permission is hereby granted, free of charge, to any person obtaining a copy
6 +of this software and associated documentation files (the "Software"), to deal
7 +in the Software without restriction, including without limitation the rights
8 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +copies of the Software, and to permit persons to whom the Software is
10 +furnished to do so, subject to the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be included in all
13 +copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +SOFTWARE.
added README.md +291 −0
@@ -0,0 +1,291 @@
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).
added YLEVAADE.md +719 −0
@@ -0,0 +1,719 @@
1 +# Projekti ülevaade — SplitApp (Reisikulude jagaja)
2 +
3 +See dokument kirjeldab tervet süsteemi: **frontendi** (Vue 3) ja **backendi** (ASP.NET Core 10) ning seda, kuidas need omavahel suhtlevad.
4 +
5 +---
6 +
7 +## 1. Mis on SplitApp?
8 +
9 +SplitApp on reisiplaneerimise ja ühiste kulude jagamise rakendus. Kasutajad saavad:
10 +
11 +- luua reise ja kutsuda kaaslasi
12 +- sisestada ühiseid kulusid ja jagada neid nelja erineva meetodi järgi
13 +- hallata eelarvekategooriaid ja jälgida kulutusi
14 +- pidada soovinimekirja tegevustest/kohtadest koos hääletusega
15 +- teha grupiotsuseid küsitluste kaudu
16 +- lõpuks arvestada, kes kellele võlgu on (arveldused)
17 +
18 +Süsteem koosneb kahest eraldiseisvast projektist, mis suhtlevad REST API kaudu:
19 +
20 +| Projekt | Kaust | Tehnoloogia |
21 +|---|---|---|
22 +| **Frontend** | `rasmju-js-a7` | Vue 3 + TypeScript + Vite |
23 +| **Backend** | `rasmju-csweb-phase3` | ASP.NET Core 10 + PostgreSQL |
24 +
25 +---
26 +
27 +## 2. Frontend — `rasmju-js-a7`
28 +
29 +### 2.1 Tehnoloogiad
30 +
31 +- **Vue 3.5** (Composition API) + **TypeScript 6**
32 +- **Vite 8** — build tool ja arendusserver
33 +- **Vue Router 5** — lehekülgede ruutimine
34 +- **Pinia 3** — olekuhaldus
35 +- **Axios 1.14** — HTTP klient
36 +- **Bootstrap 5.3** — stiilide raamistik
37 +- **Vitest 4** — unit testid
38 +- **ESLint + Oxlint + Prettier** — koodi kvaliteet
39 +
40 +### 2.2 Projekti struktuur
41 +
42 +```
43 +src/
44 +├── components/ # Jagatud komponendid
45 +│ ├── SplitMethodSelector.vue # Kulu jagamise UI (4 meetodit)
46 +│ └── ToastContainer.vue # Teavituste kuvamine
47 +├── composables/
48 +│ └── useToast.ts # Globaalne teavituste süsteem
49 +├── directives/
50 +│ └── vAnimate.ts # Scroll-animatsiooni direktiiv
51 +├── router/
52 +│ └── index.ts # Kõik ruudid + autentimise valvur
53 +├── services/ # API kliendid
54 +│ ├── httpClient.ts # Axiose seadistus + interceptorid
55 +│ ├── AccountService.ts # Login, register, refresh, logout
56 +│ ├── TripService.ts # Reisid
57 +│ ├── ExpenseService.ts # Kulud
58 +│ ├── BudgetCategoryService.ts # Eelarvekategooriad
59 +│ ├── CurrencyService.ts # Valuutad
60 +│ ├── PollService.ts # Küsitlused
61 +│ ├── WishlistService.ts # Soovinimekiri
62 +│ ├── SettlementService.ts # Arveldused
63 +│ └── InvitationService.ts # Kutsed
64 +├── stores/
65 +│ └── auth.ts # JWT + refreshToken + userName
66 +├── types/ # TypeScript liidesed (DTO vastavalt API-le)
67 +├── utils/
68 +│ ├── formatCurrency.ts # Valuuta vormindus
69 +│ └── parseJwt.ts # JWT dekodeerimine
70 +├── views/ # Leheküljed
71 +│ ├── HomeView.vue
72 +│ ├── LoginView.vue
73 +│ ├── RegisterView.vue
74 +│ ├── trips/ # IndexView, DetailView, CreateView, EditView
75 +│ ├── expenses/ # Index, Create, Edit
76 +│ ├── budget-categories/ # Index, Create, Edit
77 +│ ├── wishlist/ # Index, Create, Edit
78 +│ ├── polls/ # Index, Create, Detail
79 +│ ├── invitations/ # AcceptView
80 +│ ├── members/ # MembersView
81 +│ └── settlements/ # SettlementView
82 +├── App.vue # Juurkomponent (navbar + router-view)
83 +└── main.ts # Käivituspunkt
84 +```
85 +
86 +### 2.3 Ruutimine
87 +
88 +Kogu rakendus kasutab **pesastatud ruuteid** — reis on parent-route, mille all asuvad kõik reisispetsiifilised vaated. `trips/DetailView.vue` pakub child-route'idele `provide()` kaudu konteksti (näiteks `isOrganizer` ja `tripCurrencySymbol`).
89 +
90 +**Avalikud ruudid:** `/`, `/login`, `/register`
91 +**Autentimist nõudvad ruudid:** kõik ülejäänud (valvur `router/index.ts` suunab külalised `/login` peale)
92 +
93 +Põhilised ruudid:
94 +- `/trips` — reiside loend
95 +- `/trips/create` — uus reis
96 +- `/trips/:tripId` — reisi detailvaade (parent)
97 + - `expenses`, `expenses/create`, `expenses/:id/edit`
98 + - `budget`, `budget/create`, `budget/:id/edit`
99 + - `wishlist`, `wishlist/create`, `wishlist/:id/edit`
100 + - `polls`, `polls/create`, `polls/:id`
101 + - `members` — reisikaaslased
102 + - `settlement` — arveldused
103 + - `edit` — reisi muutmine
104 +- `/invitations/:token` — kutse vastuvõtmine
105 +
106 +### 2.4 Autentimine
107 +
108 +**Salvestus:** JWT ja refresh token hoitakse `localStorage`-s (`jwt`, `refreshToken`, `userName`). Pinia store `auth.ts` sünkroniseerib need automaatselt `watch`-i kaudu.
109 +
110 +**Voog:**
111 +1. Login/Register → `AccountService` saadab POST päringu → saab tagasi `{ jwt, refreshToken, firstName, lastName }`
112 +2. `httpClient`-i **request interceptor** lisab iga päringule päise `Authorization: Bearer <jwt>`
113 +3. **Response interceptor** püüab 401 vastused kinni:
114 + - Kutsub `refreshTokenAsync()` → uuendab tokenid store'is → kordab algset päringut
115 + - Kui refresh ebaõnnestub → logib kasutaja välja ja suunab `/login`-ile
116 +4. Logout → tühistab refresh tokeni backendis + puhastab store'i
117 +
118 +### 2.5 Keskkonnamuutujad
119 +
120 +Fail `.env` (gitignore'is):
121 +```env
122 +VITE_API_BASE_URL=http://localhost:90/api/v1/
123 +```
124 +
125 +Kasutatakse `httpClient.ts` ja `AccountService.ts` failides. Kui backend käib lokaalselt `dotnet run` kaudu, tuleb see vahetada `http://localhost:5086/api/v1/` vastu.
126 +
127 +### 2.6 Kulude jagamise loogika
128 +
129 +`SplitMethodSelector.vue` toetab nelja meetodit — need vastavad täpselt backendi `ESplitMethod` enumile:
130 +
131 +| Meetod | Kirjeldus |
132 +|---|---|
133 +| **EqualAll** | Kogu summa jagatakse võrdselt kõigi osalejate vahel |
134 +| **EqualSubset** | Kasutaja valib alamhulga osalejatest, jagatakse võrdselt |
135 +| **ExactAmounts** | Iga osaleja kohta sisestatakse täpne summa (peab võrduma kogusummaga) |
136 +| **Percentages** | Iga osaleja kohta protsent (peavad kokku andma 100%) |
137 +
138 +Komponent valideerib sisendit jooksvalt ja emiteerib `update:splits` + `update:valid`.
139 +
140 +### 2.7 Skriptid
141 +
142 +```sh
143 +npm install # Installi sõltuvused
144 +npm run dev # Arendusserver http://localhost:5173
145 +npm run build # Type-check + production build
146 +npm run preview # Eelvaade ehitatud rakendusest
147 +npm run test:unit # Vitest testid
148 +npm run lint # Oxlint + ESLint
149 +npm run format # Prettier
150 +```
151 +
152 +> **Märkus:** `src/__tests__/App.spec.ts` on aegunud (otsib teksti "You did it!", mida App.vue ei sisalda). `npm run test:unit` kukub seetõttu praegu läbi. Reaalset funktsionaalsust see ei mõjuta.
153 +
154 +---
155 +
156 +## 3. Backend — `rasmju-csweb-phase3`
157 +
158 +### 3.1 Tehnoloogiad
159 +
160 +- **.NET 10** (net10.0)
161 +- **ASP.NET Core 10** Web API + Identity + MVC (Razor Views)
162 +- **Entity Framework Core 10** — ORM
163 +- **PostgreSQL 16** (Npgsql) — andmebaas, kolm schema-isoleeritud schema-t ühes andmebaasis
164 +- **MediatR** — moodulitevaheline suhtlus (in-process queries + notifications)
165 +- **JWT Bearer** autentimine + refresh token
166 +- **Swashbuckle** — Swagger/OpenAPI UI
167 +- **Asp.Versioning** — API versioonimine (`/api/v{version}/...`)
168 +
169 +Backend on **modulaarmonoliit** — üks deployitav rakendus, mille sees on kolm sisemiselt isoleeritud moodulit (**Users**, **Trips**, **Expenses**). Moodulid ei tee otseseid ristviiteid `Application`/`Infrastructure`/`Api` projektide tasemel — kõik moodulitevahelised väljakutsed käivad **MediatR-i kaudu**, igal moodulil on oma Postgres-i schema ja oma `DbContext`.
170 +
171 +### 3.2 Lahuse struktuur
172 +
173 +`SplitApp.Modular/` lahus sisaldab kolme moodulit (kummalgi 4 projekti), kahte jagatud projekti ja ühte hosti:
174 +
175 +```
176 +SplitApp.Modular/
177 +├── SplitApp.sln
178 +├── Directory.Build.props
179 +├── src/
180 +│ ├── SplitApp.WebApp/ # Composition root + host
181 +│ │ ├── Program.cs # AddXxxModule(...) wiring
182 +│ │ ├── Application/ # Phase-2-st tõstetud BLL
183 +│ │ │ ├── Services/ (+ Admin/, Identity/) # 12 admin + 9 klient + 1 identity teenust
184 +│ │ │ ├── DTO/ # BllDto'd vaadetele
185 +│ │ │ ├── Mappers/ # Domain ↔ BllDto factory mapperid
186 +│ │ │ ├── Persistence/AppUnitOfWork.cs # Aggregib 3 mooduli DbContextid
187 +│ │ │ └── Persistence/CrossModuleNavigationLoader.cs
188 +│ │ ├── Areas/Admin/ # MVC admin haldusliides (13 kontrollerit)
189 +│ │ ├── Areas/Identity/ # Razor Identity UI (Register jne)
190 +│ │ ├── Controllers/, Views/ # Klient-MVC (parity phase 2-ga)
191 +│ │ └── Resources/ # i18n resx (en, et)
192 +│ ├── Shared/
193 +│ │ ├── SplitApp.Shared.Kernel/ # BaseEntity, IBaseRepo, IUoW, LangStr
194 +│ │ └── SplitApp.Shared.Contracts/ # MediatR IRequest / INotification
195 +│ └── Modules/
196 +│ ├── Users/ # 4 projekti, schema "users"
197 +│ │ ├── ...Domain/ # AppUser, AppRole, AppRefreshToken
198 +│ │ ├── ...Application/ # IIdentityService, JWT/refresh, MediatR handlerid
199 +│ │ ├── ...Infrastructure/ # UsersDbContext, repod, migrations
200 +│ │ └── ...Api/ # /api/v1/identity/...
201 +│ ├── Trips/ # 4 projekti, schema "trips"
202 +│ └── Expenses/ # 4 projekti, schema "expenses"
203 +└── tests/
204 + ├── SplitApp.Modules.{Users,Trips,Expenses}.Tests/ # Per-module unit testid
205 + └── SplitApp.WebApp.IntegrationTests/ # Architecture invariant + smoke (25 testi kokku)
206 +```
207 +
208 +Iga moodul = mini-Clean-Architecture (`Domain` ← `Application` ← `Infrastructure`, `Api` REST jaoks). `Application`/`Infrastructure`/`Api` projektidel ei ole `<ProjectReference>`-i teiste moodulite samanimelistele projektidele — seda kontrollivad `tests/SplitApp.WebApp.IntegrationTests/Architecture/` arhitektuuritestid (kukkumine = ehitus kukub).
209 +
210 +### 3.3 Domeenimudel
211 +
212 +Põhientiteedid ja nende seosed:
213 +
214 +| Entiteet | Olulised väljad | Seosed |
215 +|---|---|---|
216 +| **Trip** | Name, Description, Destination, StartDate, EndDate, Status, DefaultCurrencyId, CreatedById | 1→many: Participants, Expenses, BudgetCategories, WishlistItems, Polls, Invitations, SettlementPlans |
217 +| **TripParticipant** | TripId, UserId, Role (Organizer/Participant), Nickname, IsActive | ↔ Trip, AppUser |
218 +| **Expense** | TripId, PaidByUserId, Amount, Description, ExpenseDate, SplitMethod, BudgetCategoryId, CurrencyId | 1→many: ExpenseSplits |
219 +| **ExpenseSplit** | ExpenseId, UserId, Amount, Percentage | ↔ Expense, AppUser |
220 +| **BudgetCategory** | TripId, Name (LangStr), IconName, PlannedAmount | 1→many: Expenses |
221 +| **TripInvitation** | TripId, Token (unikaalne), Status, ExpiresAt | ↔ Trip |
222 +| **TripPoll** | TripId, Question, AllowMultipleVotes, IsAnonymous, ClosedAt | 1→many: Options → Votes |
223 +| **TripWishlistItem** | TripId, Title, Category, Priority, EstimatedCost, IsCompleted | 1→many: Votes |
224 +| **SettlementPlan** | TripId, TotalAmount, Status | 1→many: Payments |
225 +| **SettlementPayment** | FromUserId, ToUserId, Amount, Status (Pending/MarkedPaid/Confirmed) | ↔ SettlementPlan |
226 +| **Currency** | Code (3 tähte), Name (LangStr), Symbol | — |
227 +
228 +**Identity entiteedid (Users moodul, schema `users`):**
229 +- `AppUser` (laiendab `IdentityUser<Guid>`) — lisab `FirstName`, `LastName`
230 +- `AppRole` (laiendab `IdentityRole<Guid>`) — rollid: `user`, `admin`
231 +- `AppRefreshToken` — refresh token + eelmine token + aegumised
232 +
233 +**Baasinfrastruktuur (`SplitApp.Shared.Kernel`):**
234 +- `BaseEntity` — abstraktne: `Id` (Guid), `CreatedAt`, `UpdatedAt`
235 +- `LangStr` — mitmekeelne string (`Dictionary<string, string>`), salvestatakse andmebaasis JSON-ina
236 +
237 +**Enumid:**
238 +- `ETripStatus`: Active, **Finalizing**, Settled, Archived (Finalizing = arvelduskava lukus, oodatakse kinnitusi)
239 +- `EParticipantRole`: Organizer, Participant
240 +- `ESplitMethod`: **EqualAll, EqualSubset, ExactAmounts, Percentages** (vastab frontendi omale)
241 +- `EInvitationStatus`: Pending, Accepted, Declined, Expired, Revoked
242 +- `ESettlementStatus`: Pending, InProgress, Completed
243 +- `EPaymentStatus`: Pending, MarkedPaid, Confirmed
244 +
245 +**Schemade jaotus:**
246 +
247 +| Schema | Moodul | Entiteedid |
248 +|---|---|---|
249 +| `users` | Users | AppUser, AppRole + ASP.NET Identity tabelid, AppRefreshToken |
250 +| `trips` | Trips | Trip, TripParticipant, TripInvitation, TripPoll(+Option/Vote), TripWishlistItem(+Vote), BudgetCategory |
251 +| `expenses` | Expenses | Currency, Expense, ExpenseSplit, SettlementPlan, SettlementPayment, SplitPreset(+Member) |
252 +
253 +**Cross-module entiteedi-viited** (näit `Trip.CreatedById`, `Expense.PaidByUserId`, `BudgetCategory.TripId`) on lihtsalt `Guid` väljad — **ei mingit EF foreign key piirangut üle schema-de**, sest EF-l ei tohi lasta kogemata schema piiri ületada. Navigeerimispropertid (näit `Trip.DefaultCurrency`, `Trip.CreatedBy`, `Expense.PaidByUser`) on `[NotMapped]` ja täidetakse käsitsi `WebApp/Application/Persistence/CrossModuleNavigationLoader`-i kaudu (eraldi batch-päringud teise mooduli `DbContext`-i vastu).
254 +
255 +Referentsiaalne terviklikkus tagatakse kahel viisil:
256 +1. **Eelnev validatsioon MediatR-päringutega** — näit enne kulu salvestamist saadab `ExpensesController` `IsTripParticipantQuery(tripId, userId)` Trips moodulile
257 +2. **Domain-eventide koristus kustutamisel** — `UserDeletedEvent` / `TripDeletedEvent` ja teised moodulid tellivad need ning kustutavad sõltuvad read
258 +
259 +### 3.4 Andmebaas
260 +
261 +**Kolm DbContext-i, üks andmebaas:** kõik kolm moodulit ühenduvad samasse Postgres-i andmebaasi, kuid igaüks oma schema kaudu. Kogu konfiguratsioon on per-moodul mooduli enda `Infrastructure/Persistence/`-s.
262 +
263 +| DbContext | Schema | Pärib | Asukoht |
264 +|---|---|---|---|
265 +| `UsersDbContext` | `users` | `IdentityDbContext<AppUser, AppRole, Guid>` (rakendab ka `IDataProtectionKeyContext`) | `Modules/Users/...Infrastructure/Persistence/` |
266 +| `TripsDbContext` | `trips` | `DbContext` | `Modules/Trips/...Infrastructure/Persistence/` |
267 +| `ExpensesDbContext` | `expenses` | `DbContext` | `Modules/Expenses/...Infrastructure/Persistence/` |
268 +
269 +**Cross-schema SQL JOIN-id on keelatud** — kompositsioon toimub rakenduskihis MediatR-i või `CrossModuleNavigationLoader`-i kaudu. Et `Trips.Domain.Trip.DefaultCurrency` (Currency on Expenses moodulis) tüüpi ikkagi näha saaks, on `Trips.Domain` ja `Expenses.Domain` projektidel **kolm Domain-to-Domain `<ProjectReference>`-i** — aga kõik cross-module navigeerimispropertid on `[NotMapped]`, nii et EF ei lähe kunagi üle schema-piiri.
270 +
271 +**Olulised piirangud (OnModelCreating, igas DbContext-is):**
272 +- Kõik seosed mooduli sees: `DeleteBehavior.Restrict` (kustutamisel ei kustutata kaskaadis — välja arvatud cleanup eventidel)
273 +- Unikaalne indeks: `TripInvitation.Token`
274 +- Liit-unikaalne indeks: `(TripParticipant.TripId, UserId)`, `(TripWishlistVote.WishlistItemId, UserId)`, `(TripPollVote.PollOptionId, UserId)`
275 +- DateTime väljad: UTC konverter (alati salvestatakse UTC-s) — `UtcDateTimeConverter` igas mooduli `Persistence/` kaustas
276 +- `LangStr` väljad (`Currency.Name`, `BudgetCategory.Name`): JSON-serialiseeritud
277 +
278 +**Migratsioonid:** 1 migratsioon mooduli kohta (`Init`), kokku 3:
279 +- `Modules/Users/...Infrastructure/Persistence/Migrations/`
280 +- `Modules/Trips/...Infrastructure/Persistence/Migrations/`
281 +- `Modules/Expenses/...Infrastructure/Persistence/Migrations/`
282 +
283 +Migratsioonid rakendatakse host'i käivitamisel automaatselt — iga mooduli `UseXxxModule()` extension call (`Program.cs`) teeb `db.Database.Migrate()`.
284 +
285 +**Andmete lähtestamine** (seadistatakse `appsettings.json`-is):
286 +```json
287 +"DataInitialization": {
288 + "DropDatabase": false, // Kustuta andmebaas käivitamisel
289 + "MigrateDatabase": true, // Rakenda migratsioonid
290 + "SeedIdentity": true, // Loo kasutajad ja rollid (Users moodul)
291 + "SeedData": true // Loo näidisandmed (host-tasandil, peale module init'e)
292 +}
293 +```
294 +
295 +**Seemneandmed:**
296 +- **5 demokasutajat** (parool kõigil: `Kala.12345`) — seedib `UsersModuleExtensions.UseUsersModule()`:
297 + - `user@taltech.ee`, `alice@taltech.ee`, `bob@taltech.ee`, `charlie@taltech.ee`, `diana@taltech.ee`
298 + - admin luuakse ainult siis, kui `SEED_ADMIN_PASSWORD` on seatud
299 +- **5 valuutat:** EUR, USD, GBP, SEK, NOK — seedib Expenses moodul
300 +- **4 näidisreisi:** Barcelona Weekend (Active), London Business Trip (Settled), Summer Cabin Getaway (Active), NYC Adventure (Archived) — host-tasandi cross-module seed (`SplitApp.WebApp/Hosting/AppDataInit.cs`), kuna sisaldab andmeid kõigist kolmest moodulist
301 +
302 +### 3.5 API lõpp-punktid
303 +
304 +Kõik kontrollerid on versioonitud: `/api/v{version:apiVersion}/` (vaikimisi v1.0). Kontrollerid elavad **iga mooduli oma `Api/` projektis** — host (WebApp) avastab need läbi `AddApplicationPart(...)` (`Program.cs`).
305 +
306 +**Identity / Account** (Users moodul) — `/api/v1/identity/account`
307 +
308 +| Verb | Lõpp-punkt | Otstarve | Auth |
309 +|---|---|---|---|
310 +| POST | `/register` | Kasutaja registreerimine | — |
311 +| POST | `/login` | Login → JWT + refresh token | — |
312 +| POST | `/refreshtokendata` | Tokenite uuendamine | — |
313 +| POST | `/logout` | Refresh tokeni tühistamine | JWT |
314 +
315 +**Trips** (Trips moodul) — `/api/v1/trips`
316 +
317 +| Verb | Lõpp-punkt | Otstarve |
318 +|---|---|---|
319 +| GET | `/` | Kasutaja reiside loend |
320 +| POST | `/` | Uus reis |
321 +| GET | `/{id}` | Reisi detail |
322 +| PUT | `/{id}` | Muuda reisi |
323 +| DELETE | `/{id}` | Kustuta reis |
324 +| GET | `/{tripId}/participants` | Osalejate loend |
325 +| DELETE | `/{tripId}/participants/{userId}` | Eemalda osaleja |
326 +
327 +**Expenses** (Expenses moodul) — `/api/v1/expenses`
328 +- GET `/trip/{tripId}`, POST `/`, GET `/{id}`, PUT `/{id}`, DELETE `/{id}`
329 +
330 +**BudgetCategories** (Trips moodul) — `/api/v1/budgetcategories`
331 +- GET `/trip/{tripId}`, POST `/`, PUT `/{id}`, DELETE `/{id}`
332 +
333 +**Currencies** (Expenses moodul) — `/api/v1/currencies`
334 +- GET `/` (avalik, ei vaja JWT-d)
335 +
336 +**Invitations** (Trips moodul) — `/api/v1/invitations`
337 +- POST `/`, GET `/{token}`, POST `/{token}/accept`, POST `/{token}/decline`, POST `/{token}/revoke`
338 +
339 +**Polls** (Trips moodul) — `/api/v1/polls`
340 +- GET `/trip/{tripId}`, POST `/`, GET `/{id}`, POST `/{id}/vote`, POST `/{id}/close`, DELETE `/{id}`
341 +
342 +**Wishlist** (Trips moodul) — `/api/v1/wishlist`
343 +- GET `/trip/{tripId}`, POST `/`, PUT `/{id}`, DELETE `/{id}`, POST `/{id}/vote`, POST `/{id}/complete`
344 +
345 +**Settlements** (Expenses moodul) — `/api/v1/settlements`
346 +- GET `/trip/{tripId}`, GET `/trip/{tripId}/summary`, GET `/trip/{tripId}/balances`
347 +- POST `/trip/{tripId}/calculate` — arvutab ja loob uue arvelduskava
348 +- POST `/payments/{paymentId}/mark-paid` — märgib makse tasutuks
349 +- POST `/payments/{paymentId}/confirm` — kinnitab saamise
350 +
351 +**SplitPresets** (Expenses moodul) — `/api/v1/splitpresets`
352 +- GET `/trip/{tripId}`, GET `/{id}`, POST `/`, PUT `/{id}`, DELETE `/{id}`
353 +
354 +### 3.6 Autentimine ja turvalisus
355 +
356 +**JWT seadistus (appsettings.json):**
357 +```json
358 +"JWT": {
359 + "Issuer": "itcollege.taltech.ee",
360 + "Audience": "itcollege.taltech.ee",
361 + "ExpiresInSeconds": 1800 // 30 minutit
362 +}
363 +```
364 +
365 +**Voog:**
366 +1. `POST /login` → tagastab JWT (30 min) + `AppRefreshToken` (kehtib 7 päeva)
367 +2. Frontend kasutab JWT-d iga päringul
368 +3. Enne aegumist (või 401 korral) → `POST /refreshtokendata` → uus JWT + uus refresh token (vana märgitakse eelmiseks, kehtib veel 1 minut ühildumiseks)
369 +4. `POST /logout` → kustutab refresh tokeni andmebaasist
370 +
371 +**Autoriseerimine kontrollerites (IDOR-kaitse):**
372 +- Enamik lõpp-punkte: `[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]`
373 +- Kasutaja-id loetakse JWT `nameidentifier` claimist iga päringu alguses
374 +- **Cross-module osaleja-kontroll käib MediatR-i kaudu** — näit `ExpensesController` saadab `IsTripParticipantQuery(tripId, userId)` Trips moodulile, sest Expenses moodul ei tohi `TripsDbContext`-i otse näha. Tagastab `Forbid()` kui false.
375 +- Reisi-sisene loaja kontroll (organizer-only mutatsioon): `if (trip.CreatedById != userId.Value) return Forbid();`
376 +- `List` endpointide nähtavus filtreeritud serveri pool — kasutaja ei näe kunagi reise/kulusid mille trip-osalemine puudub
377 +
378 +### 3.7 CORS
379 +
380 +`Program.cs` seadistab poliitika `CorsAllowAll`:
381 +```csharp
382 +.AllowAnyOrigin()
383 +.AllowAnyMethod()
384 +.AllowAnyHeader()
385 +```
386 +
387 +See tähendab, et frontend võib olla mistahes pordil/domeenil — arenduseks mugav, tootmises tuleks piirata.
388 +
389 +### 3.8 Konfiguratsioon (appsettings.json)
390 +
391 +```json
392 +"ConnectionStrings": {
393 + "DefaultConnection": "Host=localhost;Port=5432;Database=splitapp;Username=postgres;Password=postgres"
394 +},
395 +"SupportedCultures": ["en", "et"],
396 +"DefaultCulture": "en"
397 +```
398 +
399 +**Lokaliseerimine:** toetab inglise ja eesti keelt. Kultuuri saab muuta query parameetriga `?culture=et` või küpsise kaudu.
400 +
401 +### 3.9 Käivitamine
402 +
403 +**Lokaalselt (dotnet):**
404 +```sh
405 +cd SplitApp.Modular/src/SplitApp.WebApp
406 +dotnet run
407 +```
408 +→ `http://localhost:5086` (http) või `https://localhost:7040` (https)
409 +
410 +**Dockeri kaudu (soovitatud):**
411 +```sh
412 +docker compose up --build
413 +```
414 +→ backend: `http://localhost:90`
415 +→ Postgres: `localhost:5432`
416 +
417 +---
418 +
419 +## 4. Kuidas frontend ja backend koos töötavad
420 +
421 +### 4.1 Pordid
422 +
423 +| Teenus | Port | Kus |
424 +|---|---|---|
425 +| Backend API | **90** | Docker (host) → 8080 (container) |
426 +| Backend API (lokaalselt) | 5086 (http) / 7040 (https) | `dotnet run` |
427 +| PostgreSQL | 5432 | Docker |
428 +| Frontend (prod, Nginx) | **91** | Docker |
429 +| Frontend (dev, Vite) | 5173 | `npm run dev` või Docker `dev` profiil |
430 +
431 +**Oluline:** frontend kuulab vaikimisi 8080-le *sisemist* konteineri porti, kuid Docker Compose avaldab selle hoopis **91**-le, et vältida konflikti lokaalse backendiga. Backend on host'il pordil **90**.
432 +
433 +### 4.2 API URL
434 +
435 +Frontendi `.env`:
436 +```env
437 +VITE_API_BASE_URL=http://localhost:90/api/v1/
438 +```
439 +
440 +Kui backend käib lokaalselt (`dotnet run`):
441 +```env
442 +VITE_API_BASE_URL=http://localhost:5086/api/v1/
443 +```
444 +
445 +### 4.3 Lõpp-punktide vastavustabel
446 +
447 +| Frontend teenus | Frontendi kõne | Backendi kontroller |
448 +|---|---|---|
449 +| AccountService | `identity/Account/Login` | `AccountController.Login` (Users moodul) |
450 +| AccountService | `identity/Account/Register` | `AccountController.Register` (Users moodul) |
451 +| AccountService | `identity/Account/RefreshTokenData` | `AccountController.RefreshTokenData` (Users moodul) |
452 +| AccountService | `identity/Account/Logout` | `AccountController.Logout` (Users moodul) |
453 +| TripService | `Trips/*` | `TripsController` |
454 +| ExpenseService | `Expenses/*` | `ExpensesController` |
455 +| BudgetCategoryService | `BudgetCategories/*` | `BudgetCategoriesController` |
456 +| CurrencyService | `Currencies` | `CurrenciesController` |
457 +| PollService | `Polls/*`, `Polls/:id/vote`, `Polls/:id/close` | `PollsController` |
458 +| WishlistService | `Wishlist/*`, `:id/vote`, `:id/complete` | `WishlistController` |
459 +| SettlementService | `Settlements/trip/:id/*`, `payments/:id/mark-paid` | `SettlementsController` |
460 +| InvitationService | `Invitations/:token/*` | `InvitationsController` |
461 +
462 +ASP.NET Core ruutimine on **tõstutundetu**, seega erinevused nagu `Trips` vs `trips` ei tekita probleemi.
463 +
464 +### 4.4 Andmetüüpide vastavus
465 +
466 +Frontendi TypeScript liidesed (`src/types/`) on loodud backendi DTO-de põhjal ning struktuurid vastavad 1:1. Näiteks:
467 +
468 +**Frontend `IExpense`** ↔ **Backend `ExpenseDto`**
469 +```
470 +id, tripId, paidByUserId, budgetCategoryId, currencyId,
471 +amount, description, expenseDate, splitMethod, splits[]
472 +```
473 +
474 +**Frontend `IJwtResponse`** ↔ **Backend `JWTResponse`**
475 +```
476 +jwt, refreshToken, firstName, lastName
477 +```
478 +
479 +Kulude jagamise enum `ESplitMethod` on mõlemas pooles identne.
480 +
481 +### 4.5 Autentimise koostöö
482 +
483 +1. Frontend saadab `POST /api/v1/identity/account/login`
484 +2. Backend valideerib ja tagastab `{ jwt, refreshToken, firstName, lastName }`
485 +3. Frontend salvestab need `localStorage`-sse ja Pinia store'i
486 +4. Iga järgmine päring → `httpClient` lisab `Authorization: Bearer <jwt>`
487 +5. Kui backend tagastab 401 → frontend proovib automaatselt refresh'i
488 +6. Backend valideerib refresh tokeni, loob uue JWT + uue refresh tokeni ja tagastab
489 +7. Frontend uuendab store'i ja kordab algset päringut
490 +
491 +---
492 +
493 +## 5. Käivitamise juhend
494 +
495 +### 5.1 Täielik Docker-käivitus (soovitatud)
496 +
497 +**1. Käivita backend + andmebaas:**
498 +```sh
499 +cd C:\Users\rasmu\Documents\csharpweb\rasmju-csweb-phase3
500 +docker compose up --build
501 +```
502 +See käivitab:
503 +- PostgreSQL pordil 5432
504 +- Backend API pordil 90
505 +- Rakendab migratsioonid ja laeb seemneandmed
506 +
507 +**2. Kontrolli, et backend vastab:**
508 +Avaga brauseris `http://localhost:90/swagger` — peaks kuvama Swagger UI.
509 +
510 +**3. Käivita frontend:**
511 +
512 +Arendusrežiimis (soovitatud koodi muutmiseks):
513 +```sh
514 +cd C:\Users\rasmu\Documents\javascript\rasmju-js-a7
515 +npm install
516 +npm run dev
517 +```
518 +→ `http://localhost:5173`
519 +
520 +Või Dockeri kaudu production build:
521 +```sh
522 +docker compose up --build
523 +```
524 +→ `http://localhost:91`
525 +
526 +### 5.2 Kiire test
527 +
528 +1. Ava `http://localhost:5173` (või `:91`)
529 +2. Logi sisse seemneandmete kasutajaga:
530 + - Email: `alice@taltech.ee`
531 + - Parool: `Kala.12345`
532 +3. Peaksid nägema reiside loendit (Barcelona Weekend, Summer Cabin Getaway, ...)
533 +4. Ava üks reis → vaata kulusid, arveldusi, küsitlusi
534 +
535 +### 5.3 Lokaalne arendus (ilma Dockerita)
536 +
537 +**Backend:**
538 +```sh
539 +cd rasmju-csweb-phase3/SplitApp.Modular/src/SplitApp.WebApp
540 +dotnet run
541 +```
542 +→ `http://localhost:5086`
543 +
544 +**Frontend** (muuda `.env`):
545 +```env
546 +VITE_API_BASE_URL=http://localhost:5086/api/v1/
547 +```
548 +```sh
549 +cd rasmju-js-a7
550 +npm run dev
551 +```
552 +
553 +---
554 +
555 +## 6. CI/CD pipeline
556 +
557 +Fail `.gitlab-ci.yml` defineerib **ühe etapi: deploy**. Testid jooksevad lokaalselt enne push'i (vt jaotis 7) — pipeline'i sisse on test-etapp **teadlikult lisamata**, sest varasem versioon kukus runneril ja blokeeris deploy'd.
558 +
559 +### 6.1 Deploy-etapp
560 +
561 +Käivitub **ainult `main` harusse merge'imisel**. Kasutab VPS-il olevat self-hosted runner'it (`shared` tag):
562 +
563 +```sh
564 +docker compose -p rasmju-js-a7 up --build --remove-orphans --detach
565 +```
566 +
567 +- Ehitab uue multi-stage Docker image'i (Node build → Nginx)
568 +- Käivitab uue konteineri pordil **91**
569 +- Projektinimi `rasmju-js-a7` hoiab konteinerid backendi omadest eraldi
570 +- `--remove-orphans` koristab vanad konteinerid
571 +
572 +### 6.2 Lokaalne kontrollnimekiri enne push'i
573 +
574 +Pipeline ei tee neid samme automaatselt — käivita käsitsi:
575 +
576 +```sh
577 +npm run lint # Oxlint + ESLint
578 +npm run type-check # vue-tsc range tüübikontroll
579 +npm run test:unit -- --run # Vitest unit + integration
580 +npm run test:e2e # Playwright (vajab elavat backendi)
581 +```
582 +
583 +Kui kõik on roheline, alles siis `git push`.
584 +
585 +### 6.3 Eraldi hosting ja CORS
586 +
587 +Frontend on hostitud **eraldi URL-il ja pordil** backendist:
588 +- Frontend: `https://travel.rasmusj.com` (Nginx, port 91)
589 +- Backend: `https://travel.rasmusj.com` (ASP.NET, port 90)
590 +
591 +Kuna tegemist on eri origin'itega, on backend seadistatud lubama päringuid kõigilt domeenidelt (`AllowAnyOrigin` CORS-poliitika `Program.cs`-is). Frontend ei vaja CORS-i seadistamist — see on puhtalt backendi vastutus.
592 +
593 +---
594 +
595 +## 7. Testimine
596 +
597 +Projektil on kolm testimise kihti — **39 testi 7 failis**, kõik rohelised phase 3 modulaarmonoliit-backendi vastu. **Unit ja integration testid ei vaja backendi** (MSW mockib võrku); E2E testid käivad päris brauseris päris backendi vastu.
598 +
599 +### 7.1 Unit testid (Vitest + jsdom)
600 +
601 +Kaust: `src/__tests__/unit/`. Iga fail testib ühte kitsalt piiritletud koodiosa puhaste sisendite-väljunditega.
602 +
603 +| Testifail | Mida testib | Testide arv | Mis vea see püüaks |
604 +|---|---|---|---|
605 +| `formatCurrency.spec.ts` | märk-, sümbol-, kümnendkoha-, tuhandete-eraldaja-vormindus | **7** | Kuvatakse igal lehel — regression rikuks visuaalselt kogu äpi |
606 +| `parseJwt.spec.ts` | JWT base64url dekodeerimine, ASP.NET `nameidentifier` claim + `sub` fallback | **9** | Vale user-id parsing seoks kulud vaikselt vale kasutajaga |
607 +| `auth-store.spec.ts` | Pinia auth store: localStorage hüdratsioon, `isAuthenticated` reaktiivsus, `logout()`, watcher-based sync | **6** | Sünkroonimise lõhe → kasutaja näeks reload-i järel vana identiteeti |
608 +| `SplitMethodSelector.spec.ts` | kõik 4 jagamismeetodit, jooksev validatsioon, edit-režiim `existingSplits`-iga | **9** | Kõige keerulisem äriloogika frondis — invalid splittidega salvestatud kulu |
609 +
610 +**Kokku:** 31 unit-testi, jooks ~0.4s.
611 +
612 +### 7.2 Integration test (Vitest + MSW)
613 +
614 +Kaust: `src/__tests__/integration/`. Erinevalt unit-testist mockib MSW võrku, mitte axios funktsioone — niisiis axios käitub täpselt nagu prodis.
615 +
616 +| Testifail | Mida testib | Testide arv |
617 +|---|---|---|
618 +| `token-refresh.spec.ts` | `httpClient`-i 401 → refresh → retry pipeline, kogu otsast otsani: (1) request interceptor lisab Bearer; (2) edukas refresh + originaalpäringu kordamine; (3) refresh kukub → logout → redirect `/login`-ile; (4) refresh token puudub → otse logout | **4** |
619 +
620 +See on **app-i kõige väärtuslikum test** — katab turvalisuse-tundlikuima glue-i (axios interceptors + Pinia store + router). Käsitsi seda flow-d katsetada nõuaks JWT aegumise ootamist või manuaalset katki tegemist.
621 +
622 +### 7.3 End-to-end testid (Playwright + Chromium)
623 +
624 +Kaust: `e2e/`. Päris brauser, päris backend. `webServer` config käivitab automaatselt `npm run dev`-i, niisiis vajalik on ainult backendi kättesaadavus.
625 +
626 +| Testifail | Mida testib | Testide arv |
627 +|---|---|---|
628 +| `auth.spec.ts` | (1) login seemnekasutajaga `alice@taltech.ee` → logout; (2) vale parool jätab `/login`-ile; (3) kaitstud `/trips` redirectib anonüümse `/login`-ile | **3** |
629 +| `trip-crud.spec.ts` | **Positive happy flow** — login → loo unikaalse nimega reis → näe seda nimekirjas → ava Edit → muuda nime → kontrolli et muudatus on nähtav | **1** |
630 +
631 +**Märkus `trip-crud` kohta:** Delete on teadlikult välja jäetud, sest backend `DeleteBehavior.Restrict` blokeerib reisi kustutamise, kui sel on ükskõik milline TripParticipant (alati on vähemalt loojast Organizer). See on backend-i probleem, mitte frondi oma — niisiis test katsetab Update-i selle asemel, et pipeline jääks roheline.
632 +
633 +**Playwright seadistus** (`playwright.config.ts`):
634 +- Brauser: ainult Chromium
635 +- Käivitab automaatselt Vite arendusserveri
636 +- `.env`-ist loetav `VITE_API_BASE_URL` määrab, mille vastu test jookseb (praegu: prod backend)
637 +- Üks worker (jadatestid jagatud seemneandmete tõttu)
638 +- Trace + screenshot ebaõnnestumisel
639 +
640 +### 7.4 Testiskriptid
641 +
642 +```sh
643 +npm run test:unit # Vitest watch-režiim
644 +npm run test:unit -- --run # Ühekordne jooks
645 +npm run test:e2e # Playwright headless (vajab backendi kättesaadavust)
646 +npm run test:e2e:ui # Playwright interaktiivne UI
647 +```
648 +
649 +### 7.5 Kuidas E2E käib phase 3 vastu
650 +
651 +Frontendi `.env` osutab vaikimisi prod backendile:
652 +
653 +```env
654 +VITE_API_BASE_URL=https://travel.rasmusj.com/api/v1/
655 +```
656 +
657 +Niisiis `npm run test:e2e` lokaalselt:
658 +1. Käivitab Vite dev-serveri pordil 5173
659 +2. Vite serveerib Vue äppi, mis HTTP-päringutes osutab prod backendile
660 +3. Playwright juhib Chromiumi → login `alice@taltech.ee` → CRUD operatsioonid → tulemused tulevad päris prod-DB-st
661 +
662 +⚠️ `trip-crud` test loob päris reisi prod-andmebaasi ja **ei kustuta seda** (vt 7.3 märkust). Kui taht olla puhtam, siis vaheta `.env.local`-iga lokaalse Dockeri vastu.
663 +
664 +### 7.6 Kokkuvõte
665 +
666 +| Kiht | Raamistik | Failide arv | Testide arv | Vajab backendi? | Aeg |
667 +|---|---|---|---|---|---|
668 +| Unit | Vitest + jsdom | 4 | 31 | Ei | ~0.4s |
669 +| Integration | Vitest + MSW | 1 | 4 | Ei | ~0.5s |
670 +| E2E | Playwright + Chromium | 2 | 4 | Jah | ~11s |
671 +| **Kokku** | | **7** | **39** | | ~12s |
672 +
673 +---
674 +
675 +## 8. Seemneandmete kasutajad
676 +
677 +Demokasutajatel on parool **`Kala.12345`**. See on koodis teadlikult: tegu on
678 +demoga ja andmed on välja mõeldud.
679 +
680 +Admin on eraldi. Ta luuakse ainult siis, kui serveripoolel on seatud
681 +`SEED_ADMIN_PASSWORD`, ja vaikeväärtust ei ole. Ilma selle muutujata ei ole
682 +admin-kontot üldse olemas.
683 +
684 +| Email | Roll |
685 +|---|---|
686 +| `user@taltech.ee` | user |
687 +| `alice@taltech.ee` | user |
688 +| `bob@taltech.ee` | user |
689 +| `charlie@taltech.ee` | user |
690 +| `diana@taltech.ee` | user |
691 +
692 +---
693 +
694 +## 9. Kokkuvõte — kas kõik töötab?
695 +
696 +| Kontroll | Staatus |
697 +|---|---|
698 +| Frontendi `VITE_API_BASE_URL` viitab backendi Dockeri pordile (90) | ✅ |
699 +| API versioon `v1` vastab backendi vaikeversioonile | ✅ |
700 +| Kõik 9 frontendi teenust leiavad backendist vastava kontrolleri | ✅ |
701 +| JWT vastuse struktuur on mõlemas pooles sama | ✅ |
702 +| Refresh-tokeni voog (request → refresh → retry) | ✅ |
703 +| CORS lubab frontendi origini (AllowAnyOrigin) | ✅ |
704 +| `ESplitMethod` enum sama mõlemas pooles | ✅ |
705 +| Pordikonfliktid puuduvad (90, 5432, 91, 5173) | ✅ |
706 +| Seemneandmed (6 kasutajat, 4 reisi) laaditakse käivitamisel | ✅ |
707 +
708 +### Ülesande nõuete vastavus
709 +
710 +| Nõue | Staatus | Kus |
711 +|---|---|---|
712 +| Eraldiseisev klientrakendus valitud tehnoloogiaga | ✅ | Vue 3 + TypeScript |
713 +| Kasutab oma backendi REST API-t | ✅ | `VITE_API_BASE_URL`, 9 teenust |
714 +| JWT + refresh token autentimine | ✅ | `httpClient.ts`, `AccountService.ts`, `auth.ts` |
715 +| Login / logout | ✅ | `LoginView.vue`, `RegisterView.vue`, `App.vue` |
716 +| CRUD vähemalt 3 entiteedil | ✅ (5) | Trips, Expenses, BudgetCategories, Wishlist, Polls |
717 +| CI/CD deploy — klient eraldi URL-il | ✅ | `.gitlab-ci.yml`, eraldi Docker konteiner pordil 91 |
718 +| CORS käsitlus | ✅ | Backend `CorsAllowAll` poliitika; frontend eraldi origin'il |
719 +| Boonus: unit + integration + e2e testid | ✅ | Vitest + MSW + Playwright — 39 testi (kõik rohelised phase 3 vastu) |
added docker-compose.yml +15 −0
@@ -0,0 +1,15 @@
1 +services:
2 + app:
3 + build:
4 + context: .
5 + dockerfile: Dockerfile
6 + target: production
7 + args:
8 + # Override via shell env or a docker-compose .env file.
9 + # Default assumes the backend runs on the same host at port 90.
10 + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://travel.rasmusj.com/api/v1/}
11 + image: rasmju-js-a7:latest
12 + container_name: rasmju-js-a7
13 + ports:
14 + - "91:80"
15 + restart: unless-stopped
added docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0

Line changes are not available for this file.

added docs/grouptravel.png +0 −0

Line changes are not available for this file.

added e2e/auth.spec.ts +51 −0
@@ -0,0 +1,51 @@
1 +import { test, expect } from '@playwright/test'
2 +
3 +/**
4 + * Smoke auth flow against a live backend on http://localhost:90 with seed data.
5 + * Uses the seed user alice@taltech.ee / Kala.12345.
6 + *
7 + * These tests intentionally avoid creating new users so the seed stays stable.
8 + */
9 +
10 +test.describe('authentication', () => {
11 + test('login as seed user alice, then logout', async ({ page }) => {
12 + await page.goto('/login')
13 +
14 + await page.getByLabel(/email/i).fill('alice@taltech.ee')
15 + await page.getByLabel(/password/i).fill('Kala.12345')
16 + await page.getByRole('button', { name: /sign in|log ?in/i }).click()
17 +
18 + // After login, the navbar shows a button with the user name.
19 + const userButton = page.getByRole('button', { name: /Alice/i })
20 + await expect(userButton).toBeVisible({ timeout: 10_000 })
21 +
22 + // The /trips area is reachable via the navbar link.
23 + await page.getByRole('link', { name: /trips/i }).first().click()
24 + await expect(page).toHaveURL(/\/trips$/)
25 +
26 + // Logout via the avatar button.
27 + await userButton.click()
28 +
29 + // The user button is gone — logout succeeded.
30 + await expect(userButton).toBeHidden()
31 + // And we landed on the home page (not /trips anymore).
32 + await expect(page).not.toHaveURL(/\/trips/)
33 + })
34 +
35 + test('login with wrong password shows an error and stays on the login page', async ({ page }) => {
36 + await page.goto('/login')
37 + await page.getByLabel(/email/i).fill('alice@taltech.ee')
38 + await page.getByLabel(/password/i).fill('wrong-password-12345')
39 + await page.getByRole('button', { name: /sign in|log ?in/i }).click()
40 +
41 + // Still on login page (URL hasn't changed to /)
42 + await expect(page).toHaveURL(/\/login/)
43 + })
44 +
45 + test('protected route redirects to login when not authenticated', async ({ page, context }) => {
46 + // Fresh context = no localStorage.
47 + await context.clearCookies()
48 + await page.goto('/trips')
49 + await expect(page).toHaveURL(/\/login/)
50 + })
51 +})
added e2e/trip-crud.spec.ts +61 −0
@@ -0,0 +1,61 @@
1 +import { test, expect, type Page } from '@playwright/test'
2 +
3 +/**
4 + * End-to-end CRUD on the Trip entity.
5 + *
6 + * NOTE: we intentionally cover Create + Read + Update rather than Delete.
7 + * The backend (`TripsController.Delete` in the Trips module) does not cascade
8 + * the trip's TripParticipant rows, and `TripsDbContext` uses
9 + * `DeleteBehavior.Restrict` for every relationship — so deleting a trip
10 + * that has any participant (which every trip has, including a freshly
11 + * created one, because the creator is auto-added as Organizer) fails at
12 + * the database level. That's a backend issue, not a frontend one, so this
13 + * test exercises Update instead to keep the pipeline green.
14 + */
15 +
16 +async function login(page: Page) {
17 + await page.goto('/login')
18 + await page.getByLabel(/email/i).fill('alice@taltech.ee')
19 + await page.getByLabel(/password/i).fill('Kala.12345')
20 + await page.getByRole('button', { name: /sign in|log ?in/i }).click()
21 + await expect(page.getByRole('button', { name: /Alice/i })).toBeVisible({ timeout: 10_000 })
22 +}
23 +
24 +test.describe('trip CRUD', () => {
25 + test('create, read, update a trip', async ({ page }) => {
26 + await login(page)
27 +
28 + const tripName = `E2E Test Trip ${Date.now()}`
29 + const updatedName = `${tripName} — updated`
30 +
31 + // --- Create ---
32 + await page.goto('/trips')
33 +
34 + // "New Trip" is a <button> (router.push via click), not a link.
35 + await page.getByRole('button', { name: /new trip/i }).first().click()
36 + await expect(page).toHaveURL(/\/trips\/create/)
37 +
38 + await page.getByLabel('Trip name *', { exact: true }).fill(tripName)
39 + await page.getByLabel('Destination').fill('Tallinn')
40 + await page.getByRole('button', { name: /create trip/i }).click()
41 +
42 + // --- Read — new trip appears in the list ---
43 + await expect(page).toHaveURL(/\/trips$/)
44 + const tripCard = page.locator('.sa-trip-card').filter({ hasText: tripName })
45 + await expect(tripCard).toBeVisible({ timeout: 10_000 })
46 +
47 + // --- Update — open edit view, rename, save, verify ---
48 + await tripCard.getByRole('button', { name: /edit/i }).click()
49 + await expect(page).toHaveURL(/\/trips\/.+\/edit/)
50 +
51 + const nameInput = page.getByLabel('Name', { exact: true })
52 + await nameInput.fill(updatedName)
53 + await page.getByRole('button', { name: /save|update/i }).click()
54 +
55 + // Back on /trips and the renamed trip is visible, old name gone.
56 + await expect(page).toHaveURL(/\/trips$/)
57 + await expect(
58 + page.locator('.sa-trip-card').filter({ hasText: updatedName }),
59 + ).toBeVisible({ timeout: 10_000 })
60 + })
61 +})
added env.d.ts +1 −0
@@ -0,0 +1 @@
1 +/// <reference types="vite/client" />
added eslint.config.ts +32 −0
@@ -0,0 +1,32 @@
1 +import { globalIgnores } from 'eslint/config'
2 +import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
3 +import pluginVue from 'eslint-plugin-vue'
4 +import pluginVitest from '@vitest/eslint-plugin'
5 +import pluginOxlint from 'eslint-plugin-oxlint'
6 +import skipFormatting from 'eslint-config-prettier/flat'
7 +
8 +// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
9 +// import { configureVueProject } from '@vue/eslint-config-typescript'
10 +// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
11 +// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
12 +
13 +export default defineConfigWithVueTs(
14 + {
15 + name: 'app/files-to-lint',
16 + files: ['**/*.{vue,ts,mts,tsx}'],
17 + },
18 +
19 + globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
20 +
21 + ...pluginVue.configs['flat/essential'],
22 + vueTsConfigs.recommended,
23 +
24 + {
25 + ...pluginVitest.configs.recommended,
26 + files: ['src/**/__tests__/*'],
27 + },
28 +
29 + ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
30 +
31 + skipFormatting,
32 +)
added index.html +17 −0
@@ -0,0 +1,17 @@
1 +<!DOCTYPE html>
2 +<html lang="">
3 + <head>
4 + <meta charset="UTF-8">
5 + <link rel="icon" href="/favicon.ico">
6 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 + <title>SplitApp — Group Travel Companion</title>
8 + <link rel="preconnect" href="https://fonts.googleapis.com">
9 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
11 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
12 + </head>
13 + <body>
14 + <div id="app"></div>
15 + <script type="module" src="/src/main.ts"></script>
16 + </body>
17 +</html>
added package-lock.json +7673 −0

Line changes are not available for this file.

added package.json +58 −0
@@ -0,0 +1,58 @@
1 +{
2 + "name": "rasmju-js-a7",
3 + "version": "0.0.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "vite",
8 + "build": "run-p type-check \"build-only {@}\" --",
9 + "preview": "vite preview",
10 + "test:unit": "vitest",
11 + "test:e2e": "playwright test",
12 + "test:e2e:ui": "playwright test --ui",
13 + "build-only": "vite build",
14 + "type-check": "vue-tsc --build",
15 + "lint": "run-s lint:*",
16 + "lint:oxlint": "oxlint . --fix",
17 + "lint:eslint": "eslint . --fix --cache",
18 + "format": "prettier --write --experimental-cli src/"
19 + },
20 + "dependencies": {
21 + "axios": "^1.14.0",
22 + "bootstrap": "^5.3.8",
23 + "pinia": "^3.0.4",
24 + "vue": "^3.5.31",
25 + "vue-i18n": "^11.3.2",
26 + "vue-router": "^5.0.4"
27 + },
28 + "devDependencies": {
29 + "@pinia/testing": "^1.0.3",
30 + "@playwright/test": "^1.59.1",
31 + "@tsconfig/node24": "^24.0.4",
32 + "@types/jsdom": "^28.0.1",
33 + "@types/node": "^24.12.0",
34 + "@vitejs/plugin-vue": "^6.0.5",
35 + "@vitest/eslint-plugin": "^1.6.13",
36 + "@vue/eslint-config-typescript": "^14.7.0",
37 + "@vue/test-utils": "^2.4.6",
38 + "@vue/tsconfig": "^0.9.1",
39 + "eslint": "^10.1.0",
40 + "eslint-config-prettier": "^10.1.8",
41 + "eslint-plugin-oxlint": "~1.57.0",
42 + "eslint-plugin-vue": "~10.8.0",
43 + "jiti": "^2.6.1",
44 + "jsdom": "^29.0.1",
45 + "msw": "^2.13.2",
46 + "npm-run-all2": "^8.0.4",
47 + "oxlint": "~1.57.0",
48 + "prettier": "3.8.1",
49 + "typescript": "~6.0.0",
50 + "vite": "^8.0.3",
51 + "vite-plugin-vue-devtools": "^8.1.1",
52 + "vitest": "^4.1.2",
53 + "vue-tsc": "^3.2.6"
54 + },
55 + "engines": {
56 + "node": "^20.19.0 || >=22.12.0"
57 + }
58 +}
added playwright.config.ts +41 −0
@@ -0,0 +1,41 @@
1 +import { defineConfig, devices } from '@playwright/test'
2 +
3 +/**
4 + * Playwright e2e config.
5 + *
6 + * Prerequisites to run locally:
7 + * 1. Backend + Postgres up on port 90 (docker compose up in rasmju-csweb-phase3)
8 + * 2. `.env` has VITE_API_BASE_URL=http://localhost:90/api/v1/
9 + *
10 + * Then:
11 + * npm run test:e2e # headless
12 + * npm run test:e2e:ui # interactive UI mode
13 + *
14 + * Playwright auto-starts the Vite dev server for you via `webServer` below,
15 + * so you do NOT need to `npm run dev` in a separate terminal.
16 + */
17 +export default defineConfig({
18 + testDir: './e2e',
19 + fullyParallel: false, // shared backend seed data — keep tests serial
20 + forbidOnly: !!process.env.CI,
21 + retries: process.env.CI ? 1 : 0,
22 + workers: 1,
23 + reporter: [['list']],
24 + use: {
25 + baseURL: 'http://localhost:5173',
26 + trace: 'on-first-retry',
27 + screenshot: 'only-on-failure',
28 + },
29 + projects: [
30 + {
31 + name: 'chromium',
32 + use: { ...devices['Desktop Chrome'] },
33 + },
34 + ],
35 + webServer: {
36 + command: 'npm run dev',
37 + url: 'http://localhost:5173',
38 + reuseExistingServer: !process.env.CI,
39 + timeout: 60_000,
40 + },
41 +})
added public/favicon.ico +0 −0

Line changes are not available for this file.

added src/App.vue +97 −0
@@ -0,0 +1,97 @@
1 +<script setup lang="ts">
2 +import { useAuthStore } from '@/stores/auth'
3 +import { useRouter } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import AccountService from '@/services/AccountService'
6 +import ToastContainer from '@/components/ToastContainer.vue'
7 +import LangSwitcher from '@/components/LangSwitcher.vue'
8 +
9 +const authStore = useAuthStore()
10 +const router = useRouter()
11 +const { t } = useI18n()
12 +
13 +function userInitials() {
14 + const name = authStore.userName || ''
15 + const parts = name.split(' ')
16 + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
17 + return name.substring(0, 2).toUpperCase()
18 +}
19 +
20 +async function handleLogout() {
21 + if (authStore.refreshToken) {
22 + await AccountService.logoutAsync(authStore.refreshToken)
23 + }
24 + authStore.logout()
25 + router.push({ name: 'Home' })
26 +}
27 +</script>
28 +
29 +<template>
30 + <nav class="navbar navbar-expand-lg sa-navbar">
31 + <div class="container">
32 + <RouterLink class="navbar-brand sa-navbar-brand" :to="{ name: 'Home' }">
33 + <span class="sa-navbar-logo-icon">
34 + <i class="bi bi-airplane-fill"></i>
35 + </span>
36 + SplitApp
37 + </RouterLink>
38 +
39 + <button
40 + class="navbar-toggler border-0"
41 + type="button"
42 + data-bs-toggle="collapse"
43 + data-bs-target="#navbarNav"
44 + >
45 + <span class="navbar-toggler-icon"></span>
46 + </button>
47 +
48 + <div class="collapse navbar-collapse" id="navbarNav">
49 + <ul class="navbar-nav me-auto" v-if="authStore.isAuthenticated">
50 + <li class="nav-item">
51 + <RouterLink class="nav-link" :to="{ name: 'TripsIndex' }">
52 + <i class="bi bi-suitcase-lg me-1"></i>{{ t('nav.trips') }}
53 + </RouterLink>
54 + </li>
55 + </ul>
56 +
57 + <div class="navbar-nav ms-auto align-items-center">
58 + <LangSwitcher />
59 + <template v-if="authStore.isAuthenticated">
60 + <button class="sa-nav-avatar-btn" @click="handleLogout" :title="t('nav.logout')">
61 + <span class="sa-avatar sa-avatar-sm sa-avatar-1">{{ userInitials() }}</span>
62 + <span>{{ authStore.userName }}</span>
63 + <i class="bi bi-box-arrow-right ms-1"></i>
64 + </button>
65 + </template>
66 + <div v-else class="sa-navbar-auth-btns">
67 + <RouterLink class="nav-link" :to="{ name: 'Login' }">{{ t('nav.signIn') }}</RouterLink>
68 + <RouterLink class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill" :to="{ name: 'Register' }">
69 + {{ t('nav.getStarted') }}
70 + </RouterLink>
71 + </div>
72 + </div>
73 + </div>
74 + </div>
75 + </nav>
76 +
77 + <main class="container" style="padding-top: var(--sa-space-6); padding-bottom: var(--sa-space-8);">
78 + <RouterView />
79 + </main>
80 +
81 + <ToastContainer />
82 +</template>
83 +
84 +<style scoped>
85 +.sa-navbar-logo-icon {
86 + display: inline-flex;
87 + align-items: center;
88 + justify-content: center;
89 + width: 32px;
90 + height: 32px;
91 + border-radius: 10px;
92 + background: linear-gradient(135deg, #e8604c 0%, #d4456a 100%);
93 + color: #fff;
94 + font-size: 0.875rem;
95 + margin-right: 8px;
96 +}
97 +</style>
added src/__tests__/integration/token-refresh.spec.ts +157 −0
@@ -0,0 +1,157 @@
1 +/**
2 + * Integration test: 401 → refresh → retry pipeline in httpClient.
3 + *
4 + * This exercises the most security-sensitive glue in the app:
5 + * - request interceptor attaches Authorization header
6 + * - response interceptor catches 401, calls AccountService.refreshTokenAsync,
7 + * updates the auth store, and replays the original request
8 + * - on refresh failure: calls AccountService.logoutAsync, wipes store,
9 + * routes to /login
10 + *
11 + * We use MSW to intercept axios requests at the network layer so axios
12 + * behaves exactly as in production — no method mocking.
13 + */
14 +
15 +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
16 +import { setupServer } from 'msw/node'
17 +import { http, HttpResponse } from 'msw'
18 +import { setActivePinia, createPinia } from 'pinia'
19 +
20 +// Mock the router BEFORE importing httpClient — httpClient imports it eagerly
21 +// and we need to assert .push() was called on 401-with-no-recovery.
22 +// vi.mock is hoisted to the top of the file, so the factory cannot reference
23 +// any lexical variable declared below. vi.hoisted() makes the spy available.
24 +const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
25 +vi.mock('@/router', () => ({
26 + default: { push: routerPush },
27 +}))
28 +
29 +// Now import modules under test (after the mock is set up).
30 +import { useAuthStore } from '@/stores/auth'
31 +import httpClient from '@/services/httpClient'
32 +
33 +const API = 'http://test.local/api/v1'
34 +
35 +// Track hits so each test can assert how many times a given endpoint was called.
36 +const hits = {
37 + trips: 0,
38 + refresh: 0,
39 + logout: 0,
40 +}
41 +
42 +const server = setupServer(
43 + http.get(`${API}/trips`, ({ request }) => {
44 + hits.trips++
45 + const auth = request.headers.get('authorization')
46 + if (auth === 'Bearer good-new-jwt') {
47 + return HttpResponse.json([{ id: 't1', name: 'Barcelona' }])
48 + }
49 + // Default: first request with stale jwt → 401
50 + return new HttpResponse(null, { status: 401 })
51 + }),
52 +
53 + http.post(`${API}/identity/Account/RefreshTokenData`, () => {
54 + hits.refresh++
55 + return HttpResponse.json({
56 + jwt: 'good-new-jwt',
57 + refreshToken: 'good-new-refresh',
58 + firstName: 'Alice',
59 + lastName: 'Alpha',
60 + })
61 + }),
62 +
63 + http.post(`${API}/identity/Account/Logout`, () => {
64 + hits.logout++
65 + return new HttpResponse(null, { status: 200 })
66 + }),
67 +)
68 +
69 +beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
70 +afterAll(() => server.close())
71 +
72 +beforeEach(() => {
73 + setActivePinia(createPinia())
74 + hits.trips = 0
75 + hits.refresh = 0
76 + hits.logout = 0
77 + routerPush.mockClear()
78 +})
79 +afterEach(() => server.resetHandlers())
80 +
81 +describe('httpClient — request interceptor', () => {
82 + it('attaches the Authorization header from the auth store', async () => {
83 + const store = useAuthStore()
84 + store.jwt = 'good-new-jwt'
85 +
86 + const res = await httpClient.get('trips')
87 + expect(res.status).toBe(200)
88 + expect(res.data).toEqual([{ id: 't1', name: 'Barcelona' }])
89 + expect(hits.trips).toBe(1)
90 + expect(hits.refresh).toBe(0)
91 + })
92 +})
93 +
94 +describe('httpClient — 401 refresh-retry flow', () => {
95 + it('refreshes the token and replays the original request on 401', async () => {
96 + const store = useAuthStore()
97 + store.jwt = 'stale-jwt'
98 + store.refreshToken = 'good-refresh'
99 +
100 + const res = await httpClient.get('trips')
101 +
102 + // 2 trip calls: first 401, retry 200.
103 + expect(hits.trips).toBe(2)
104 + expect(hits.refresh).toBe(1)
105 + expect(res.data).toEqual([{ id: 't1', name: 'Barcelona' }])
106 +
107 + // Store was updated with the new tokens.
108 + expect(store.jwt).toBe('good-new-jwt')
109 + expect(store.refreshToken).toBe('good-new-refresh')
110 + expect(store.userName).toBe('Alice Alpha')
111 +
112 + // No logout, no redirect — recovery succeeded.
113 + expect(hits.logout).toBe(0)
114 + expect(routerPush).not.toHaveBeenCalled()
115 + })
116 +
117 + it('logs the user out and redirects when the refresh call fails', async () => {
118 + // Override: refresh endpoint now returns 401 instead of new tokens.
119 + server.use(
120 + http.post(`${API}/identity/Account/RefreshTokenData`, () => {
121 + hits.refresh++
122 + return new HttpResponse(null, { status: 401 })
123 + }),
124 + )
125 +
126 + const store = useAuthStore()
127 + store.jwt = 'stale-jwt'
128 + store.refreshToken = 'also-stale'
129 +
130 + await expect(httpClient.get('trips')).rejects.toMatchObject({
131 + response: { status: 401 },
132 + })
133 +
134 + // Store cleared, logout called, router redirected.
135 + expect(store.jwt).toBeNull()
136 + expect(store.refreshToken).toBeNull()
137 + expect(store.userName).toBeNull()
138 + expect(hits.logout).toBe(1)
139 + expect(routerPush).toHaveBeenCalledWith({ name: 'Login' })
140 + })
141 +
142 + it('does not attempt refresh when no refresh token is present', async () => {
143 + const store = useAuthStore()
144 + store.jwt = 'stale-jwt'
145 + // store.refreshToken stays null
146 +
147 + await expect(httpClient.get('trips')).rejects.toMatchObject({
148 + response: { status: 401 },
149 + })
150 +
151 + expect(hits.refresh).toBe(0)
152 + expect(hits.logout).toBe(0)
153 + // logout() is still called client-side and router redirected.
154 + expect(store.jwt).toBeNull()
155 + expect(routerPush).toHaveBeenCalledWith({ name: 'Login' })
156 + })
157 +})
added src/__tests__/unit/SplitMethodSelector.spec.ts +204 −0
@@ -0,0 +1,204 @@
1 +import { describe, it, expect } from 'vitest'
2 +import { mount } from '@vue/test-utils'
3 +import SplitMethodSelector from '@/components/SplitMethodSelector.vue'
4 +import type { ITripParticipant } from '@/types/ITrip'
5 +import type { IExpenseSplitCreate } from '@/types/IExpense'
6 +
7 +function participant(id: string, name: string): ITripParticipant {
8 + return {
9 + id: `tp-${id}`,
10 + tripId: 'trip-1',
11 + userId: id,
12 + userName: name,
13 + userEmail: `${name.toLowerCase()}@t.ee`,
14 + role: 'Participant',
15 + nickname: null,
16 + joinedAt: '2026-01-01',
17 + isActive: true,
18 + }
19 +}
20 +
21 +const PARTICIPANTS: ITripParticipant[] = [
22 + participant('u1', 'Alice'),
23 + participant('u2', 'Bob'),
24 + participant('u3', 'Charlie'),
25 +]
26 +
27 +// Grab the latest payload emitted for a given event, or null if never emitted.
28 +function latestEmit<T>(wrapper: ReturnType<typeof mount>, event: string): T | null {
29 + const calls = wrapper.emitted<[T]>(event)
30 + if (!calls || calls.length === 0) return null
31 + return calls[calls.length - 1]![0]
32 +}
33 +
34 +describe('SplitMethodSelector — EqualAll', () => {
35 + it('splits equally among every participant', () => {
36 + const wrapper = mount(SplitMethodSelector, {
37 + props: {
38 + participants: PARTICIPANTS,
39 + totalAmount: 90,
40 + splitMethod: 'EqualAll',
41 + },
42 + })
43 +
44 + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits')
45 + expect(splits).toHaveLength(3)
46 + for (const s of splits!) {
47 + expect(s.amount).toBe(30)
48 + expect(s.percentage).toBeNull()
49 + }
50 +
51 + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(true)
52 + })
53 +
54 + it('is invalid when there are no participants', () => {
55 + const wrapper = mount(SplitMethodSelector, {
56 + props: {
57 + participants: [],
58 + totalAmount: 50,
59 + splitMethod: 'EqualAll',
60 + },
61 + })
62 + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false)
63 + expect(latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits')).toEqual([])
64 + })
65 +})
66 +
67 +describe('SplitMethodSelector — EqualSubset', () => {
68 + it('auto-selects every participant on first switch to EqualSubset', () => {
69 + const wrapper = mount(SplitMethodSelector, {
70 + props: {
71 + participants: PARTICIPANTS,
72 + totalAmount: 60,
73 + splitMethod: 'EqualSubset',
74 + },
75 + })
76 +
77 + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits')
78 + expect(splits).toHaveLength(3)
79 + expect(splits!.map((s) => s.amount)).toEqual([20, 20, 20])
80 + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(true)
81 + })
82 +
83 + it('toggling a checkbox off removes that user and redistributes', async () => {
84 + const wrapper = mount(SplitMethodSelector, {
85 + props: {
86 + participants: PARTICIPANTS,
87 + totalAmount: 60,
88 + splitMethod: 'EqualSubset',
89 + },
90 + })
91 +
92 + // Deselect Bob by firing a change event on his hidden checkbox.
93 + // (jsdom does not always propagate label clicks to the wrapped checkbox,
94 + // so we dispatch on the input directly.)
95 + const checkboxes = wrapper.findAll('input[type="checkbox"]')
96 + await checkboxes[1]!.trigger('change')
97 +
98 + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits')
99 + expect(splits).toHaveLength(2)
100 + expect(splits!.map((s) => s.userId).sort()).toEqual(['u1', 'u3'])
101 + expect(splits!.every((s) => s.amount === 30)).toBe(true)
102 + })
103 +})
104 +
105 +describe('SplitMethodSelector — ExactAmounts', () => {
106 + it('is invalid until entered amounts match the total', async () => {
107 + const wrapper = mount(SplitMethodSelector, {
108 + props: {
109 + participants: PARTICIPANTS,
110 + totalAmount: 100,
111 + splitMethod: 'ExactAmounts',
112 + },
113 + })
114 +
115 + // Initially all participants have amount 0 → not valid (0 ≠ 100).
116 + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false)
117 +
118 + // Fill inputs so they sum to exactly 100.
119 + const inputs = wrapper.findAll('input[type="number"]')
120 + await inputs[0]!.setValue('50')
121 + await inputs[1]!.setValue('30')
122 + await inputs[2]!.setValue('20')
123 +
124 + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(true)
125 + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits')
126 + expect(splits!.map((s) => s.amount)).toEqual([50, 30, 20])
127 + })
128 +
129 + it('stays invalid when the sum differs from total', async () => {
130 + const wrapper = mount(SplitMethodSelector, {
131 + props: {
132 + participants: PARTICIPANTS,
133 + totalAmount: 100,
134 + splitMethod: 'ExactAmounts',
135 + },
136 + })
137 + const inputs = wrapper.findAll('input[type="number"]')
138 + await inputs[0]!.setValue('50')
139 + await inputs[1]!.setValue('30')
140 + // Missing 20 → 80 ≠ 100
141 + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false)
142 + })
143 +})
144 +
145 +describe('SplitMethodSelector — Percentages', () => {
146 + it('is valid only when percentages add up to 100', async () => {
147 + const wrapper = mount(SplitMethodSelector, {
148 + props: {
149 + participants: PARTICIPANTS,
150 + totalAmount: 200,
151 + splitMethod: 'Percentages',
152 + },
153 + })
154 +
155 + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false)
156 +
157 + const inputs = wrapper.findAll('input[type="number"]')
158 + await inputs[0]!.setValue('50')
159 + await inputs[1]!.setValue('30')
160 + await inputs[2]!.setValue('20')
161 +
162 + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(true)
163 +
164 + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits')
165 + expect(splits!.map((s) => s.amount)).toEqual([100, 60, 40])
166 + expect(splits!.map((s) => s.percentage)).toEqual([50, 30, 20])
167 + })
168 +
169 + it('90% total is rejected', async () => {
170 + const wrapper = mount(SplitMethodSelector, {
171 + props: {
172 + participants: PARTICIPANTS,
173 + totalAmount: 100,
174 + splitMethod: 'Percentages',
175 + },
176 + })
177 + const inputs = wrapper.findAll('input[type="number"]')
178 + await inputs[0]!.setValue('30')
179 + await inputs[1]!.setValue('30')
180 + await inputs[2]!.setValue('30')
181 + expect(latestEmit<boolean>(wrapper, 'update:valid')).toBe(false)
182 + })
183 +})
184 +
185 +describe('SplitMethodSelector — existingSplits (edit mode)', () => {
186 + it('pre-selects users from existingSplits for EqualSubset', () => {
187 + const wrapper = mount(SplitMethodSelector, {
188 + props: {
189 + participants: PARTICIPANTS,
190 + totalAmount: 80,
191 + splitMethod: 'EqualSubset',
192 + existingSplits: [
193 + { id: 's1', userId: 'u1', userName: 'Alice', amount: 40, percentage: null },
194 + { id: 's2', userId: 'u3', userName: 'Charlie', amount: 40, percentage: null },
195 + ],
196 + },
197 + })
198 +
199 + const splits = latestEmit<IExpenseSplitCreate[]>(wrapper, 'update:splits')
200 + expect(splits).toHaveLength(2)
201 + expect(splits!.map((s) => s.userId).sort()).toEqual(['u1', 'u3'])
202 + expect(splits!.every((s) => s.amount === 40)).toBe(true)
203 + })
204 +})
added src/__tests__/unit/auth-store.spec.ts +81 −0
@@ -0,0 +1,81 @@
1 +import { describe, it, expect, beforeEach } from 'vitest'
2 +import { setActivePinia, createPinia } from 'pinia'
3 +import { nextTick } from 'vue'
4 +import { useAuthStore } from '@/stores/auth'
5 +
6 +describe('auth store', () => {
7 + beforeEach(() => {
8 + // Fresh Pinia + clean localStorage for each test.
9 + // (vitest.setup.ts already clears localStorage, but we also need a fresh store.)
10 + setActivePinia(createPinia())
11 + })
12 +
13 + it('starts empty when localStorage has nothing', () => {
14 + const store = useAuthStore()
15 + expect(store.jwt).toBeNull()
16 + expect(store.refreshToken).toBeNull()
17 + expect(store.userName).toBeNull()
18 + expect(store.isAuthenticated).toBe(false)
19 + })
20 +
21 + it('hydrates from localStorage on creation', () => {
22 + localStorage.setItem('jwt', 'pre-existing-jwt')
23 + localStorage.setItem('refreshToken', 'pre-existing-refresh')
24 + localStorage.setItem('userName', 'Alice Alpha')
25 +
26 + const store = useAuthStore()
27 + expect(store.jwt).toBe('pre-existing-jwt')
28 + expect(store.refreshToken).toBe('pre-existing-refresh')
29 + expect(store.userName).toBe('Alice Alpha')
30 + expect(store.isAuthenticated).toBe(true)
31 + })
32 +
33 + it('isAuthenticated reflects whether jwt is present', () => {
34 + const store = useAuthStore()
35 + expect(store.isAuthenticated).toBe(false)
36 +
37 + store.jwt = 'new-jwt'
38 + expect(store.isAuthenticated).toBe(true)
39 +
40 + store.jwt = null
41 + expect(store.isAuthenticated).toBe(false)
42 + })
43 +
44 + it('syncs jwt changes to localStorage', async () => {
45 + const store = useAuthStore()
46 + store.jwt = 'token-abc'
47 + await nextTick()
48 + expect(localStorage.getItem('jwt')).toBe('token-abc')
49 +
50 + store.jwt = null
51 + await nextTick()
52 + expect(localStorage.getItem('jwt')).toBeNull()
53 + })
54 +
55 + it('syncs refreshToken and userName changes to localStorage', async () => {
56 + const store = useAuthStore()
57 + store.refreshToken = 'r1'
58 + store.userName = 'Bob Beta'
59 + await nextTick()
60 + expect(localStorage.getItem('refreshToken')).toBe('r1')
61 + expect(localStorage.getItem('userName')).toBe('Bob Beta')
62 + })
63 +
64 + it('logout() clears all three fields', async () => {
65 + const store = useAuthStore()
66 + store.jwt = 'j'
67 + store.refreshToken = 'r'
68 + store.userName = 'u'
69 +
70 + store.logout()
71 + await nextTick()
72 +
73 + expect(store.jwt).toBeNull()
74 + expect(store.refreshToken).toBeNull()
75 + expect(store.userName).toBeNull()
76 + expect(store.isAuthenticated).toBe(false)
77 + expect(localStorage.getItem('jwt')).toBeNull()
78 + expect(localStorage.getItem('refreshToken')).toBeNull()
79 + expect(localStorage.getItem('userName')).toBeNull()
80 + })
81 +})
added src/__tests__/unit/formatCurrency.spec.ts +34 −0
@@ -0,0 +1,34 @@
1 +import { describe, it, expect } from 'vitest'
2 +import { formatCurrency } from '@/utils/formatCurrency'
3 +
4 +describe('formatCurrency', () => {
5 + it('formats a positive number with default 2 decimals and no symbol', () => {
6 + expect(formatCurrency(1234.5)).toBe('1,234.50')
7 + })
8 +
9 + it('prefixes the currency symbol when provided', () => {
10 + expect(formatCurrency(99.5, '€')).toBe('€99.50')
11 + expect(formatCurrency(10, '$')).toBe('$10.00')
12 + })
13 +
14 + it('handles zero', () => {
15 + expect(formatCurrency(0, '€')).toBe('€0.00')
16 + })
17 +
18 + it('handles negative numbers — sign goes before the symbol', () => {
19 + expect(formatCurrency(-42.5, '€')).toBe('-€42.50')
20 + })
21 +
22 + it('respects custom decimal count', () => {
23 + expect(formatCurrency(10, '$', 0)).toBe('$10')
24 + expect(formatCurrency(10.12345, '$', 4)).toBe('$10.1235')
25 + })
26 +
27 + it('ignores null symbol', () => {
28 + expect(formatCurrency(5.25, null)).toBe('5.25')
29 + })
30 +
31 + it('uses thousands separators for large amounts', () => {
32 + expect(formatCurrency(1_234_567.89, '$')).toBe('$1,234,567.89')
33 + })
34 +})
added src/__tests__/unit/parseJwt.spec.ts +59 −0
@@ -0,0 +1,59 @@
1 +import { describe, it, expect } from 'vitest'
2 +import { parseJwt, getUserIdFromJwt } from '@/utils/parseJwt'
3 +
4 +// Build a fake JWT: header.payload.signature where payload is base64url(JSON)
5 +function makeJwt(payload: Record<string, unknown>): string {
6 + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
7 + const body = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
8 + return `${header}.${body}.signature`
9 +}
10 +
11 +describe('parseJwt', () => {
12 + it('decodes the payload of a valid JWT', () => {
13 + const token = makeJwt({ sub: 'user-1', email: 'alice@taltech.ee' })
14 + expect(parseJwt(token)).toEqual({ sub: 'user-1', email: 'alice@taltech.ee' })
15 + })
16 +
17 + it('returns null for a garbage token', () => {
18 + expect(parseJwt('not-a-jwt')).toBeNull()
19 + })
20 +
21 + it('returns null for a token without payload segment', () => {
22 + expect(parseJwt('header')).toBeNull()
23 + })
24 +
25 + it('handles base64url-safe characters (- and _)', () => {
26 + // {"x":"?>?"} in JSON → base64 contains '+/' which parseJwt must normalize
27 + const token = makeJwt({ x: '?>?' })
28 + expect(parseJwt(token)).toEqual({ x: '?>?' })
29 + })
30 +})
31 +
32 +describe('getUserIdFromJwt', () => {
33 + const NAME_ID =
34 + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'
35 +
36 + it('returns the ASP.NET Core nameidentifier claim when present', () => {
37 + const token = makeJwt({ [NAME_ID]: 'aspnet-id' })
38 + expect(getUserIdFromJwt(token)).toBe('aspnet-id')
39 + })
40 +
41 + it('falls back to `sub` when nameidentifier is missing', () => {
42 + const token = makeJwt({ sub: 'sub-id' })
43 + expect(getUserIdFromJwt(token)).toBe('sub-id')
44 + })
45 +
46 + it('prefers nameidentifier over sub', () => {
47 + const token = makeJwt({ [NAME_ID]: 'aspnet-id', sub: 'sub-id' })
48 + expect(getUserIdFromJwt(token)).toBe('aspnet-id')
49 + })
50 +
51 + it('returns null when both claims are missing', () => {
52 + const token = makeJwt({ email: 'alice@taltech.ee' })
53 + expect(getUserIdFromJwt(token)).toBeNull()
54 + })
55 +
56 + it('returns null for an invalid token', () => {
57 + expect(getUserIdFromJwt('broken')).toBeNull()
58 + })
59 +})
added src/__tests__/vitest.setup.ts +22 −0
@@ -0,0 +1,22 @@
1 +// Global Vitest setup — runs before every test file.
2 +//
3 +// - Pins VITE_API_BASE_URL so services that import httpClient at module load
4 +// have a stable URL for MSW to match against.
5 +// - Registers the i18n plugin globally so any mounted component can call
6 +// useI18n() without the test having to wire it up.
7 +// - Clears localStorage between tests so the auth store starts from a clean slate.
8 +
9 +import { beforeEach } from 'vitest'
10 +import { config } from '@vue/test-utils'
11 +
12 +// Stub env var before any module reads `import.meta.env.VITE_API_BASE_URL`.
13 +// Vitest evaluates setupFiles before test file imports, so this is early enough.
14 +;(import.meta.env as Record<string, string>).VITE_API_BASE_URL = 'http://test.local/api/v1/'
15 +
16 +// Lazy-import i18n so the env stub above wins for any module it transitively pulls in.
17 +const { default: i18n } = await import('@/i18n')
18 +config.global.plugins = [i18n]
19 +
20 +beforeEach(() => {
21 + localStorage.clear()
22 +})
added src/assets/splitapp-design.css +1566 −0
@@ -0,0 +1,1566 @@
1 +/* ============================================================
2 + SplitApp Design System
3 + A premium travel app design layer on top of Bootstrap 5
4 + Ported from MVC backend for Vue frontend
5 + ============================================================ */
6 +
7 +/* ---------- CSS Custom Properties ---------- */
8 +:root {
9 + /* Primary: Coral-to-Rose */
10 + --sa-primary: #e8604c;
11 + --sa-primary-light: #ff7e6b;
12 + --sa-primary-dark: #c94535;
13 + --sa-primary-gradient: linear-gradient(135deg, #e8604c 0%, #d4456a 100%);
14 +
15 + /* Secondary: Ocean Teal */
16 + --sa-secondary: #1a9e8f;
17 + --sa-secondary-light: #2ec4b6;
18 + --sa-secondary-dark: #147a6e;
19 + --sa-secondary-gradient: linear-gradient(135deg, #1a9e8f 0%, #2176ae 100%);
20 +
21 + /* Accent: Golden Amber */
22 + --sa-accent: #f4a623;
23 + --sa-accent-light: #ffc857;
24 + --sa-accent-dark: #d48e15;
25 +
26 + /* Neutrals: Warm Grays */
27 + --sa-gray-50: #faf9f7;
28 + --sa-gray-100: #f3f1ed;
29 + --sa-gray-200: #e8e5df;
30 + --sa-gray-300: #d4d0c8;
31 + --sa-gray-400: #a8a29e;
32 + --sa-gray-500: #78716c;
33 + --sa-gray-600: #57534e;
34 + --sa-gray-700: #44403c;
35 + --sa-gray-800: #292524;
36 + --sa-gray-900: #1c1917;
37 +
38 + /* Semantic */
39 + --sa-success: #22c55e;
40 + --sa-success-light: #dcfce7;
41 + --sa-warning: #f59e0b;
42 + --sa-warning-light: #fef3c7;
43 + --sa-danger: #ef4444;
44 + --sa-danger-light: #fee2e2;
45 + --sa-info: #3b82f6;
46 + --sa-info-light: #dbeafe;
47 +
48 + /* Spacing (4px base) */
49 + --sa-space-1: 4px;
50 + --sa-space-2: 8px;
51 + --sa-space-3: 12px;
52 + --sa-space-4: 16px;
53 + --sa-space-5: 20px;
54 + --sa-space-6: 24px;
55 + --sa-space-8: 32px;
56 + --sa-space-10: 40px;
57 + --sa-space-12: 48px;
58 + --sa-space-16: 64px;
59 +
60 + /* Border Radius */
61 + --sa-radius-sm: 6px;
62 + --sa-radius-md: 12px;
63 + --sa-radius-lg: 16px;
64 + --sa-radius-xl: 24px;
65 + --sa-radius-full: 9999px;
66 +
67 + /* Shadows (warm-tinted with coral accent) */
68 + --sa-shadow-sm: 0 1px 3px rgba(28, 25, 23, 0.06), 0 1px 2px rgba(28, 25, 23, 0.04);
69 + --sa-shadow-md: 0 4px 12px rgba(28, 25, 23, 0.08), 0 2px 4px rgba(28, 25, 23, 0.04);
70 + --sa-shadow-lg: 0 12px 32px rgba(28, 25, 23, 0.1), 0 4px 8px rgba(28, 25, 23, 0.06);
71 + --sa-shadow-xl: 0 20px 48px rgba(28, 25, 23, 0.14), 0 8px 16px rgba(28, 25, 23, 0.06);
72 + --sa-shadow-glass: 0 8px 32px rgba(232, 96, 76, 0.08), 0 2px 8px rgba(28, 25, 23, 0.06);
73 +
74 + /* Transitions */
75 + --sa-transition-fast: 150ms ease;
76 + --sa-transition-normal: 250ms ease;
77 + --sa-transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
78 +
79 + /* Typography */
80 + --sa-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
81 + --sa-font-mono: 'JetBrains Mono', 'Fira Code', monospace;
82 +}
83 +
84 +/* ---------- Base Overrides ---------- */
85 +html {
86 + scroll-behavior: smooth;
87 +}
88 +
89 +body {
90 + font-family: var(--sa-font-sans);
91 + background: var(--sa-gray-50);
92 + background-image: radial-gradient(at 20% 0%, rgba(232, 96, 76, 0.04) 0%, transparent 60%),
93 + radial-gradient(at 80% 100%, rgba(26, 158, 143, 0.04) 0%, transparent 60%);
94 + color: var(--sa-gray-800);
95 + font-size: 15px;
96 + -webkit-font-smoothing: antialiased;
97 + -moz-osx-font-smoothing: grayscale;
98 + margin-bottom: 0;
99 + min-height: 100vh;
100 +}
101 +
102 +h1, h2, h3, h4, h5, h6 {
103 + color: var(--sa-gray-900);
104 + letter-spacing: -0.02em;
105 +}
106 +
107 +h1 { font-size: 1.875rem; font-weight: 800; }
108 +h2 { font-size: 1.5rem; font-weight: 700; }
109 +h3 { font-size: 1.25rem; font-weight: 600; }
110 +
111 +a {
112 + color: var(--sa-secondary);
113 + text-decoration: none;
114 + transition: color var(--sa-transition-fast);
115 +}
116 +
117 +a:hover {
118 + color: var(--sa-secondary-dark);
119 +}
120 +
121 +/* Focus rings */
122 +:focus-visible {
123 + outline: 2px solid var(--sa-secondary);
124 + outline-offset: 2px;
125 + box-shadow: none;
126 +}
127 +
128 +.btn:focus-visible, .form-control:focus-visible, .form-select:focus-visible, .form-check-input:focus-visible {
129 + box-shadow: 0 0 0 3px rgba(26, 158, 143, 0.25);
130 + border-color: var(--sa-secondary);
131 +}
132 +
133 +/* ---------- Form Enhancements ---------- */
134 +.form-control, .form-select {
135 + border-radius: var(--sa-radius-sm);
136 + border: 1.5px solid var(--sa-gray-200);
137 + padding: 10px 14px;
138 + font-size: 0.938rem;
139 + transition: border-color var(--sa-transition-fast), box-shadow var(--sa-transition-fast);
140 + background-color: #fff;
141 +}
142 +
143 +.form-control:focus, .form-select:focus {
144 + border-color: var(--sa-secondary);
145 + box-shadow: 0 0 0 3px rgba(26, 158, 143, 0.15);
146 +}
147 +
148 +.form-label {
149 + font-weight: 600;
150 + font-size: 0.85rem;
151 + color: var(--sa-gray-600);
152 + text-transform: uppercase;
153 + letter-spacing: 0.04em;
154 + margin-bottom: 6px;
155 +}
156 +
157 +.form-check-input:checked {
158 + background-color: var(--sa-secondary);
159 + border-color: var(--sa-secondary);
160 +}
161 +
162 +/* ---------- sa-card ---------- */
163 +.sa-card {
164 + background: rgba(255, 255, 255, 0.72);
165 + backdrop-filter: blur(16px);
166 + -webkit-backdrop-filter: blur(16px);
167 + border-radius: var(--sa-radius-lg);
168 + box-shadow: var(--sa-shadow-glass);
169 + border: 1px solid rgba(255, 255, 255, 0.3);
170 + overflow: hidden;
171 + transition: transform var(--sa-transition-normal), box-shadow var(--sa-transition-normal);
172 +}
173 +
174 +.sa-card:hover {
175 + transform: translateY(-2px);
176 + box-shadow: var(--sa-shadow-lg);
177 +}
178 +
179 +.sa-card-static {
180 + background: rgba(255, 255, 255, 0.72);
181 + backdrop-filter: blur(16px);
182 + -webkit-backdrop-filter: blur(16px);
183 + border-radius: var(--sa-radius-lg);
184 + box-shadow: var(--sa-shadow-glass);
185 + border: 1px solid rgba(255, 255, 255, 0.3);
186 + overflow: hidden;
187 +}
188 +
189 +.sa-card-body {
190 + padding: var(--sa-space-6);
191 +}
192 +
193 +.sa-card-header {
194 + padding: var(--sa-space-5) var(--sa-space-6);
195 + border-bottom: 1px solid var(--sa-gray-100);
196 + font-weight: 600;
197 +}
198 +
199 +.sa-card-footer {
200 + padding: var(--sa-space-4) var(--sa-space-6);
201 + border-top: 1px solid var(--sa-gray-100);
202 + background: var(--sa-gray-50);
203 +}
204 +
205 +.sa-card-accent {
206 + border-top: 3px solid;
207 +}
208 +
209 +.sa-card-accent-primary { border-top-color: var(--sa-primary); }
210 +.sa-card-accent-secondary { border-top-color: var(--sa-secondary); }
211 +.sa-card-accent-accent { border-top-color: var(--sa-accent); }
212 +.sa-card-accent-danger { border-top-color: var(--sa-danger); }
213 +
214 +/* Colored gradient strip on top */
215 +.sa-card-gradient-strip {
216 + height: 4px;
217 + background: var(--sa-primary-gradient);
218 +}
219 +
220 +.sa-card-gradient-strip-teal {
221 + background: var(--sa-secondary-gradient);
222 +}
223 +
224 +.sa-card-gradient-strip-amber {
225 + background: linear-gradient(135deg, var(--sa-accent) 0%, var(--sa-primary) 100%);
226 +}
227 +
228 +/* ---------- Buttons ---------- */
229 +.sa-btn {
230 + display: inline-flex;
231 + align-items: center;
232 + justify-content: center;
233 + gap: 8px;
234 + padding: 10px 20px;
235 + font-weight: 600;
236 + font-size: 0.938rem;
237 + border-radius: 12px;
238 + border: none;
239 + cursor: pointer;
240 + transition: all 0.2s ease;
241 + text-decoration: none;
242 + line-height: 1.4;
243 +}
244 +
245 +.sa-btn:hover {
246 + transform: translateY(-1px);
247 + text-decoration: none;
248 +}
249 +
250 +.sa-btn:active {
251 + transform: translateY(0);
252 +}
253 +
254 +.sa-btn-primary {
255 + background: var(--sa-primary-gradient);
256 + color: #fff;
257 + box-shadow: 0 2px 8px rgba(232, 96, 76, 0.3);
258 +}
259 +
260 +.sa-btn-primary:hover {
261 + background: linear-gradient(135deg, #f07060 0%, #de5578 100%);
262 + box-shadow: 0 6px 20px rgba(232, 96, 76, 0.45);
263 + transform: translateY(-1px);
264 + color: #fff;
265 +}
266 +
267 +.sa-btn-secondary {
268 + background: var(--sa-secondary-gradient);
269 + color: #fff;
270 + box-shadow: 0 2px 8px rgba(26, 158, 143, 0.3);
271 +}
272 +
273 +.sa-btn-secondary:hover {
274 + background: linear-gradient(135deg, #20b0a0 0%, #2986be 100%);
275 + box-shadow: 0 6px 20px rgba(26, 158, 143, 0.45);
276 + transform: translateY(-1px);
277 + color: #fff;
278 +}
279 +
280 +.sa-btn-accent {
281 + background: linear-gradient(135deg, var(--sa-accent) 0%, var(--sa-accent-dark) 100%);
282 + color: #fff;
283 + box-shadow: 0 2px 8px rgba(244, 166, 35, 0.3);
284 +}
285 +
286 +.sa-btn-ghost {
287 + background: transparent;
288 + color: var(--sa-gray-700);
289 + border: 1.5px solid var(--sa-gray-300);
290 +}
291 +
292 +.sa-btn-ghost:hover {
293 + background: var(--sa-gray-100);
294 + color: var(--sa-gray-900);
295 + border-color: var(--sa-gray-400);
296 +}
297 +
298 +.sa-btn-danger {
299 + background: linear-gradient(135deg, var(--sa-danger) 0%, #dc2626 100%);
300 + color: #fff;
301 +}
302 +
303 +.sa-btn-success {
304 + background: linear-gradient(135deg, var(--sa-success) 0%, #16a34a 100%);
305 + color: #fff;
306 +}
307 +
308 +.sa-btn-sm {
309 + padding: 6px 14px;
310 + font-size: 0.813rem;
311 +}
312 +
313 +.sa-btn-lg {
314 + padding: 14px 28px;
315 + font-size: 1.063rem;
316 +}
317 +
318 +.sa-btn-pill {
319 + border-radius: var(--sa-radius-full);
320 +}
321 +
322 +.sa-btn-icon {
323 + width: 40px;
324 + height: 40px;
325 + padding: 0;
326 + border-radius: var(--sa-radius-full);
327 + font-size: 1.1rem;
328 +}
329 +
330 +.sa-btn-icon.sa-btn-sm {
331 + width: 32px;
332 + height: 32px;
333 + font-size: 0.9rem;
334 +}
335 +
336 +/* Loading state */
337 +.sa-btn-loading {
338 + position: relative;
339 + pointer-events: none;
340 + opacity: 0.75;
341 +}
342 +
343 +.sa-btn-loading::after {
344 + content: '';
345 + position: absolute;
346 + width: 16px;
347 + height: 16px;
348 + border: 2px solid transparent;
349 + border-top-color: currentColor;
350 + border-radius: 50%;
351 + animation: sa-spin 0.6s linear infinite;
352 +}
353 +
354 +/* ---------- Badges ---------- */
355 +.sa-badge {
356 + display: inline-flex;
357 + align-items: center;
358 + gap: 4px;
359 + padding: 3px 10px;
360 + font-size: 0.75rem;
361 + font-weight: 600;
362 + border-radius: var(--sa-radius-full);
363 + letter-spacing: 0.02em;
364 +}
365 +
366 +.sa-badge-primary { background: rgba(232, 96, 76, 0.12); color: var(--sa-primary-dark); }
367 +.sa-badge-secondary { background: rgba(26, 158, 143, 0.12); color: var(--sa-secondary-dark); }
368 +.sa-badge-accent { background: rgba(244, 166, 35, 0.12); color: var(--sa-accent-dark); }
369 +.sa-badge-success { background: var(--sa-success-light); color: #15803d; }
370 +.sa-badge-warning { background: var(--sa-warning-light); color: #92400e; }
371 +.sa-badge-danger { background: var(--sa-danger-light); color: #dc2626; }
372 +.sa-badge-info { background: var(--sa-info-light); color: #1d4ed8; }
373 +.sa-badge-neutral { background: var(--sa-gray-100); color: var(--sa-gray-600); }
374 +
375 +.sa-badge-solid-success { background: var(--sa-success); color: #fff; }
376 +.sa-badge-solid-warning { background: var(--sa-warning); color: #fff; }
377 +.sa-badge-solid-danger { background: var(--sa-danger); color: #fff; }
378 +.sa-badge-solid-info { background: var(--sa-info); color: #fff; }
379 +.sa-badge-solid-secondary { background: var(--sa-secondary); color: #fff; }
380 +.sa-badge-solid-neutral { background: var(--sa-gray-400); color: #fff; }
381 +
382 +/* ---------- Progress Bar ---------- */
383 +.sa-progress {
384 + height: 10px;
385 + background: var(--sa-gray-100);
386 + border-radius: var(--sa-radius-full);
387 + overflow: hidden;
388 +}
389 +
390 +.sa-progress-bar {
391 + height: 100%;
392 + border-radius: var(--sa-radius-full);
393 + transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
394 + background: var(--sa-secondary-gradient);
395 +}
396 +
397 +.sa-progress-bar-success { background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%); }
398 +.sa-progress-bar-warning { background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%); }
399 +.sa-progress-bar-danger { background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%); }
400 +.sa-progress-bar-primary { background: var(--sa-primary-gradient); }
401 +
402 +.sa-progress-lg { height: 16px; }
403 +.sa-progress-sm { height: 6px; }
404 +
405 +/* ---------- Avatar ---------- */
406 +.sa-avatar {
407 + display: inline-flex;
408 + align-items: center;
409 + justify-content: center;
410 + width: 40px;
411 + height: 40px;
412 + border-radius: 50%;
413 + font-weight: 700;
414 + font-size: 0.875rem;
415 + color: #fff;
416 + text-transform: uppercase;
417 + flex-shrink: 0;
418 + border: 2px solid #fff;
419 + box-shadow: 0 1px 3px rgba(0,0,0,0.1);
420 +}
421 +
422 +.sa-avatar-sm { width: 32px; height: 32px; font-size: 0.75rem; }
423 +.sa-avatar-lg { width: 52px; height: 52px; font-size: 1.1rem; }
424 +.sa-avatar-xl { width: 64px; height: 64px; font-size: 1.3rem; }
425 +
426 +/* Avatar color palette */
427 +.sa-avatar-1 { background: #e8604c; }
428 +.sa-avatar-2 { background: #1a9e8f; }
429 +.sa-avatar-3 { background: #6366f1; }
430 +.sa-avatar-4 { background: #f59e0b; }
431 +.sa-avatar-5 { background: #ec4899; }
432 +.sa-avatar-6 { background: #14b8a6; }
433 +.sa-avatar-7 { background: #8b5cf6; }
434 +.sa-avatar-8 { background: #f97316; }
435 +
436 +/* Avatar stack (overlapping) */
437 +.sa-avatar-stack {
438 + display: flex;
439 +}
440 +
441 +.sa-avatar-stack .sa-avatar {
442 + margin-left: -10px;
443 +}
444 +
445 +.sa-avatar-stack .sa-avatar:first-child {
446 + margin-left: 0;
447 +}
448 +
449 +.sa-avatar-overflow {
450 + background: var(--sa-gray-200);
451 + color: var(--sa-gray-600);
452 + font-size: 0.7rem;
453 +}
454 +
455 +/* ---------- Stats ---------- */
456 +.sa-stat {
457 + text-align: center;
458 + padding: var(--sa-space-5);
459 +}
460 +
461 +.sa-stat-value {
462 + font-size: 1.75rem;
463 + font-weight: 800;
464 + line-height: 1.2;
465 + color: var(--sa-gray-900);
466 + letter-spacing: -0.03em;
467 + animation: sa-counter 0.6s ease-out both;
468 +}
469 +
470 +.sa-stat-label {
471 + font-size: 0.8rem;
472 + font-weight: 500;
473 + color: var(--sa-gray-500);
474 + text-transform: uppercase;
475 + letter-spacing: 0.06em;
476 + margin-top: 4px;
477 +}
478 +
479 +.sa-stat-icon {
480 + font-size: 1.5rem;
481 + margin-bottom: 8px;
482 + opacity: 0.8;
483 +}
484 +
485 +/* ---------- Empty State ---------- */
486 +.sa-empty {
487 + text-align: center;
488 + padding: var(--sa-space-16) var(--sa-space-6);
489 +}
490 +
491 +.sa-empty-icon {
492 + font-size: 3.5rem;
493 + color: var(--sa-gray-300);
494 + margin-bottom: var(--sa-space-4);
495 +}
496 +
497 +.sa-empty-title {
498 + font-size: 1.25rem;
499 + font-weight: 700;
500 + color: var(--sa-gray-700);
501 + margin-bottom: var(--sa-space-2);
502 +}
503 +
504 +.sa-empty-text {
505 + font-size: 0.938rem;
506 + color: var(--sa-gray-500);
507 + margin-bottom: var(--sa-space-6);
508 + max-width: 360px;
509 + margin-left: auto;
510 + margin-right: auto;
511 +}
512 +
513 +/* ---------- Gradient Header ---------- */
514 +.sa-gradient-header {
515 + background: linear-gradient(135deg, #e8604c 0%, #d4456a 40%, #1a9e8f 100%);
516 + color: #fff;
517 + padding: 32px 28px;
518 + margin: -1rem -0.75rem var(--sa-space-8);
519 + border-radius: var(--sa-radius-lg);
520 + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
521 +}
522 +
523 +.sa-gradient-header-coral {
524 + background: var(--sa-primary-gradient);
525 +}
526 +
527 +.sa-gradient-header-green {
528 + background: linear-gradient(135deg, #22c55e 0%, #1a9e8f 100%);
529 +}
530 +
531 +.sa-gradient-header-purple {
532 + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
533 +}
534 +
535 +.sa-gradient-header-blue {
536 + background: linear-gradient(135deg, #3b82f6 0%, #2176ae 100%);
537 +}
538 +
539 +.sa-gradient-header-accent {
540 + background: linear-gradient(135deg, var(--sa-accent) 0%, #f97316 100%);
541 +}
542 +
543 +.sa-gradient-header h1,
544 +.sa-gradient-header h2,
545 +.sa-gradient-header h3,
546 +.sa-gradient-header h4 {
547 + color: #fff;
548 +}
549 +
550 +.sa-gradient-header .sa-badge {
551 + background: rgba(255,255,255,0.2);
552 + color: #fff;
553 +}
554 +
555 +.sa-gradient-header .text-muted {
556 + color: rgba(255,255,255,0.8) !important;
557 +}
558 +
559 +/* ---------- Navigation Card ---------- */
560 +.sa-nav-card {
561 + display: flex;
562 + flex-direction: column;
563 + align-items: center;
564 + gap: 8px;
565 + padding: var(--sa-space-5) var(--sa-space-4);
566 + background: rgba(255, 255, 255, 0.72);
567 + backdrop-filter: blur(16px);
568 + -webkit-backdrop-filter: blur(16px);
569 + border-radius: var(--sa-radius-lg);
570 + box-shadow: var(--sa-shadow-glass);
571 + border: 1px solid rgba(255, 255, 255, 0.3);
572 + text-decoration: none;
573 + color: var(--sa-gray-700);
574 + transition: all var(--sa-transition-normal);
575 + text-align: center;
576 + cursor: pointer;
577 +}
578 +
579 +.sa-nav-card:hover {
580 + transform: translateY(-3px);
581 + box-shadow: var(--sa-shadow-md);
582 + color: var(--sa-gray-900);
583 + text-decoration: none;
584 +}
585 +
586 +.sa-nav-card-icon {
587 + width: 48px;
588 + height: 48px;
589 + border-radius: var(--sa-radius-md);
590 + display: flex;
591 + align-items: center;
592 + justify-content: center;
593 + font-size: 1.4rem;
594 +}
595 +
596 +.sa-nav-card-label {
597 + font-weight: 600;
598 + font-size: 0.875rem;
599 +}
600 +
601 +.sa-nav-card-count {
602 + font-size: 0.75rem;
603 + color: var(--sa-gray-500);
604 +}
605 +
606 +/* Nav card icon themes */
607 +.sa-nav-icon-expenses { background: rgba(232, 96, 76, 0.1); color: var(--sa-primary); }
608 +.sa-nav-icon-members { background: rgba(99, 102, 241, 0.1); color: #6366f1; }
609 +.sa-nav-icon-budget { background: rgba(34, 197, 94, 0.1); color: var(--sa-success); }
610 +.sa-nav-icon-wishlist { background: rgba(244, 166, 35, 0.1); color: var(--sa-accent); }
611 +.sa-nav-icon-polls { background: rgba(59, 130, 246, 0.1); color: var(--sa-info); }
612 +.sa-nav-icon-settlement { background: rgba(26, 158, 143, 0.1); color: var(--sa-secondary); }
613 +
614 +/* ---------- Floating Action Button ---------- */
615 +.sa-fab {
616 + position: fixed;
617 + bottom: 24px;
618 + right: 24px;
619 + width: 56px;
620 + height: 56px;
621 + border-radius: 50%;
622 + background: var(--sa-primary-gradient);
623 + color: #fff;
624 + display: flex;
625 + align-items: center;
626 + justify-content: center;
627 + font-size: 1.5rem;
628 + border: none;
629 + cursor: pointer;
630 + box-shadow: 0 4px 16px rgba(232, 96, 76, 0.4);
631 + transition: all var(--sa-transition-normal);
632 + z-index: 1000;
633 + text-decoration: none;
634 +}
635 +
636 +.sa-fab:hover {
637 + transform: scale(1.1);
638 + box-shadow: 0 6px 24px rgba(232, 96, 76, 0.5);
639 + color: #fff;
640 +}
641 +
642 +@media (min-width: 768px) {
643 + .sa-fab {
644 + display: none;
645 + }
646 +}
647 +
648 +/* ---------- Amount Display ---------- */
649 +.sa-amount {
650 + font-variant-numeric: tabular-nums;
651 + font-weight: 700;
652 +}
653 +
654 +.sa-amount-lg {
655 + font-size: 2rem;
656 + letter-spacing: -0.02em;
657 +}
658 +
659 +.sa-amount-positive { color: var(--sa-success); }
660 +.sa-amount-negative { color: var(--sa-danger); }
661 +
662 +/* ---------- Balance Bar ---------- */
663 +.sa-balance-bar-container {
664 + display: flex;
665 + align-items: center;
666 + gap: 8px;
667 + height: 28px;
668 +}
669 +
670 +.sa-balance-bar-track {
671 + flex: 1;
672 + display: flex;
673 + align-items: center;
674 + justify-content: center;
675 + position: relative;
676 + height: 8px;
677 + background: var(--sa-gray-100);
678 + border-radius: var(--sa-radius-full);
679 +}
680 +
681 +.sa-balance-bar-fill {
682 + position: absolute;
683 + height: 100%;
684 + border-radius: var(--sa-radius-full);
685 + transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
686 +}
687 +
688 +.sa-balance-bar-positive {
689 + right: 50%;
690 + left: auto;
691 + background: var(--sa-success);
692 +}
693 +
694 +.sa-balance-bar-negative {
695 + left: 50%;
696 + right: auto;
697 + background: var(--sa-danger);
698 +}
699 +
700 +.sa-balance-bar-center {
701 + position: absolute;
702 + width: 2px;
703 + height: 16px;
704 + background: var(--sa-gray-400);
705 + left: 50%;
706 + transform: translateX(-50%);
707 +}
708 +
709 +/* ---------- Settlement Flow Card ---------- */
710 +.sa-settlement-card {
711 + display: flex;
712 + align-items: center;
713 + gap: var(--sa-space-4);
714 + padding: var(--sa-space-4) var(--sa-space-5);
715 + background: rgba(255, 255, 255, 0.72);
716 + backdrop-filter: blur(16px);
717 + -webkit-backdrop-filter: blur(16px);
718 + border-radius: var(--sa-radius-lg);
719 + box-shadow: var(--sa-shadow-glass);
720 + border: 1px solid rgba(255, 255, 255, 0.3);
721 +}
722 +
723 +.sa-settlement-arrow {
724 + color: var(--sa-gray-400);
725 + font-size: 1.2rem;
726 + flex-shrink: 0;
727 +}
728 +
729 +.sa-settlement-amount {
730 + font-size: 1.1rem;
731 + font-weight: 700;
732 + color: var(--sa-gray-900);
733 + flex-shrink: 0;
734 +}
735 +
736 +/* Status dots */
737 +.sa-status-dot {
738 + width: 10px;
739 + height: 10px;
740 + border-radius: 50%;
741 + display: inline-block;
742 +}
743 +
744 +.sa-status-dot-pending { background: var(--sa-gray-300); }
745 +.sa-status-dot-active { background: var(--sa-success); }
746 +.sa-status-dot-warning { background: var(--sa-warning); }
747 +.sa-status-dot-pulse {
748 + animation: sa-pulse 2s ease-in-out infinite;
749 +}
750 +
751 +/* ---------- Toggle Switch ---------- */
752 +.sa-toggle {
753 + position: relative;
754 + width: 44px;
755 + height: 24px;
756 + appearance: none;
757 + -webkit-appearance: none;
758 + background: var(--sa-gray-300);
759 + border-radius: var(--sa-radius-full);
760 + outline: none;
761 + cursor: pointer;
762 + transition: background var(--sa-transition-fast);
763 +}
764 +
765 +.sa-toggle:checked {
766 + background: var(--sa-secondary);
767 +}
768 +
769 +.sa-toggle::before {
770 + content: '';
771 + position: absolute;
772 + top: 2px;
773 + left: 2px;
774 + width: 20px;
775 + height: 20px;
776 + border-radius: 50%;
777 + background: #fff;
778 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
779 + transition: transform var(--sa-transition-fast);
780 +}
781 +
782 +.sa-toggle:checked::before {
783 + transform: translateX(20px);
784 +}
785 +
786 +/* ---------- Toast ---------- */
787 +.sa-toast-container {
788 + position: fixed;
789 + top: 20px;
790 + right: 20px;
791 + z-index: 9999;
792 + display: flex;
793 + flex-direction: column;
794 + gap: 8px;
795 + pointer-events: none;
796 +}
797 +
798 +.sa-toast {
799 + display: flex;
800 + align-items: center;
801 + gap: 10px;
802 + padding: 12px 20px;
803 + background: #fff;
804 + border-radius: var(--sa-radius-md);
805 + box-shadow: var(--sa-shadow-lg);
806 + border-left: 4px solid var(--sa-secondary);
807 + font-size: 0.875rem;
808 + font-weight: 500;
809 + pointer-events: auto;
810 + animation: sa-slide-in-right 0.3s ease-out;
811 + max-width: 380px;
812 +}
813 +
814 +.sa-toast-success { border-left-color: var(--sa-success); }
815 +.sa-toast-error { border-left-color: var(--sa-danger); }
816 +.sa-toast-warning { border-left-color: var(--sa-warning); }
817 +
818 +.sa-toast-dismiss {
819 + animation: sa-fade-out 0.3s ease-in forwards;
820 +}
821 +
822 +/* ---------- Expense List Item ---------- */
823 +.sa-expense-item {
824 + display: flex;
825 + align-items: center;
826 + gap: var(--sa-space-4);
827 + padding: var(--sa-space-4) var(--sa-space-5);
828 + border-bottom: 1px solid var(--sa-gray-100);
829 + transition: background var(--sa-transition-fast);
830 +}
831 +
832 +.sa-expense-item:last-child {
833 + border-bottom: none;
834 +}
835 +
836 +.sa-expense-item:hover {
837 + background: var(--sa-gray-50);
838 +}
839 +
840 +.sa-expense-icon {
841 + width: 40px;
842 + height: 40px;
843 + border-radius: var(--sa-radius-sm);
844 + display: flex;
845 + align-items: center;
846 + justify-content: center;
847 + font-size: 1rem;
848 + flex-shrink: 0;
849 +}
850 +
851 +.sa-expense-details {
852 + flex: 1;
853 + min-width: 0;
854 +}
855 +
856 +.sa-expense-desc {
857 + font-weight: 600;
858 + font-size: 0.938rem;
859 + color: var(--sa-gray-800);
860 + white-space: nowrap;
861 + overflow: hidden;
862 + text-overflow: ellipsis;
863 +}
864 +
865 +.sa-expense-meta {
866 + font-size: 0.8rem;
867 + color: var(--sa-gray-500);
868 + margin-top: 2px;
869 +}
870 +
871 +.sa-expense-amount {
872 + font-weight: 700;
873 + font-size: 1rem;
874 + color: var(--sa-gray-900);
875 + text-align: right;
876 + flex-shrink: 0;
877 +}
878 +
879 +.sa-expense-actions {
880 + display: flex;
881 + gap: 4px;
882 + min-width: 72px;
883 + justify-content: flex-end;
884 + opacity: 0;
885 + transition: opacity var(--sa-transition-fast);
886 +}
887 +
888 +.sa-expense-item:hover .sa-expense-actions {
889 + opacity: 1;
890 +}
891 +
892 +@media (max-width: 767px) {
893 + .sa-expense-actions {
894 + opacity: 1;
895 + }
896 +}
897 +
898 +/* ---------- Split Method Cards ---------- */
899 +.sa-split-methods {
900 + display: grid;
901 + grid-template-columns: repeat(2, 1fr);
902 + gap: 8px;
903 +}
904 +
905 +@media (min-width: 576px) {
906 + .sa-split-methods {
907 + grid-template-columns: repeat(4, 1fr);
908 + }
909 +}
910 +
911 +.sa-split-option {
912 + position: relative;
913 + cursor: pointer;
914 +}
915 +
916 +.sa-split-option input {
917 + position: absolute;
918 + opacity: 0;
919 + width: 0;
920 + height: 0;
921 +}
922 +
923 +.sa-split-option-label {
924 + display: flex;
925 + flex-direction: column;
926 + align-items: center;
927 + gap: 6px;
928 + padding: var(--sa-space-4) var(--sa-space-3);
929 + border: 2px solid var(--sa-gray-200);
930 + border-radius: var(--sa-radius-md);
931 + text-align: center;
932 + transition: all var(--sa-transition-fast);
933 + background: #fff;
934 +}
935 +
936 +.sa-split-option input:checked + .sa-split-option-label {
937 + border-color: var(--sa-secondary);
938 + background: rgba(26, 158, 143, 0.06);
939 + box-shadow: 0 0 0 1px var(--sa-secondary);
940 +}
941 +
942 +.sa-split-option-icon {
943 + font-size: 1.4rem;
944 + color: var(--sa-gray-500);
945 +}
946 +
947 +.sa-split-option input:checked + .sa-split-option-label .sa-split-option-icon {
948 + color: var(--sa-secondary);
949 +}
950 +
951 +.sa-split-option-text {
952 + font-size: 0.75rem;
953 + font-weight: 600;
954 + color: var(--sa-gray-600);
955 +}
956 +
957 +/* ---------- Poll Option Card ---------- */
958 +.sa-poll-option {
959 + border: 2px solid var(--sa-gray-200);
960 + border-radius: var(--sa-radius-md);
961 + padding: var(--sa-space-4) var(--sa-space-5);
962 + margin-bottom: var(--sa-space-3);
963 + transition: all var(--sa-transition-fast);
964 + background: #fff;
965 +}
966 +
967 +.sa-poll-option-voted {
968 + border-color: var(--sa-secondary);
969 + background: rgba(26, 158, 143, 0.03);
970 +}
971 +
972 +.sa-poll-option-winner {
973 + border-color: var(--sa-accent);
974 + background: rgba(244, 166, 35, 0.05);
975 +}
976 +
977 +/* ---------- Wishlist Vote Button ---------- */
978 +.sa-vote-btn {
979 + display: inline-flex;
980 + align-items: center;
981 + gap: 6px;
982 + padding: 6px 14px;
983 + border: 1.5px solid var(--sa-gray-300);
984 + border-radius: var(--sa-radius-full);
985 + background: #fff;
986 + color: var(--sa-gray-600);
987 + font-size: 0.85rem;
988 + font-weight: 600;
989 + cursor: pointer;
990 + transition: all var(--sa-transition-fast);
991 +}
992 +
993 +.sa-vote-btn:hover {
994 + border-color: var(--sa-primary);
995 + color: var(--sa-primary);
996 +}
997 +
998 +.sa-vote-btn-active {
999 + border-color: var(--sa-primary);
1000 + background: rgba(232, 96, 76, 0.08);
1001 + color: var(--sa-primary);
1002 +}
1003 +
1004 +/* ---------- Navbar ---------- */
1005 +.sa-navbar {
1006 + position: sticky;
1007 + top: 0;
1008 + z-index: 1050;
1009 + background: rgba(255, 255, 255, 0.85);
1010 + backdrop-filter: blur(12px);
1011 + -webkit-backdrop-filter: blur(12px);
1012 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
1013 + padding: 0 var(--sa-space-4);
1014 +}
1015 +
1016 +.sa-navbar-brand {
1017 + font-weight: 800;
1018 + font-size: 1.25rem;
1019 + color: var(--sa-primary) !important;
1020 + display: flex;
1021 + align-items: center;
1022 + gap: 8px;
1023 + text-decoration: none;
1024 +}
1025 +
1026 +.sa-navbar-brand:hover {
1027 + color: var(--sa-primary-dark) !important;
1028 +}
1029 +
1030 +.sa-navbar .nav-link {
1031 + font-weight: 500;
1032 + color: var(--sa-gray-600) !important;
1033 + padding: 8px 16px !important;
1034 + border-radius: var(--sa-radius-sm);
1035 + transition: all var(--sa-transition-fast);
1036 +}
1037 +
1038 +.sa-navbar .nav-link:hover,
1039 +.sa-navbar .nav-link.active {
1040 + color: var(--sa-gray-900) !important;
1041 + background: var(--sa-gray-100);
1042 +}
1043 +
1044 +.sa-nav-avatar-btn {
1045 + display: flex;
1046 + align-items: center;
1047 + gap: 8px;
1048 + padding: 4px 12px 4px 4px;
1049 + background: var(--sa-gray-100);
1050 + border-radius: var(--sa-radius-full);
1051 + border: none;
1052 + cursor: pointer;
1053 + font-size: 0.875rem;
1054 + font-weight: 500;
1055 + color: var(--sa-gray-700);
1056 + transition: background var(--sa-transition-fast);
1057 +}
1058 +
1059 +.sa-nav-avatar-btn:hover {
1060 + background: var(--sa-gray-200);
1061 +}
1062 +
1063 +.sa-navbar-auth-btns {
1064 + display: flex;
1065 + gap: 8px;
1066 + align-items: center;
1067 +}
1068 +
1069 +/* ---------- Hero Section ---------- */
1070 +.sa-hero {
1071 + text-align: center;
1072 + padding: var(--sa-space-16) var(--sa-space-4);
1073 + background: var(--sa-primary-gradient);
1074 + color: #fff;
1075 + margin: -1rem -0.75rem var(--sa-space-12);
1076 + border-radius: 0 0 var(--sa-radius-lg) var(--sa-radius-lg);
1077 +}
1078 +
1079 +.sa-hero h1 {
1080 + color: #fff;
1081 + font-size: 2.75rem;
1082 + font-weight: 800;
1083 + letter-spacing: -0.03em;
1084 + margin-bottom: var(--sa-space-4);
1085 +}
1086 +
1087 +.sa-hero p {
1088 + font-size: 1.2rem;
1089 + opacity: 0.9;
1090 + max-width: 540px;
1091 + margin: 0 auto var(--sa-space-8);
1092 +}
1093 +
1094 +.sa-hero-btns {
1095 + display: flex;
1096 + gap: 12px;
1097 + justify-content: center;
1098 + flex-wrap: wrap;
1099 +}
1100 +
1101 +.sa-hero-btn-white {
1102 + background: #fff;
1103 + color: var(--sa-primary);
1104 + font-weight: 700;
1105 + padding: 12px 28px;
1106 + border-radius: var(--sa-radius-full);
1107 + border: none;
1108 + font-size: 1rem;
1109 + transition: all var(--sa-transition-fast);
1110 + text-decoration: none;
1111 + display: inline-flex;
1112 + align-items: center;
1113 + gap: 8px;
1114 +}
1115 +
1116 +.sa-hero-btn-white:hover {
1117 + transform: translateY(-2px);
1118 + box-shadow: 0 4px 16px rgba(0,0,0,0.15);
1119 + color: var(--sa-primary);
1120 +}
1121 +
1122 +.sa-hero-btn-outline {
1123 + background: transparent;
1124 + color: #fff;
1125 + font-weight: 600;
1126 + padding: 12px 28px;
1127 + border-radius: var(--sa-radius-full);
1128 + border: 2px solid rgba(255,255,255,0.4);
1129 + font-size: 1rem;
1130 + transition: all var(--sa-transition-fast);
1131 + text-decoration: none;
1132 + display: inline-flex;
1133 + align-items: center;
1134 + gap: 8px;
1135 +}
1136 +
1137 +.sa-hero-btn-outline:hover {
1138 + background: rgba(255,255,255,0.15);
1139 + border-color: rgba(255,255,255,0.7);
1140 + color: #fff;
1141 +}
1142 +
1143 +@media (max-width: 767px) {
1144 + .sa-hero h1 { font-size: 2rem; }
1145 + .sa-hero { padding-top: var(--sa-space-10); padding-bottom: var(--sa-space-10); }
1146 +}
1147 +
1148 +/* ---------- Feature Cards ---------- */
1149 +.sa-feature-card {
1150 + text-align: center;
1151 + padding: var(--sa-space-8) var(--sa-space-6);
1152 +}
1153 +
1154 +.sa-feature-icon {
1155 + width: 64px;
1156 + height: 64px;
1157 + border-radius: var(--sa-radius-lg);
1158 + display: flex;
1159 + align-items: center;
1160 + justify-content: center;
1161 + font-size: 1.75rem;
1162 + margin: 0 auto var(--sa-space-4);
1163 +}
1164 +
1165 +.sa-feature-title {
1166 + font-size: 1.1rem;
1167 + font-weight: 700;
1168 + margin-bottom: var(--sa-space-2);
1169 +}
1170 +
1171 +.sa-feature-text {
1172 + font-size: 0.938rem;
1173 + color: var(--sa-gray-500);
1174 + line-height: 1.6;
1175 +}
1176 +
1177 +/* ---------- Confirmation Card ---------- */
1178 +.sa-confirm-card {
1179 + max-width: 480px;
1180 + margin: var(--sa-space-8) auto;
1181 +}
1182 +
1183 +.sa-confirm-icon {
1184 + width: 64px;
1185 + height: 64px;
1186 + border-radius: 50%;
1187 + display: flex;
1188 + align-items: center;
1189 + justify-content: center;
1190 + font-size: 1.75rem;
1191 + margin: 0 auto var(--sa-space-4);
1192 +}
1193 +
1194 +.sa-confirm-icon-danger { background: var(--sa-danger-light); color: var(--sa-danger); }
1195 +.sa-confirm-icon-success { background: var(--sa-success-light); color: var(--sa-success); }
1196 +.sa-confirm-icon-warning { background: var(--sa-warning-light); color: var(--sa-warning); }
1197 +
1198 +/* ---------- Big Amount Input ---------- */
1199 +.sa-amount-input {
1200 + font-size: 2.5rem;
1201 + font-weight: 800;
1202 + text-align: center;
1203 + border: none;
1204 + border-bottom: 3px solid var(--sa-gray-200);
1205 + border-radius: 0;
1206 + padding: var(--sa-space-4);
1207 + background: transparent;
1208 + letter-spacing: -0.02em;
1209 +}
1210 +
1211 +.sa-amount-input:focus {
1212 + border-color: var(--sa-secondary);
1213 + box-shadow: none;
1214 + outline: none;
1215 +}
1216 +
1217 +.sa-amount-input::placeholder {
1218 + color: var(--sa-gray-300);
1219 +}
1220 +
1221 +/* ---------- Animations ---------- */
1222 +@keyframes sa-fade-in {
1223 + from { opacity: 0; transform: translateY(8px); }
1224 + to { opacity: 1; transform: translateY(0); }
1225 +}
1226 +
1227 +@keyframes sa-slide-up {
1228 + from { opacity: 0; transform: translateY(8px); }
1229 + to { opacity: 1; transform: translateY(0); }
1230 +}
1231 +
1232 +@keyframes sa-counter {
1233 + from { opacity: 0; transform: translateY(10px); }
1234 + to { opacity: 1; transform: translateY(0); }
1235 +}
1236 +
1237 +@keyframes sa-slide-in-right {
1238 + from { opacity: 0; transform: translateX(100px); }
1239 + to { opacity: 1; transform: translateX(0); }
1240 +}
1241 +
1242 +@keyframes sa-fade-out {
1243 + from { opacity: 1; transform: translateX(0); }
1244 + to { opacity: 0; transform: translateX(100px); }
1245 +}
1246 +
1247 +@keyframes sa-scale-in {
1248 + from { opacity: 0; transform: scale(0.9); }
1249 + to { opacity: 1; transform: scale(1); }
1250 +}
1251 +
1252 +@keyframes sa-spin {
1253 + to { transform: rotate(360deg); }
1254 +}
1255 +
1256 +@keyframes sa-pulse {
1257 + 0%, 100% { opacity: 1; }
1258 + 50% { opacity: 0.5; }
1259 +}
1260 +
1261 +@keyframes sa-bounce-in {
1262 + 0% { transform: scale(0); }
1263 + 50% { transform: scale(1.15); }
1264 + 100% { transform: scale(1); }
1265 +}
1266 +
1267 +@keyframes sa-progress-fill {
1268 + from { width: 0; }
1269 +}
1270 +
1271 +.sa-animate-fade-in { animation: sa-fade-in 0.5s ease-out both; }
1272 +.sa-animate-slide-up { animation: sa-slide-up 0.5s ease-out both; }
1273 +.sa-animate-scale-in { animation: sa-scale-in 0.4s ease-out both; }
1274 +.sa-animate-bounce-in { animation: sa-bounce-in 0.5s ease-out both; }
1275 +.sa-animate-counter { animation: sa-counter 0.6s ease-out both; }
1276 +
1277 +/* Hover lift effect */
1278 +.sa-hover-lift {
1279 + transition: transform 0.2s ease, box-shadow 0.2s ease;
1280 +}
1281 +
1282 +.sa-hover-lift:hover {
1283 + transform: translateY(-2px);
1284 + box-shadow: var(--sa-shadow-lg);
1285 +}
1286 +
1287 +/* Stagger delays */
1288 +.sa-stagger-1 { animation-delay: 0.05s; }
1289 +.sa-stagger-2 { animation-delay: 0.1s; }
1290 +.sa-stagger-3 { animation-delay: 0.15s; }
1291 +.sa-stagger-4 { animation-delay: 0.2s; }
1292 +.sa-stagger-5 { animation-delay: 0.25s; }
1293 +.sa-stagger-6 { animation-delay: 0.3s; }
1294 +
1295 +/* Scroll-triggered */
1296 +.sa-animate-on-scroll {
1297 + opacity: 0;
1298 + transform: translateY(20px);
1299 + transition: opacity 0.6s ease-out, transform 0.6s ease-out;
1300 +}
1301 +
1302 +.sa-animate-on-scroll.sa-visible {
1303 + opacity: 1;
1304 + transform: translateY(0);
1305 +}
1306 +
1307 +/* ---------- Utility Classes ---------- */
1308 +.sa-text-gradient {
1309 + background: var(--sa-primary-gradient);
1310 + -webkit-background-clip: text;
1311 + -webkit-text-fill-color: transparent;
1312 + background-clip: text;
1313 +}
1314 +
1315 +.sa-text-primary { color: var(--sa-primary); }
1316 +.sa-text-secondary { color: var(--sa-secondary); }
1317 +.sa-text-accent { color: var(--sa-accent); }
1318 +.sa-text-muted { color: var(--sa-gray-500); }
1319 +
1320 +.sa-bg-soft-primary { background: rgba(232, 96, 76, 0.08); }
1321 +.sa-bg-soft-secondary { background: rgba(26, 158, 143, 0.08); }
1322 +.sa-bg-soft-success { background: var(--sa-success-light); }
1323 +.sa-bg-soft-danger { background: var(--sa-danger-light); }
1324 +.sa-bg-soft-warning { background: var(--sa-warning-light); }
1325 +
1326 +.sa-rounded { border-radius: var(--sa-radius-md); }
1327 +.sa-rounded-lg { border-radius: var(--sa-radius-lg); }
1328 +
1329 +.sa-shadow { box-shadow: var(--sa-shadow-md); }
1330 +.sa-shadow-lg { box-shadow: var(--sa-shadow-lg); }
1331 +
1332 +.sa-divider {
1333 + height: 1px;
1334 + background: var(--sa-gray-100);
1335 + margin: var(--sa-space-4) 0;
1336 +}
1337 +
1338 +.sa-truncate {
1339 + white-space: nowrap;
1340 + overflow: hidden;
1341 + text-overflow: ellipsis;
1342 +}
1343 +
1344 +/* ---------- Category Colors ---------- */
1345 +.sa-category-food { background: #fee2e2; color: #dc2626; }
1346 +.sa-category-accommodation { background: #dbeafe; color: #2563eb; }
1347 +.sa-category-transport { background: #fef3c7; color: #d97706; }
1348 +.sa-category-activities { background: #dcfce7; color: #16a34a; }
1349 +.sa-category-shopping { background: #f3e8ff; color: #7c3aed; }
1350 +.sa-category-default { background: var(--sa-gray-100); color: var(--sa-gray-600); }
1351 +
1352 +/* ---------- Responsive Helpers ---------- */
1353 +@media (max-width: 575px) {
1354 + .sa-hide-mobile { display: none !important; }
1355 + .sa-gradient-header {
1356 + padding-top: var(--sa-space-6);
1357 + padding-bottom: var(--sa-space-6);
1358 + }
1359 + h1 { font-size: 1.5rem; }
1360 + .sa-stat-value { font-size: 1.4rem; }
1361 +}
1362 +
1363 +@media (min-width: 576px) {
1364 + .sa-hide-desktop { display: none !important; }
1365 +}
1366 +
1367 +/* ---------- Dropdown override ---------- */
1368 +.dropdown-menu {
1369 + border: 1px solid var(--sa-gray-100);
1370 + border-radius: var(--sa-radius-md);
1371 + box-shadow: var(--sa-shadow-lg);
1372 + padding: 6px;
1373 +}
1374 +
1375 +.dropdown-item {
1376 + border-radius: var(--sa-radius-sm);
1377 + padding: 8px 14px;
1378 + font-size: 0.875rem;
1379 + font-weight: 500;
1380 + transition: background var(--sa-transition-fast);
1381 +}
1382 +
1383 +.dropdown-item:hover {
1384 + background: var(--sa-gray-100);
1385 +}
1386 +
1387 +/* ---------- Table Override ---------- */
1388 +.sa-table {
1389 + width: 100%;
1390 + border-collapse: separate;
1391 + border-spacing: 0;
1392 +}
1393 +
1394 +.sa-table thead th {
1395 + font-size: 0.75rem;
1396 + font-weight: 600;
1397 + text-transform: uppercase;
1398 + letter-spacing: 0.06em;
1399 + color: var(--sa-gray-500);
1400 + border-bottom: 2px solid var(--sa-gray-100);
1401 + padding: var(--sa-space-3) var(--sa-space-4);
1402 +}
1403 +
1404 +.sa-table tbody td {
1405 + padding: var(--sa-space-3) var(--sa-space-4);
1406 + border-bottom: 1px solid var(--sa-gray-100);
1407 + font-size: 0.938rem;
1408 +}
1409 +
1410 +.sa-table tbody tr:hover {
1411 + background: var(--sa-gray-50);
1412 +}
1413 +
1414 +.sa-table tbody tr:last-child td {
1415 + border-bottom: none;
1416 +}
1417 +
1418 +/* ---------- Trip Card ---------- */
1419 +.sa-trip-card {
1420 + overflow: hidden;
1421 +}
1422 +
1423 +.sa-trip-card-gradient {
1424 + height: 6px;
1425 +}
1426 +
1427 +/* Generate variety with nth-child */
1428 +.sa-trip-card:nth-child(6n+1) .sa-trip-card-gradient { background: var(--sa-primary-gradient); }
1429 +.sa-trip-card:nth-child(6n+2) .sa-trip-card-gradient { background: var(--sa-secondary-gradient); }
1430 +.sa-trip-card:nth-child(6n+3) .sa-trip-card-gradient { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
1431 +.sa-trip-card:nth-child(6n+4) .sa-trip-card-gradient { background: linear-gradient(135deg, var(--sa-accent), #f97316); }
1432 +.sa-trip-card:nth-child(6n+5) .sa-trip-card-gradient { background: linear-gradient(135deg, #ec4899, #f43f5e); }
1433 +.sa-trip-card:nth-child(6n+6) .sa-trip-card-gradient { background: linear-gradient(135deg, #14b8a6, #06b6d4); }
1434 +
1435 +/* ---------- Invitation / Accept page ---------- */
1436 +.sa-invite-page {
1437 + min-height: 80vh;
1438 + display: flex;
1439 + align-items: center;
1440 + justify-content: center;
1441 +}
1442 +
1443 +.sa-invite-card {
1444 + max-width: 440px;
1445 + width: 100%;
1446 + text-align: center;
1447 +}
1448 +
1449 +.sa-invite-icon {
1450 + font-size: 3rem;
1451 + margin-bottom: var(--sa-space-4);
1452 +}
1453 +
1454 +/* Copy link input */
1455 +.sa-copy-group {
1456 + display: flex;
1457 + gap: 0;
1458 +}
1459 +
1460 +.sa-copy-input {
1461 + flex: 1;
1462 + border-radius: var(--sa-radius-sm) 0 0 var(--sa-radius-sm);
1463 + font-family: var(--sa-font-mono);
1464 + font-size: 0.85rem;
1465 +}
1466 +
1467 +.sa-copy-btn {
1468 + border-radius: 0 var(--sa-radius-sm) var(--sa-radius-sm) 0;
1469 + white-space: nowrap;
1470 +}
1471 +
1472 +/* ---------- Participant Chip ---------- */
1473 +.sa-participant-chip {
1474 + display: inline-flex;
1475 + align-items: center;
1476 + gap: 8px;
1477 + padding: 4px 12px 4px 4px;
1478 + background: var(--sa-gray-100);
1479 + border-radius: var(--sa-radius-full);
1480 + font-size: 0.85rem;
1481 + font-weight: 500;
1482 + cursor: pointer;
1483 + transition: all var(--sa-transition-fast);
1484 + border: 2px solid transparent;
1485 +}
1486 +
1487 +.sa-participant-chip.active {
1488 + border-color: var(--sa-secondary);
1489 + background: rgba(26, 158, 143, 0.08);
1490 +}
1491 +
1492 +.sa-participant-chip:hover {
1493 + background: var(--sa-gray-200);
1494 +}
1495 +
1496 +/* ---------- Auth Pages ---------- */
1497 +.sa-auth-page {
1498 + min-height: 80vh;
1499 + display: flex;
1500 + align-items: center;
1501 + justify-content: center;
1502 +}
1503 +
1504 +.sa-auth-card {
1505 + max-width: 420px;
1506 + width: 100%;
1507 +}
1508 +
1509 +.sa-auth-brand {
1510 + text-align: center;
1511 + margin-bottom: var(--sa-space-8);
1512 +}
1513 +
1514 +.sa-auth-brand h1 {
1515 + font-weight: 800;
1516 + font-size: 2rem;
1517 + color: var(--sa-primary);
1518 +}
1519 +
1520 +.sa-auth-brand p {
1521 + color: var(--sa-gray-500);
1522 + font-size: 0.938rem;
1523 +}
1524 +
1525 +/* ---------- Mobile Premium Overrides (< 768px) ---------- */
1526 +@media (max-width: 767px) {
1527 + .sa-card {
1528 + width: 100%;
1529 + border-radius: 12px;
1530 + }
1531 +
1532 + .sa-nav-card {
1533 + display: grid;
1534 + grid-template-columns: repeat(2, 1fr);
1535 + }
1536 +
1537 + .sa-btn,
1538 + .sa-btn-primary,
1539 + .sa-btn-secondary,
1540 + .sa-btn-ghost,
1541 + .sa-btn-danger,
1542 + .sa-btn-success,
1543 + .sa-btn-accent {
1544 + min-height: 44px;
1545 + min-width: 44px;
1546 + }
1547 +
1548 + .sa-form-sticky-submit {
1549 + position: sticky;
1550 + bottom: 0;
1551 + background: rgba(255, 255, 255, 0.92);
1552 + backdrop-filter: blur(12px);
1553 + -webkit-backdrop-filter: blur(12px);
1554 + padding: var(--sa-space-4);
1555 + margin: 0 calc(-1 * var(--sa-space-4));
1556 + border-top: 1px solid var(--sa-gray-100);
1557 + z-index: 10;
1558 + }
1559 +}
1560 +
1561 +/* ---------- Print ---------- */
1562 +@media print {
1563 + .sa-navbar, .sa-footer, .sa-fab, .sa-toast-container { display: none !important; }
1564 + .sa-card, .sa-card-static { box-shadow: none; border: 1px solid #ddd; }
1565 + body { background: #fff; }
1566 +}
added src/components/LangSwitcher.vue +61 −0
@@ -0,0 +1,61 @@
1 +<script setup lang="ts">
2 +import { useLangStore } from '@/stores/lang'
3 +import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n'
4 +
5 +const lang = useLangStore()
6 +
7 +function switchTo(code: AppLocale) {
8 + lang.setLocale(code)
9 +}
10 +</script>
11 +
12 +<template>
13 + <div class="sa-lang-switcher">
14 + <button
15 + v-for="code in SUPPORTED_LOCALES"
16 + :key="code"
17 + type="button"
18 + class="sa-lang-btn"
19 + :class="{ 'sa-lang-btn-active': lang.currentLocale === code }"
20 + @click="switchTo(code)"
21 + >
22 + {{ code.toUpperCase() }}
23 + </button>
24 + </div>
25 +</template>
26 +
27 +<style scoped>
28 +.sa-lang-switcher {
29 + display: inline-flex;
30 + align-items: center;
31 + gap: 2px;
32 + padding: 2px;
33 + border-radius: 999px;
34 + background: var(--sa-gray-100);
35 + border: 1px solid var(--sa-gray-200);
36 + margin-right: 12px;
37 +}
38 +
39 +.sa-lang-btn {
40 + border: 0;
41 + background: transparent;
42 + color: var(--sa-gray-500);
43 + font-size: 0.75rem;
44 + font-weight: 700;
45 + letter-spacing: 0.04em;
46 + padding: 4px 10px;
47 + border-radius: 999px;
48 + cursor: pointer;
49 + transition: all 0.15s ease;
50 +}
51 +
52 +.sa-lang-btn:hover {
53 + color: var(--sa-gray-700);
54 +}
55 +
56 +.sa-lang-btn-active {
57 + background: #fff;
58 + color: var(--sa-gray-900);
59 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
60 +}
61 +</style>
added src/components/SplitMethodSelector.vue +386 −0
@@ -0,0 +1,386 @@
1 +<script setup lang="ts">
2 +import { ref, computed, watch } from 'vue'
3 +import { useI18n } from 'vue-i18n'
4 +import type { ITripParticipant } from '@/types/ITrip'
5 +import type { IExpenseSplit, IExpenseSplitCreate } from '@/types/IExpense'
6 +
7 +const props = defineProps<{
8 + participants: ITripParticipant[]
9 + totalAmount: number
10 + splitMethod: string
11 + existingSplits?: IExpenseSplit[] | null
12 +}>()
13 +
14 +const emit = defineEmits<{
15 + 'update:splits': [splits: IExpenseSplitCreate[]]
16 + 'update:valid': [valid: boolean]
17 +}>()
18 +
19 +const { t } = useI18n()
20 +
21 +// EqualSubset: which participants are selected
22 +const selectedUserIds = ref<Set<string>>(new Set())
23 +
24 +// ExactAmounts: per-participant amount
25 +const exactAmounts = ref<Record<string, number>>({})
26 +
27 +// Percentages: per-participant percentage
28 +const percentages = ref<Record<string, number>>({})
29 +
30 +watch(
31 + () => props.existingSplits,
32 + (splits) => {
33 + if (!splits || splits.length === 0) return
34 + const ids = new Set(splits.map((s) => s.userId))
35 + selectedUserIds.value = ids
36 +
37 + const amts: Record<string, number> = {}
38 + const pcts: Record<string, number> = {}
39 + for (const s of splits) {
40 + amts[s.userId] = s.amount
41 + pcts[s.userId] = s.percentage ?? 0
42 + }
43 + exactAmounts.value = amts
44 + percentages.value = pcts
45 + },
46 + { immediate: true },
47 +)
48 +
49 +watch(
50 + () => props.splitMethod,
51 + (method) => {
52 + if (method === 'EqualSubset' && selectedUserIds.value.size === 0) {
53 + selectedUserIds.value = new Set(props.participants.map((p) => p.userId))
54 + }
55 + if (method === 'ExactAmounts') {
56 + for (const p of props.participants) {
57 + if (!(p.userId in exactAmounts.value)) {
58 + exactAmounts.value[p.userId] = 0
59 + }
60 + }
61 + }
62 + if (method === 'Percentages') {
63 + for (const p of props.participants) {
64 + if (!(p.userId in percentages.value)) {
65 + percentages.value[p.userId] = 0
66 + }
67 + }
68 + }
69 + },
70 + { immediate: true },
71 +)
72 +
73 +function toggleParticipant(userId: string) {
74 + const s = new Set(selectedUserIds.value)
75 + if (s.has(userId)) s.delete(userId)
76 + else s.add(userId)
77 + selectedUserIds.value = s
78 +}
79 +
80 +const equalAllSplits = computed<IExpenseSplitCreate[]>(() => {
81 + if (props.participants.length === 0) return []
82 + const amount = Math.round((props.totalAmount / props.participants.length) * 100) / 100
83 + return props.participants.map((p) => ({
84 + userId: p.userId,
85 + amount,
86 + percentage: null,
87 + }))
88 +})
89 +
90 +const equalSubsetSplits = computed<IExpenseSplitCreate[]>(() => {
91 + const selected = props.participants.filter((p) => selectedUserIds.value.has(p.userId))
92 + if (selected.length === 0) return []
93 + const amount = Math.round((props.totalAmount / selected.length) * 100) / 100
94 + return selected.map((p) => ({
95 + userId: p.userId,
96 + amount,
97 + percentage: null,
98 + }))
99 +})
100 +
101 +const exactSplits = computed<IExpenseSplitCreate[]>(() => {
102 + return props.participants.map((p) => ({
103 + userId: p.userId,
104 + amount: exactAmounts.value[p.userId] ?? 0,
105 + percentage: null,
106 + }))
107 +})
108 +
109 +const percentageSplits = computed<IExpenseSplitCreate[]>(() => {
110 + return props.participants.map((p) => {
111 + const pct = percentages.value[p.userId] ?? 0
112 + return {
113 + userId: p.userId,
114 + amount: Math.round(((pct / 100) * props.totalAmount) * 100) / 100,
115 + percentage: pct,
116 + }
117 + })
118 +})
119 +
120 +const exactTotal = computed(() =>
121 + Object.values(exactAmounts.value).reduce((sum, v) => sum + (v || 0), 0),
122 +)
123 +
124 +const percentageTotal = computed(() =>
125 + Object.values(percentages.value).reduce((sum, v) => sum + (v || 0), 0),
126 +)
127 +
128 +const isValid = computed(() => {
129 + switch (props.splitMethod) {
130 + case 'EqualAll':
131 + return props.participants.length > 0
132 + case 'EqualSubset':
133 + return selectedUserIds.value.size > 0
134 + case 'ExactAmounts':
135 + return Math.abs(exactTotal.value - props.totalAmount) < 0.01
136 + case 'Percentages':
137 + return Math.abs(percentageTotal.value - 100) < 0.01
138 + default:
139 + return true
140 + }
141 +})
142 +
143 +const currentSplits = computed<IExpenseSplitCreate[]>(() => {
144 + switch (props.splitMethod) {
145 + case 'EqualAll':
146 + return equalAllSplits.value
147 + case 'EqualSubset':
148 + return equalSubsetSplits.value
149 + case 'ExactAmounts':
150 + return exactSplits.value
151 + case 'Percentages':
152 + return percentageSplits.value
153 + default:
154 + return equalAllSplits.value
155 + }
156 +})
157 +
158 +watch(currentSplits, (splits) => emit('update:splits', splits), { immediate: true, deep: true })
159 +watch(isValid, (v) => emit('update:valid', v), { immediate: true })
160 +
161 +function getInitials(name: string | null) {
162 + if (!name) return '?'
163 + const parts = name.split(' ')
164 + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
165 + return name.substring(0, 2).toUpperCase()
166 +}
167 +</script>
168 +
169 +<template>
170 + <div class="sa-split-selector">
171 + <!-- EqualAll -->
172 + <div v-if="splitMethod === 'EqualAll'" class="sa-split-info">
173 + <div class="sa-split-info-card">
174 + <i class="bi bi-people-fill me-2" style="color: var(--sa-secondary)"></i>
175 + {{ t('expenses.split.equalAmong', { count: participants.length }) }}
176 + <span v-if="participants.length > 0" class="ms-1" style="color: var(--sa-gray-500)">
177 + {{ t('expenses.split.eachGets', { amount: (totalAmount / participants.length).toFixed(2) }) }}
178 + </span>
179 + </div>
180 + </div>
181 +
182 + <!-- EqualSubset -->
183 + <div v-else-if="splitMethod === 'EqualSubset'">
184 + <div class="mb-2" style="font-size: 0.85rem; color: var(--sa-gray-500)">
185 + {{ t('expenses.split.selectWho') }}
186 + </div>
187 + <div class="sa-split-participant-list">
188 + <label
189 + v-for="p in participants"
190 + :key="p.userId"
191 + class="sa-split-participant"
192 + :class="{ 'sa-split-participant-selected': selectedUserIds.has(p.userId) }"
193 + >
194 + <input
195 + type="checkbox"
196 + :checked="selectedUserIds.has(p.userId)"
197 + @change="toggleParticipant(p.userId)"
198 + style="display: none"
199 + />
200 + <span class="sa-avatar sa-avatar-xs sa-avatar-1">{{ getInitials(p.userName) }}</span>
201 + <span class="sa-split-participant-name">{{ p.userName || p.userEmail || t('common.unknown') }}</span>
202 + <span v-if="selectedUserIds.has(p.userId) && selectedUserIds.size > 0" class="sa-split-participant-amount">
203 + {{ (totalAmount / selectedUserIds.size).toFixed(2) }}
204 + </span>
205 + <i v-if="selectedUserIds.has(p.userId)" class="bi bi-check-circle-fill" style="color: var(--sa-success)"></i>
206 + <i v-else class="bi bi-circle" style="color: var(--sa-gray-300)"></i>
207 + </label>
208 + </div>
209 + <div v-if="selectedUserIds.size === 0" class="sa-split-error">
210 + <i class="bi bi-exclamation-circle me-1"></i>{{ t('expenses.split.selectAtLeastOne') }}
211 + </div>
212 + </div>
213 +
214 + <!-- ExactAmounts -->
215 + <div v-else-if="splitMethod === 'ExactAmounts'">
216 + <div class="mb-2" style="font-size: 0.85rem; color: var(--sa-gray-500)">
217 + {{ t('expenses.split.enterExact') }}
218 + </div>
219 + <div class="sa-split-participant-list">
220 + <div v-for="p in participants" :key="p.userId" class="sa-split-participant sa-split-participant-input">
221 + <span class="sa-avatar sa-avatar-xs sa-avatar-1">{{ getInitials(p.userName) }}</span>
222 + <span class="sa-split-participant-name">{{ p.userName || p.userEmail || t('common.unknown') }}</span>
223 + <input
224 + type="number"
225 + step="0.01"
226 + min="0"
227 + class="form-control form-control-sm sa-split-amount-input"
228 + :value="exactAmounts[p.userId] ?? 0"
229 + @input="exactAmounts[p.userId] = parseFloat(($event.target as HTMLInputElement).value) || 0"
230 + />
231 + </div>
232 + </div>
233 + <div class="sa-split-total" :class="{ 'sa-split-total-valid': Math.abs(exactTotal - totalAmount) < 0.01, 'sa-split-total-invalid': Math.abs(exactTotal - totalAmount) >= 0.01 }">
234 + {{ t('expenses.split.total') }} {{ exactTotal.toFixed(2) }} / {{ totalAmount.toFixed(2) }}
235 + <span v-if="Math.abs(exactTotal - totalAmount) >= 0.01" class="ms-2">
236 + ({{ exactTotal > totalAmount ? '+' : '' }}{{ (exactTotal - totalAmount).toFixed(2) }})
237 + </span>
238 + </div>
239 + </div>
240 +
241 + <!-- Percentages -->
242 + <div v-else-if="splitMethod === 'Percentages'">
243 + <div class="mb-2" style="font-size: 0.85rem; color: var(--sa-gray-500)">
244 + {{ t('expenses.split.enterPercentage') }}
245 + </div>
246 + <div class="sa-split-participant-list">
247 + <div v-for="p in participants" :key="p.userId" class="sa-split-participant sa-split-participant-input">
248 + <span class="sa-avatar sa-avatar-xs sa-avatar-1">{{ getInitials(p.userName) }}</span>
249 + <span class="sa-split-participant-name">{{ p.userName || p.userEmail || t('common.unknown') }}</span>
250 + <div class="sa-split-pct-group">
251 + <input
252 + type="number"
253 + step="0.1"
254 + min="0"
255 + max="100"
256 + class="form-control form-control-sm sa-split-amount-input"
257 + :value="percentages[p.userId] ?? 0"
258 + @input="percentages[p.userId] = parseFloat(($event.target as HTMLInputElement).value) || 0"
259 + />
260 + <span class="sa-split-pct-symbol">%</span>
261 + </div>
262 + <span class="sa-split-participant-amount">
263 + {{ ((percentages[p.userId] ?? 0) / 100 * totalAmount).toFixed(2) }}
264 + </span>
265 + </div>
266 + </div>
267 + <div class="sa-split-total" :class="{ 'sa-split-total-valid': Math.abs(percentageTotal - 100) < 0.01, 'sa-split-total-invalid': Math.abs(percentageTotal - 100) >= 0.01 }">
268 + {{ t('expenses.split.total') }} {{ percentageTotal.toFixed(1) }}% / 100%
269 + <span v-if="Math.abs(percentageTotal - 100) >= 0.01" class="ms-2">
270 + ({{ percentageTotal > 100 ? '+' : '' }}{{ (percentageTotal - 100).toFixed(1) }}%)
271 + </span>
272 + </div>
273 + </div>
274 + </div>
275 +</template>
276 +
277 +<style scoped>
278 +.sa-split-selector {
279 + margin-top: var(--sa-space-2);
280 +}
281 +
282 +.sa-split-info-card {
283 + background: var(--sa-gray-50);
284 + border: 1px solid var(--sa-gray-100);
285 + border-radius: var(--sa-radius-md);
286 + padding: var(--sa-space-3) var(--sa-space-4);
287 + font-size: 0.9rem;
288 + color: var(--sa-gray-700);
289 +}
290 +
291 +.sa-split-participant-list {
292 + display: flex;
293 + flex-direction: column;
294 + gap: var(--sa-space-2);
295 +}
296 +
297 +.sa-split-participant {
298 + display: flex;
299 + align-items: center;
300 + gap: var(--sa-space-2);
301 + padding: var(--sa-space-2) var(--sa-space-3);
302 + border-radius: var(--sa-radius-md);
303 + border: 1px solid var(--sa-gray-100);
304 + cursor: pointer;
305 + transition: all 0.15s ease;
306 +}
307 +
308 +.sa-split-participant:hover {
309 + border-color: var(--sa-gray-200);
310 + background: var(--sa-gray-50);
311 +}
312 +
313 +.sa-split-participant-selected {
314 + border-color: var(--sa-success);
315 + background: rgba(34, 197, 94, 0.05);
316 +}
317 +
318 +.sa-split-participant-input {
319 + cursor: default;
320 +}
321 +
322 +.sa-split-participant-name {
323 + flex: 1;
324 + font-size: 0.875rem;
325 + font-weight: 500;
326 + color: var(--sa-gray-700);
327 +}
328 +
329 +.sa-split-participant-amount {
330 + font-size: 0.85rem;
331 + font-weight: 600;
332 + color: var(--sa-gray-500);
333 + min-width: 60px;
334 + text-align: right;
335 +}
336 +
337 +.sa-split-amount-input {
338 + width: 90px;
339 + text-align: right;
340 + font-size: 0.85rem;
341 +}
342 +
343 +.sa-split-pct-group {
344 + display: flex;
345 + align-items: center;
346 + gap: 2px;
347 +}
348 +
349 +.sa-split-pct-symbol {
350 + font-size: 0.85rem;
351 + color: var(--sa-gray-400);
352 + font-weight: 600;
353 +}
354 +
355 +.sa-split-total {
356 + margin-top: var(--sa-space-3);
357 + padding: var(--sa-space-2) var(--sa-space-3);
358 + border-radius: var(--sa-radius-sm);
359 + font-size: 0.85rem;
360 + font-weight: 600;
361 + text-align: right;
362 +}
363 +
364 +.sa-split-total-valid {
365 + background: rgba(34, 197, 94, 0.1);
366 + color: var(--sa-success);
367 +}
368 +
369 +.sa-split-total-invalid {
370 + background: rgba(239, 68, 68, 0.1);
371 + color: var(--sa-danger);
372 +}
373 +
374 +.sa-split-error {
375 + margin-top: var(--sa-space-2);
376 + font-size: 0.8rem;
377 + color: var(--sa-danger);
378 + font-weight: 500;
379 +}
380 +
381 +.sa-avatar-xs {
382 + width: 28px;
383 + height: 28px;
384 + font-size: 0.7rem;
385 +}
386 +</style>
added src/components/ToastContainer.vue +33 −0
@@ -0,0 +1,33 @@
1 +<script setup lang="ts">
2 +import { useToast } from '@/composables/useToast'
3 +
4 +const { toasts, dismiss } = useToast()
5 +
6 +function iconClass(type: string) {
7 + switch (type) {
8 + case 'success':
9 + return 'bi-check-circle-fill'
10 + case 'error':
11 + return 'bi-x-circle-fill'
12 + case 'warning':
13 + return 'bi-exclamation-triangle-fill'
14 + default:
15 + return 'bi-info-circle-fill'
16 + }
17 +}
18 +</script>
19 +
20 +<template>
21 + <div class="sa-toast-container">
22 + <div
23 + v-for="toast in toasts"
24 + :key="toast.id"
25 + class="sa-toast"
26 + :class="[`sa-toast-${toast.type}`, { 'sa-toast-dismiss': toast.dismissing }]"
27 + @click="dismiss(toast.id)"
28 + >
29 + <i :class="['bi', iconClass(toast.type)]"></i>
30 + <span>{{ toast.message }}</span>
31 + </div>
32 + </div>
33 +</template>
added src/composables/useToast.ts +39 −0
@@ -0,0 +1,39 @@
1 +import { ref } from 'vue'
2 +
3 +export interface Toast {
4 + id: number
5 + message: string
6 + type: 'success' | 'error' | 'warning' | 'info'
7 + dismissing?: boolean
8 +}
9 +
10 +const toasts = ref<Toast[]>([])
11 +let nextId = 0
12 +
13 +function addToast(message: string, type: Toast['type'], duration = 4000) {
14 + const id = nextId++
15 + toasts.value.push({ id, message, type })
16 +
17 + setTimeout(() => dismissToast(id), duration)
18 +}
19 +
20 +function dismissToast(id: number) {
21 + const toast = toasts.value.find((t) => t.id === id)
22 + if (toast) {
23 + toast.dismissing = true
24 + setTimeout(() => {
25 + toasts.value = toasts.value.filter((t) => t.id !== id)
26 + }, 300)
27 + }
28 +}
29 +
30 +export function useToast() {
31 + return {
32 + toasts,
33 + success: (message: string) => addToast(message, 'success'),
34 + error: (message: string) => addToast(message, 'error', 6000),
35 + warning: (message: string) => addToast(message, 'warning'),
36 + info: (message: string) => addToast(message, 'info'),
37 + dismiss: dismissToast,
38 + }
39 +}
added src/directives/vAnimate.ts +23 −0
@@ -0,0 +1,23 @@
1 +import type { Directive } from 'vue'
2 +
3 +const observer = new IntersectionObserver(
4 + (entries) => {
5 + entries.forEach((entry) => {
6 + if (entry.isIntersecting) {
7 + entry.target.classList.add('sa-visible')
8 + observer.unobserve(entry.target)
9 + }
10 + })
11 + },
12 + { threshold: 0.1 },
13 +)
14 +
15 +export const vAnimate: Directive = {
16 + mounted(el: HTMLElement) {
17 + el.classList.add('sa-animate-on-scroll')
18 + observer.observe(el)
19 + },
20 + unmounted(el: HTMLElement) {
21 + observer.unobserve(el)
22 + },
23 +}
added src/i18n/index.ts +47 −0
@@ -0,0 +1,47 @@
1 +import { createI18n } from 'vue-i18n'
2 +import en from '@/locales/en.json'
3 +import et from '@/locales/et.json'
4 +
5 +export type AppLocale = 'en' | 'et'
6 +
7 +const numberFormats = {
8 + en: {
9 + currency: { style: 'currency', currency: 'EUR', notation: 'standard' },
10 + decimal: { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 },
11 + },
12 + et: {
13 + currency: { style: 'currency', currency: 'EUR', notation: 'standard' },
14 + decimal: { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 },
15 + },
16 +} as const
17 +
18 +const datetimeFormats = {
19 + en: {
20 + short: { year: 'numeric', month: 'short', day: 'numeric' },
21 + long: { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' },
22 + },
23 + et: {
24 + short: { year: 'numeric', month: 'short', day: 'numeric' },
25 + long: { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' },
26 + },
27 +} as const
28 +
29 +export const SUPPORTED_LOCALES: AppLocale[] = ['en', 'et']
30 +export const DEFAULT_LOCALE: AppLocale = 'en'
31 +
32 +function initialLocale(): AppLocale {
33 + const saved = localStorage.getItem('locale')
34 + if (saved === 'en' || saved === 'et') return saved
35 + return DEFAULT_LOCALE
36 +}
37 +
38 +const i18n = createI18n({
39 + legacy: false,
40 + locale: initialLocale(),
41 + fallbackLocale: DEFAULT_LOCALE,
42 + messages: { en, et },
43 + numberFormats,
44 + datetimeFormats,
45 +})
46 +
47 +export default i18n
added src/locales/en.json +390 −0
@@ -0,0 +1,390 @@
1 +{
2 + "common": {
3 + "save": "Save",
4 + "saving": "Saving...",
5 + "saveChanges": "Save Changes",
6 + "cancel": "Cancel",
7 + "delete": "Delete",
8 + "edit": "Edit",
9 + "create": "Create",
10 + "creating": "Creating...",
11 + "back": "Back",
12 + "loading": "Loading...",
13 + "confirm": "Confirm",
14 + "yes": "Yes",
15 + "no": "No",
16 + "close": "Close",
17 + "required": "required",
18 + "none": "None",
19 + "default": "Default",
20 + "unknown": "Unknown",
21 + "select": "Select",
22 + "viewAll": "View All",
23 + "goHome": "Go Home",
24 + "goToTrips": "Go to Trips"
25 + },
26 + "nav": {
27 + "trips": "Trips",
28 + "signIn": "Sign in",
29 + "getStarted": "Get Started",
30 + "logout": "Logout"
31 + },
32 + "home": {
33 + "heroBadge": "Group Travel Made Simple",
34 + "heroTitle1": "Split expenses.",
35 + "heroTitle2": "Plan together.",
36 + "heroSubtitle": "The complete group travel companion. From planning and budgeting to expense tracking and debt settlement — everything in one place.",
37 + "myTrips": "My Trips",
38 + "getStartedFree": "Get Started Free",
39 + "featuresTitle": "Everything your trip needs",
40 + "featuresSubtitle": "One app to replace the spreadsheet, the calculator, and the endless group chat debates.",
41 + "features": {
42 + "expenseTitle": "Expense Splitting",
43 + "expenseText": "Track who paid what and split costs equally, by exact amounts, or by percentages.",
44 + "budgetTitle": "Budget Tracking",
45 + "budgetText": "Set category budgets and see real-time spending progress throughout your trip.",
46 + "wishlistTitle": "Collaborative Wishlist",
47 + "wishlistText": "Add places, activities, and restaurants. Vote on favorites and track what you've done.",
48 + "pollsTitle": "Group Polls",
49 + "pollsText": "Make decisions together with polls. No more lost messages in group chats.",
50 + "settlementTitle": "Smart Settlement",
51 + "settlementText": "Minimize payments with optimized debt simplification and two-sided confirmation."
52 + }
53 + },
54 + "auth": {
55 + "login": {
56 + "welcomeBack": "Welcome back. Sign in to your account.",
57 + "email": "Email",
58 + "emailPlaceholder": "you{'@'}example.com",
59 + "password": "Password",
60 + "passwordPlaceholder": "Your password",
61 + "signIn": "Sign in",
62 + "signingIn": "Signing in...",
63 + "noAccount": "Don't have an account?",
64 + "createOne": "Create one"
65 + },
66 + "register": {
67 + "getStarted": "Get started with your free account.",
68 + "firstName": "First name",
69 + "lastName": "Last name",
70 + "firstNamePlaceholder": "Jane",
71 + "lastNamePlaceholder": "Doe",
72 + "email": "Email",
73 + "emailPlaceholder": "you{'@'}example.com",
74 + "password": "Password",
75 + "passwordPlaceholder": "Min. 6 characters",
76 + "createAccount": "Create account",
77 + "creatingAccount": "Creating account...",
78 + "haveAccount": "Already have an account?",
79 + "signIn": "Sign in"
80 + }
81 + },
82 + "trips": {
83 + "index": {
84 + "title": "My Trips",
85 + "count": "{n} trip | {n} trips",
86 + "newTrip": "New Trip",
87 + "emptyTitle": "No trips yet",
88 + "emptyText": "Create your first trip to start tracking expenses and planning together.",
89 + "createFirst": "Create Your First Trip",
90 + "confirmDelete": "Are you sure you want to delete this trip? This cannot be undone.",
91 + "deleted": "Trip deleted successfully"
92 + },
93 + "create": {
94 + "title": "Plan a new trip",
95 + "subtitle": "Add the basics — you can invite people and log expenses afterwards.",
96 + "name": "Trip name",
97 + "namePlaceholder": "e.g. Rome weekend 2026",
98 + "destination": "Destination",
99 + "destinationPlaceholder": "Where are you going?",
100 + "description": "Description",
101 + "descriptionPlaceholder": "A short note about the trip (optional)",
102 + "start": "Start",
103 + "end": "End",
104 + "nightCount": "{n} night | {n} nights",
105 + "defaultCurrency": "Default currency",
106 + "selectCurrency": "Select currency",
107 + "createTrip": "Create trip",
108 + "creating": "Creating…",
109 + "dateError": "End date must be on or after start date"
110 + },
111 + "edit": {
112 + "title": "Edit Trip",
113 + "name": "Name",
114 + "destination": "Destination",
115 + "description": "Description",
116 + "startDate": "Start Date",
117 + "endDate": "End Date",
118 + "currency": "Currency",
119 + "selectCurrency": "Select currency",
120 + "status": "Status",
121 + "confirmDelete": "Are you sure you want to delete this trip?",
122 + "notFound": "Trip not found"
123 + },
124 + "detail": {
125 + "organizer": "Organizer",
126 + "participant": "Participant",
127 + "memberCount": "{n} member | {n} members",
128 + "totalExpenses": "Total Expenses",
129 + "budgetUsed": "Budget Used",
130 + "yourBalance": "Your Balance",
131 + "members": "Members",
132 + "budgetProgress": "Budget Progress",
133 + "balances": "Balances",
134 + "recentExpenses": "Recent Expenses",
135 + "about": "About This Trip",
136 + "backTo": "Back to {name}",
137 + "untitledExpense": "Untitled expense"
138 + },
139 + "status": {
140 + "Active": "Active",
141 + "Completed": "Completed",
142 + "Finalizing": "Finalizing",
143 + "Settled": "Settled"
144 + }
145 + },
146 + "expenses": {
147 + "index": {
148 + "title": "Expenses",
149 + "total": "Total:",
150 + "addExpense": "Add Expense",
151 + "emptyTitle": "No expenses yet",
152 + "emptyText": "Add your first expense to start tracking spending.",
153 + "untitled": "Untitled expense",
154 + "uncategorized": "Uncategorized",
155 + "unknown": "Unknown",
156 + "confirmDelete": "Delete this expense?",
157 + "deleted": "Expense deleted"
158 + },
159 + "create": {
160 + "title": "Add Expense",
161 + "amount": "Amount",
162 + "description": "Description",
163 + "descriptionPlaceholder": "What was this expense for?",
164 + "date": "Date",
165 + "paidBy": "Paid by",
166 + "selectPayer": "Select payer",
167 + "splitMethod": "Split Method",
168 + "budgetCategory": "Budget Category",
169 + "currency": "Currency",
170 + "addExpense": "Add Expense",
171 + "added": "Expense added"
172 + },
173 + "edit": {
174 + "title": "Edit Expense",
175 + "confirmDelete": "Are you sure you want to delete this expense?",
176 + "updated": "Expense updated",
177 + "deleted": "Expense deleted",
178 + "notFound": "Expense not found"
179 + },
180 + "splitMethod": {
181 + "EqualAll": "Equal All",
182 + "EqualSubset": "Equal Select",
183 + "ExactAmounts": "Exact",
184 + "Percentages": "Percentage"
185 + },
186 + "split": {
187 + "equalAmong": "Split equally among all {count} members",
188 + "eachGets": "({amount} each)",
189 + "selectWho": "Select who to split with:",
190 + "selectAtLeastOne": "Select at least one person",
191 + "enterExact": "Enter exact amount for each person:",
192 + "enterPercentage": "Enter percentage for each person:",
193 + "total": "Total:"
194 + }
195 + },
196 + "budget": {
197 + "index": {
198 + "title": "Budget",
199 + "categoryCount": "{n} category | {n} categories",
200 + "addCategory": "Add Category",
201 + "emptyTitle": "No budget categories yet",
202 + "emptyText": "Add one to start tracking your spending against planned amounts.",
203 + "totalPlanned": "Total Planned",
204 + "totalSpent": "Total Spent",
205 + "remaining": "Remaining",
206 + "overallBudget": "Overall Budget",
207 + "overBudget": "Over budget!",
208 + "confirmDelete": "Delete this budget category?",
209 + "deleted": "Category deleted"
210 + },
211 + "create": {
212 + "title": "Add Budget Category",
213 + "name": "NAME",
214 + "namePlaceholder": "e.g. Food, Transport",
215 + "iconName": "ICON NAME",
216 + "iconNamePlaceholder": "e.g. cup-hot-fill, car-front-fill",
217 + "iconHelp": "Bootstrap Icons name without the \"bi-\" prefix",
218 + "plannedAmount": "PLANNED AMOUNT",
219 + "displayOrder": "DISPLAY ORDER",
220 + "addCategory": "Add Category",
221 + "created": "Category created"
222 + },
223 + "edit": {
224 + "title": "Edit Budget Category",
225 + "spentAmount": "SPENT AMOUNT",
226 + "confirmDelete": "Are you sure you want to delete this budget category?",
227 + "updated": "Category updated",
228 + "notFound": "Budget category not found"
229 + }
230 + },
231 + "members": {
232 + "title": "Members",
233 + "count": "{n} member | {n} members",
234 + "invite": "Invite",
235 + "createInvitation": "Create Invitation",
236 + "emptyTitle": "No members yet",
237 + "emptyText": "Invite people to join your trip and start planning together.",
238 + "invitationCreated": "Invitation link created!",
239 + "shareLink": "Share this link to invite someone to your trip:",
240 + "copyLink": "Copy link",
241 + "copied": "Link copied to clipboard!",
242 + "copyFailed": "Failed to copy link",
243 + "joined": "Joined {date}",
244 + "role": {
245 + "Organizer": "Organizer",
246 + "Participant": "Participant"
247 + }
248 + },
249 + "invitations": {
250 + "accept": {
251 + "title": "Trip Invitation",
252 + "unavailable": "Invitation Unavailable",
253 + "allDone": "All Done",
254 + "aTrip": "A trip",
255 + "invitedBy": "Invited by {name}",
256 + "expires": "Expires: {date}",
257 + "accept": "Accept",
258 + "decline": "Decline",
259 + "accepted": "Invitation accepted! You have been added to the trip.",
260 + "declined": "Invitation declined."
261 + }
262 + },
263 + "settlements": {
264 + "title": "Settlement",
265 + "loading": "Loading settlement...",
266 + "somethingWrong": "Something went wrong",
267 + "allSettled": "All settled up!",
268 + "allSettledText": "There are no outstanding balances or payments.",
269 + "balances": "Balances",
270 + "noBalances": "No balances to show. Add expenses first.",
271 + "suggestedPayments": "Suggested Payments",
272 + "preview": "Preview",
273 + "previewHint": "These are suggested payments. Finalize the trip to lock them in.",
274 + "settlementPlan": "Settlement Plan",
275 + "total": "Total",
276 + "finalize": "Finalize Trip",
277 + "reopen": "Reopen",
278 + "finalizing": "Finalizing",
279 + "tripSettled": "Trip Settled",
280 + "markPaid": "Mark Paid",
281 + "confirm": "Confirm",
282 + "awaitingConfirmation": "Awaiting Confirmation",
283 + "pending": "Pending",
284 + "confirmed": "Confirmed",
285 + "markedPaid": "MarkedPaid",
286 + "finalizeSuccess": "Trip finalized — settlement plan created",
287 + "finalizeFailed": "Failed to finalize trip",
288 + "reopenSuccess": "Trip reopened — you can add expenses again",
289 + "reopenFailed": "Failed to reopen trip",
290 + "markPaidSuccess": "Payment marked as paid",
291 + "markPaidFailed": "Failed to mark payment as paid",
292 + "confirmSuccess": "Payment confirmed",
293 + "confirmFailed": "Failed to confirm payment"
294 + },
295 + "polls": {
296 + "index": {
297 + "title": "Polls",
298 + "newPoll": "New Poll",
299 + "emptyTitle": "No polls yet",
300 + "emptyText": "Create a poll to start voting with your group.",
301 + "createFirst": "Create First Poll",
302 + "optionCount": "{n} options",
303 + "multipleVotes": "Multiple votes",
304 + "viewResults": "View Results",
305 + "vote": "Vote"
306 + },
307 + "create": {
308 + "title": "New Poll",
309 + "question": "Question",
310 + "questionPlaceholder": "What would you like to ask?",
311 + "allowMultiple": "Allow multiple votes",
312 + "allowMultipleHelp": "Members can vote for more than one option",
313 + "anonymous": "Anonymous voting",
314 + "anonymousHelp": "Votes are hidden from other members",
315 + "options": "Options",
316 + "optionPlaceholder": "Option {n}",
317 + "addOption": "Add Option",
318 + "createPoll": "Create Poll",
319 + "creating": "Creating...",
320 + "atLeastTwo": "At least 2 options are required"
321 + },
322 + "detail": {
323 + "yourVote": "Your Vote",
324 + "winner": "Winner",
325 + "voteCount": "{n} vote | {n} votes",
326 + "unvote": "Unvote",
327 + "vote": "Vote",
328 + "closePoll": "Close Poll",
329 + "backToPolls": "Back to Polls",
330 + "confirmClose": "Close this poll? No more votes will be accepted.",
331 + "confirmDelete": "Delete this poll?",
332 + "voteRecorded": "Vote recorded",
333 + "closed": "Poll closed",
334 + "deleted": "Poll deleted"
335 + },
336 + "status": {
337 + "Open": "Open",
338 + "Closed": "Closed"
339 + }
340 + },
341 + "wishlist": {
342 + "index": {
343 + "title": "Wishlist",
344 + "itemCount": "{n} item | {n} items",
345 + "addItem": "Add Item",
346 + "emptyTitle": "No wishlist items yet",
347 + "emptyText": "Add something you'd like to do on this trip!",
348 + "addedBy": "Added by {name}",
349 + "complete": "Complete",
350 + "undo": "Undo",
351 + "confirmDelete": "Delete this wishlist item?",
352 + "completed": "Item marked as complete!",
353 + "uncompleted": "Item marked as not complete",
354 + "deleted": "Item deleted"
355 + },
356 + "create": {
357 + "title": "Add Wishlist Item",
358 + "itemTitle": "Title",
359 + "titlePlaceholder": "What do you want to do?",
360 + "description": "Description",
361 + "descriptionPlaceholder": "Add some details...",
362 + "category": "Category",
363 + "priority": "Priority",
364 + "estimatedCost": "Estimated Cost",
365 + "url": "URL",
366 + "location": "Location",
367 + "locationPlaceholder": "Where is it?",
368 + "addItem": "Add Item",
369 + "saving": "Saving...",
370 + "pleaseFix": "Please fix the following:"
371 + },
372 + "edit": {
373 + "title": "Edit Wishlist Item",
374 + "somethingWrong": "Something went wrong",
375 + "confirmDelete": "Are you sure you want to delete this wishlist item?",
376 + "notFound": "Wishlist item not found"
377 + },
378 + "category": {
379 + "Place": "Place",
380 + "Activity": "Activity",
381 + "Restaurant": "Restaurant",
382 + "Other": "Other"
383 + },
384 + "priority": {
385 + "MustDo": "Must Do",
386 + "NiceToHave": "Nice to Have",
387 + "Optional": "Optional"
388 + }
389 + }
390 +}
added src/locales/et.json +390 −0
@@ -0,0 +1,390 @@
1 +{
2 + "common": {
3 + "save": "Salvesta",
4 + "saving": "Salvestan...",
5 + "saveChanges": "Salvesta muudatused",
6 + "cancel": "Tühista",
7 + "delete": "Kustuta",
8 + "edit": "Muuda",
9 + "create": "Loo",
10 + "creating": "Loon...",
11 + "back": "Tagasi",
12 + "loading": "Laadin...",
13 + "confirm": "Kinnita",
14 + "yes": "Jah",
15 + "no": "Ei",
16 + "close": "Sulge",
17 + "required": "kohustuslik",
18 + "none": "Puudub",
19 + "default": "Vaikimisi",
20 + "unknown": "Tundmatu",
21 + "select": "Vali",
22 + "viewAll": "Vaata kõiki",
23 + "goHome": "Avalehele",
24 + "goToTrips": "Reisid"
25 + },
26 + "nav": {
27 + "trips": "Reisid",
28 + "signIn": "Logi sisse",
29 + "getStarted": "Alusta",
30 + "logout": "Logi välja"
31 + },
32 + "home": {
33 + "heroBadge": "Grupireisi lihtsus",
34 + "heroTitle1": "Jaga kulusid.",
35 + "heroTitle2": "Planeeri koos.",
36 + "heroSubtitle": "Täielik grupireisi kaaslane. Planeerimisest ja eelarvestamisest kuluarvestuse ja võlgade arvelduseni — kõik ühes kohas.",
37 + "myTrips": "Minu reisid",
38 + "getStartedFree": "Alusta tasuta",
39 + "featuresTitle": "Kõik mida reis vajab",
40 + "featuresSubtitle": "Üks rakendus, mis asendab tabelarvutuse, kalkulaatori ja lõputud grupivestlused.",
41 + "features": {
42 + "expenseTitle": "Kulude jagamine",
43 + "expenseText": "Jälgi, kes mille eest maksis, ja jaga kulud võrdselt, täpsete summade või protsentide järgi.",
44 + "budgetTitle": "Eelarve jälgimine",
45 + "budgetText": "Sea kategooriate eelarved ja näe reaalajas kulutuste progressi kogu reisi vältel.",
46 + "wishlistTitle": "Ühine soovinimekiri",
47 + "wishlistText": "Lisa kohti, tegevusi ja restorane. Hääleta lemmikute poolt ja jälgi, mis juba tehtud.",
48 + "pollsTitle": "Grupiküsitlused",
49 + "pollsText": "Tehke otsuseid koos küsitluste abil. Lõpp kadunud sõnumitele grupivestlustes.",
50 + "settlementTitle": "Tark arveldus",
51 + "settlementText": "Minimeeri makseid optimeeritud võlasimplifikatsiooni ja kahepoolse kinnitusega."
52 + }
53 + },
54 + "auth": {
55 + "login": {
56 + "welcomeBack": "Tere tulemast tagasi. Logi oma kontole sisse.",
57 + "email": "E-post",
58 + "emailPlaceholder": "sina{'@'}näide.ee",
59 + "password": "Parool",
60 + "passwordPlaceholder": "Sinu parool",
61 + "signIn": "Logi sisse",
62 + "signingIn": "Sisselogimine...",
63 + "noAccount": "Pole veel kontot?",
64 + "createOne": "Loo konto"
65 + },
66 + "register": {
67 + "getStarted": "Alusta tasuta kontoga.",
68 + "firstName": "Eesnimi",
69 + "lastName": "Perekonnanimi",
70 + "firstNamePlaceholder": "Mari",
71 + "lastNamePlaceholder": "Maasikas",
72 + "email": "E-post",
73 + "emailPlaceholder": "sina{'@'}näide.ee",
74 + "password": "Parool",
75 + "passwordPlaceholder": "Vähemalt 6 tähemärki",
76 + "createAccount": "Loo konto",
77 + "creatingAccount": "Konto loomine...",
78 + "haveAccount": "Konto juba olemas?",
79 + "signIn": "Logi sisse"
80 + }
81 + },
82 + "trips": {
83 + "index": {
84 + "title": "Minu reisid",
85 + "count": "{n} reis | {n} reisi",
86 + "newTrip": "Uus reis",
87 + "emptyTitle": "Reise veel pole",
88 + "emptyText": "Loo oma esimene reis, et alustada kulude jälgimist ja koos planeerimist.",
89 + "createFirst": "Loo esimene reis",
90 + "confirmDelete": "Oled kindel, et soovid selle reisi kustutada? Seda ei saa tagasi võtta.",
91 + "deleted": "Reis edukalt kustutatud"
92 + },
93 + "create": {
94 + "title": "Planeeri uus reis",
95 + "subtitle": "Lisa põhilised andmed — inimesi saad kutsuda ja kulusid lisada hiljem.",
96 + "name": "Reisi nimi",
97 + "namePlaceholder": "nt Rooma nädalavahetus 2026",
98 + "destination": "Sihtkoht",
99 + "destinationPlaceholder": "Kuhu sa lähed?",
100 + "description": "Kirjeldus",
101 + "descriptionPlaceholder": "Lühike märge reisi kohta (valikuline)",
102 + "start": "Algus",
103 + "end": "Lõpp",
104 + "nightCount": "{n} öö | {n} ööd",
105 + "defaultCurrency": "Vaikevaluuta",
106 + "selectCurrency": "Vali valuuta",
107 + "createTrip": "Loo reis",
108 + "creating": "Loon…",
109 + "dateError": "Lõppkuupäev peab olema alguskuupäeval või hiljem"
110 + },
111 + "edit": {
112 + "title": "Muuda reisi",
113 + "name": "Nimi",
114 + "destination": "Sihtkoht",
115 + "description": "Kirjeldus",
116 + "startDate": "Alguskuupäev",
117 + "endDate": "Lõppkuupäev",
118 + "currency": "Valuuta",
119 + "selectCurrency": "Vali valuuta",
120 + "status": "Olek",
121 + "confirmDelete": "Oled kindel, et soovid selle reisi kustutada?",
122 + "notFound": "Reisi ei leitud"
123 + },
124 + "detail": {
125 + "organizer": "Korraldaja",
126 + "participant": "Osaleja",
127 + "memberCount": "{n} liige | {n} liiget",
128 + "totalExpenses": "Kulud kokku",
129 + "budgetUsed": "Eelarvest kasutatud",
130 + "yourBalance": "Sinu saldo",
131 + "members": "Liikmed",
132 + "budgetProgress": "Eelarve progress",
133 + "balances": "Saldod",
134 + "recentExpenses": "Hiljutised kulud",
135 + "about": "Reisi kohta",
136 + "backTo": "Tagasi reisi \"{name}\" juurde",
137 + "untitledExpense": "Pealkirjata kulu"
138 + },
139 + "status": {
140 + "Active": "Aktiivne",
141 + "Completed": "Lõpetatud",
142 + "Finalizing": "Lõpetamine",
143 + "Settled": "Arveldatud"
144 + }
145 + },
146 + "expenses": {
147 + "index": {
148 + "title": "Kulud",
149 + "total": "Kokku:",
150 + "addExpense": "Lisa kulu",
151 + "emptyTitle": "Kulusid veel pole",
152 + "emptyText": "Lisa esimene kulu, et alustada kulutuste jälgimist.",
153 + "untitled": "Pealkirjata kulu",
154 + "uncategorized": "Kategoriseerimata",
155 + "unknown": "Tundmatu",
156 + "confirmDelete": "Kas kustutan selle kulu?",
157 + "deleted": "Kulu kustutatud"
158 + },
159 + "create": {
160 + "title": "Lisa kulu",
161 + "amount": "Summa",
162 + "description": "Kirjeldus",
163 + "descriptionPlaceholder": "Mille eest see kulu oli?",
164 + "date": "Kuupäev",
165 + "paidBy": "Maksja",
166 + "selectPayer": "Vali maksja",
167 + "splitMethod": "Jagamise meetod",
168 + "budgetCategory": "Eelarvekategooria",
169 + "currency": "Valuuta",
170 + "addExpense": "Lisa kulu",
171 + "added": "Kulu lisatud"
172 + },
173 + "edit": {
174 + "title": "Muuda kulu",
175 + "confirmDelete": "Oled kindel, et soovid selle kulu kustutada?",
176 + "updated": "Kulu uuendatud",
177 + "deleted": "Kulu kustutatud",
178 + "notFound": "Kulu ei leitud"
179 + },
180 + "splitMethod": {
181 + "EqualAll": "Võrdselt kõik",
182 + "EqualSubset": "Võrdselt valitud",
183 + "ExactAmounts": "Täpne",
184 + "Percentages": "Protsent"
185 + },
186 + "split": {
187 + "equalAmong": "Jagatud võrdselt kõigi {count} liikme vahel",
188 + "eachGets": "({amount} kumbki)",
189 + "selectWho": "Vali, kellega jagad:",
190 + "selectAtLeastOne": "Vali vähemalt üks inimene",
191 + "enterExact": "Sisesta täpne summa iga inimese kohta:",
192 + "enterPercentage": "Sisesta protsent iga inimese kohta:",
193 + "total": "Kokku:"
194 + }
195 + },
196 + "budget": {
197 + "index": {
198 + "title": "Eelarve",
199 + "categoryCount": "{n} kategooria | {n} kategooriat",
200 + "addCategory": "Lisa kategooria",
201 + "emptyTitle": "Eelarvekategooriaid veel pole",
202 + "emptyText": "Lisa üks, et alustada kulutuste jälgimist plaanitud summade vastu.",
203 + "totalPlanned": "Plaanitud kokku",
204 + "totalSpent": "Kulutatud kokku",
205 + "remaining": "Järelejäänud",
206 + "overallBudget": "Üldine eelarve",
207 + "overBudget": "Üle eelarve!",
208 + "confirmDelete": "Kas kustutan selle eelarvekategooria?",
209 + "deleted": "Kategooria kustutatud"
210 + },
211 + "create": {
212 + "title": "Lisa eelarvekategooria",
213 + "name": "NIMI",
214 + "namePlaceholder": "nt Toit, Transport",
215 + "iconName": "IKOONI NIMI",
216 + "iconNamePlaceholder": "nt cup-hot-fill, car-front-fill",
217 + "iconHelp": "Bootstrap Icons'i nimi ilma \"bi-\" eesliiteta",
218 + "plannedAmount": "PLAANITUD SUMMA",
219 + "displayOrder": "KUVAMISE JÄRJEKORD",
220 + "addCategory": "Lisa kategooria",
221 + "created": "Kategooria loodud"
222 + },
223 + "edit": {
224 + "title": "Muuda eelarvekategooriat",
225 + "spentAmount": "KULUTATUD SUMMA",
226 + "confirmDelete": "Oled kindel, et soovid selle eelarvekategooria kustutada?",
227 + "updated": "Kategooria uuendatud",
228 + "notFound": "Eelarvekategooriat ei leitud"
229 + }
230 + },
231 + "members": {
232 + "title": "Liikmed",
233 + "count": "{n} liige | {n} liiget",
234 + "invite": "Kutsu",
235 + "createInvitation": "Loo kutse",
236 + "emptyTitle": "Liikmeid veel pole",
237 + "emptyText": "Kutsu inimesi reisiga liituma ja hakake koos planeerima.",
238 + "invitationCreated": "Kutselink loodud!",
239 + "shareLink": "Jaga seda linki, et kutsuda kedagi oma reisile:",
240 + "copyLink": "Kopeeri link",
241 + "copied": "Link kopeeritud lõikelauale!",
242 + "copyFailed": "Lingi kopeerimine ebaõnnestus",
243 + "joined": "Liitus {date}",
244 + "role": {
245 + "Organizer": "Korraldaja",
246 + "Participant": "Osaleja"
247 + }
248 + },
249 + "invitations": {
250 + "accept": {
251 + "title": "Reisikutse",
252 + "unavailable": "Kutse pole saadaval",
253 + "allDone": "Valmis",
254 + "aTrip": "Reis",
255 + "invitedBy": "Kutsus {name}",
256 + "expires": "Aegub: {date}",
257 + "accept": "Võta vastu",
258 + "decline": "Keeldu",
259 + "accepted": "Kutse vastu võetud! Sind lisati reisile.",
260 + "declined": "Kutse tagasi lükatud."
261 + }
262 + },
263 + "settlements": {
264 + "title": "Arveldus",
265 + "loading": "Laadin arveldust...",
266 + "somethingWrong": "Midagi läks valesti",
267 + "allSettled": "Kõik arveldatud!",
268 + "allSettledText": "Pooleliolevaid saldode ega makseid pole.",
269 + "balances": "Saldod",
270 + "noBalances": "Saldode näitamiseks pole andmeid. Lisa esmalt kulud.",
271 + "suggestedPayments": "Soovitatud maksed",
272 + "preview": "Eelvaade",
273 + "previewHint": "Need on soovitatud maksed. Lõpeta reis, et need fikseerida.",
274 + "settlementPlan": "Arvelduseplaan",
275 + "total": "Kokku",
276 + "finalize": "Lõpeta reis",
277 + "reopen": "Ava uuesti",
278 + "finalizing": "Lõpetamine",
279 + "tripSettled": "Reis arveldatud",
280 + "markPaid": "Märgi makstuks",
281 + "confirm": "Kinnita",
282 + "awaitingConfirmation": "Ootab kinnitust",
283 + "pending": "Ootel",
284 + "confirmed": "Kinnitatud",
285 + "markedPaid": "Märgitud makstuks",
286 + "finalizeSuccess": "Reis lõpetatud — arvelduseplaan loodud",
287 + "finalizeFailed": "Reisi lõpetamine ebaõnnestus",
288 + "reopenSuccess": "Reis uuesti avatud — saad taas kulusid lisada",
289 + "reopenFailed": "Reisi uuesti avamine ebaõnnestus",
290 + "markPaidSuccess": "Makse märgitud makstuks",
291 + "markPaidFailed": "Makse märgimine ebaõnnestus",
292 + "confirmSuccess": "Makse kinnitatud",
293 + "confirmFailed": "Makse kinnitamine ebaõnnestus"
294 + },
295 + "polls": {
296 + "index": {
297 + "title": "Küsitlused",
298 + "newPoll": "Uus küsitlus",
299 + "emptyTitle": "Küsitlusi veel pole",
300 + "emptyText": "Loo küsitlus, et grupiga hääletama hakata.",
301 + "createFirst": "Loo esimene küsitlus",
302 + "optionCount": "{n} valikut",
303 + "multipleVotes": "Mitu häält",
304 + "viewResults": "Vaata tulemusi",
305 + "vote": "Hääleta"
306 + },
307 + "create": {
308 + "title": "Uus küsitlus",
309 + "question": "Küsimus",
310 + "questionPlaceholder": "Mida sa küsida tahaksid?",
311 + "allowMultiple": "Luba mitu häält",
312 + "allowMultipleHelp": "Liikmed saavad hääletada mitme valiku poolt",
313 + "anonymous": "Anonüümne hääletus",
314 + "anonymousHelp": "Hääled on teistele liikmetele peidetud",
315 + "options": "Valikud",
316 + "optionPlaceholder": "Valik {n}",
317 + "addOption": "Lisa valik",
318 + "createPoll": "Loo küsitlus",
319 + "creating": "Loon...",
320 + "atLeastTwo": "Vaja on vähemalt 2 valikut"
321 + },
322 + "detail": {
323 + "yourVote": "Sinu hääl",
324 + "winner": "Võitja",
325 + "voteCount": "{n} hääl | {n} häält",
326 + "unvote": "Tühista hääl",
327 + "vote": "Hääleta",
328 + "closePoll": "Sulge küsitlus",
329 + "backToPolls": "Tagasi küsitluste juurde",
330 + "confirmClose": "Kas sulgen selle küsitluse? Uusi hääli enam vastu ei võeta.",
331 + "confirmDelete": "Kas kustutan selle küsitluse?",
332 + "voteRecorded": "Hääl salvestatud",
333 + "closed": "Küsitlus suletud",
334 + "deleted": "Küsitlus kustutatud"
335 + },
336 + "status": {
337 + "Open": "Avatud",
338 + "Closed": "Suletud"
339 + }
340 + },
341 + "wishlist": {
342 + "index": {
343 + "title": "Soovinimekiri",
344 + "itemCount": "{n} kirje | {n} kirjet",
345 + "addItem": "Lisa kirje",
346 + "emptyTitle": "Soovinimekirjas pole veel ühtegi kirjet",
347 + "emptyText": "Lisa midagi, mida sa selle reisi ajal teha tahaksid!",
348 + "addedBy": "Lisas {name}",
349 + "complete": "Tehtud",
350 + "undo": "Võta tagasi",
351 + "confirmDelete": "Kas kustutan selle soovinimekirja kirje?",
352 + "completed": "Kirje märgitud tehtuks!",
353 + "uncompleted": "Kirje märgitud tegemata",
354 + "deleted": "Kirje kustutatud"
355 + },
356 + "create": {
357 + "title": "Lisa soovinimekirja kirje",
358 + "itemTitle": "Pealkiri",
359 + "titlePlaceholder": "Mida sa teha tahaksid?",
360 + "description": "Kirjeldus",
361 + "descriptionPlaceholder": "Lisa detaile...",
362 + "category": "Kategooria",
363 + "priority": "Prioriteet",
364 + "estimatedCost": "Hinnanguline maksumus",
365 + "url": "URL",
366 + "location": "Asukoht",
367 + "locationPlaceholder": "Kus see on?",
368 + "addItem": "Lisa kirje",
369 + "saving": "Salvestan...",
370 + "pleaseFix": "Palun paranda järgmine:"
371 + },
372 + "edit": {
373 + "title": "Muuda soovinimekirja kirjet",
374 + "somethingWrong": "Midagi läks valesti",
375 + "confirmDelete": "Oled kindel, et soovid selle soovinimekirja kirje kustutada?",
376 + "notFound": "Soovinimekirja kirjet ei leitud"
377 + },
378 + "category": {
379 + "Place": "Koht",
380 + "Activity": "Tegevus",
381 + "Restaurant": "Restoran",
382 + "Other": "Muu"
383 + },
384 + "priority": {
385 + "MustDo": "Kindlasti teha",
386 + "NiceToHave": "Tore oleks",
387 + "Optional": "Valikuline"
388 + }
389 + }
390 +}
added src/main.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { createApp } from 'vue'
2 +import { createPinia } from 'pinia'
3 +import 'bootstrap/dist/css/bootstrap.min.css'
4 +import './assets/splitapp-design.css'
5 +
6 +import App from './App.vue'
7 +import router from './router'
8 +import i18n from './i18n'
9 +import { useLangStore } from './stores/lang'
10 +import { vAnimate } from './directives/vAnimate'
11 +
12 +const app = createApp(App)
13 +
14 +app.use(createPinia())
15 +app.use(i18n)
16 +useLangStore()
17 +app.use(router)
18 +app.directive('animate', vAnimate)
19 +
20 +app.mount('#app')
added src/router/index.ts +138 −0
@@ -0,0 +1,138 @@
1 +import { createRouter, createWebHistory } from 'vue-router'
2 +import { useAuthStore } from '@/stores/auth'
3 +import HomeView from '@/views/HomeView.vue'
4 +
5 +const router = createRouter({
6 + history: createWebHistory(import.meta.env.BASE_URL),
7 + routes: [
8 + {
9 + path: '/',
10 + name: 'Home',
11 + component: HomeView,
12 + },
13 + {
14 + path: '/login',
15 + name: 'Login',
16 + component: () => import('@/views/LoginView.vue'),
17 + },
18 + {
19 + path: '/register',
20 + name: 'Register',
21 + component: () => import('@/views/RegisterView.vue'),
22 + },
23 + {
24 + path: '/trips',
25 + name: 'TripsIndex',
26 + component: () => import('@/views/trips/IndexView.vue'),
27 + meta: { requiresAuth: true },
28 + },
29 + {
30 + path: '/trips/create',
31 + name: 'TripsCreate',
32 + component: () => import('@/views/trips/CreateView.vue'),
33 + meta: { requiresAuth: true },
34 + },
35 + {
36 + path: '/trips/:tripId',
37 + component: () => import('@/views/trips/DetailView.vue'),
38 + meta: { requiresAuth: true },
39 + children: [
40 + {
41 + path: '',
42 + name: 'TripDetail',
43 + component: () => import('@/views/expenses/IndexView.vue'),
44 + },
45 + {
46 + path: 'edit',
47 + name: 'TripsEdit',
48 + component: () => import('@/views/trips/EditView.vue'),
49 + },
50 + {
51 + path: 'expenses',
52 + name: 'ExpensesIndex',
53 + component: () => import('@/views/expenses/IndexView.vue'),
54 + },
55 + {
56 + path: 'expenses/create',
57 + name: 'ExpensesCreate',
58 + component: () => import('@/views/expenses/CreateView.vue'),
59 + },
60 + {
61 + path: 'expenses/:id/edit',
62 + name: 'ExpensesEdit',
63 + component: () => import('@/views/expenses/EditView.vue'),
64 + },
65 + {
66 + path: 'budget',
67 + name: 'BudgetIndex',
68 + component: () => import('@/views/budget-categories/IndexView.vue'),
69 + },
70 + {
71 + path: 'budget/create',
72 + name: 'BudgetCreate',
73 + component: () => import('@/views/budget-categories/CreateView.vue'),
74 + },
75 + {
76 + path: 'budget/:id/edit',
77 + name: 'BudgetEdit',
78 + component: () => import('@/views/budget-categories/EditView.vue'),
79 + },
80 + {
81 + path: 'wishlist',
82 + name: 'WishlistIndex',
83 + component: () => import('@/views/wishlist/IndexView.vue'),
84 + },
85 + {
86 + path: 'wishlist/create',
87 + name: 'WishlistCreate',
88 + component: () => import('@/views/wishlist/CreateView.vue'),
89 + },
90 + {
91 + path: 'wishlist/:id/edit',
92 + name: 'WishlistEdit',
93 + component: () => import('@/views/wishlist/EditView.vue'),
94 + },
95 + {
96 + path: 'polls',
97 + name: 'PollsIndex',
98 + component: () => import('@/views/polls/IndexView.vue'),
99 + },
100 + {
101 + path: 'polls/create',
102 + name: 'PollsCreate',
103 + component: () => import('@/views/polls/CreateView.vue'),
104 + },
105 + {
106 + path: 'polls/:id',
107 + name: 'PollDetail',
108 + component: () => import('@/views/polls/DetailView.vue'),
109 + },
110 + {
111 + path: 'members',
112 + name: 'MembersView',
113 + component: () => import('@/views/members/MembersView.vue'),
114 + },
115 + {
116 + path: 'settlement',
117 + name: 'SettlementView',
118 + component: () => import('@/views/settlements/SettlementView.vue'),
119 + },
120 + ],
121 + },
122 + {
123 + path: '/invitations/:token',
124 + name: 'AcceptInvitation',
125 + component: () => import('@/views/invitations/AcceptView.vue'),
126 + meta: { requiresAuth: true },
127 + },
128 + ],
129 +})
130 +
131 +router.beforeEach((to) => {
132 + const authStore = useAuthStore()
133 + if (to.meta.requiresAuth && !authStore.isAuthenticated) {
134 + return { name: 'Login' }
135 + }
136 +})
137 +
138 +export default router
added src/services/AccountService.ts +86 −0
@@ -0,0 +1,86 @@
1 +import axios from 'axios'
2 +import type { IResultObject } from '@/types/IResultObject'
3 +import type { IJwtResponse } from '@/types/IJwtResponse'
4 +
5 +const apiClient = axios.create({
6 + baseURL: import.meta.env.VITE_API_BASE_URL,
7 +})
8 +
9 +export default class AccountService {
10 + static async loginAsync(
11 + email: string,
12 + password: string,
13 + ): Promise<IResultObject<IJwtResponse>> {
14 + try {
15 + const response = await apiClient.post<IJwtResponse>('identity/Account/Login', {
16 + email,
17 + password,
18 + })
19 + return { data: response.data }
20 + } catch (e) {
21 + return { errors: AccountService.handleError(e) }
22 + }
23 + }
24 +
25 + static async registerAsync(
26 + email: string,
27 + password: string,
28 + firstName: string,
29 + lastName: string,
30 + ): Promise<IResultObject<IJwtResponse>> {
31 + try {
32 + const response = await apiClient.post<IJwtResponse>('identity/Account/Register', {
33 + email,
34 + password,
35 + firstName,
36 + lastName,
37 + })
38 + return { data: response.data }
39 + } catch (e) {
40 + return { errors: AccountService.handleError(e) }
41 + }
42 + }
43 +
44 + static async refreshTokenAsync(
45 + jwt: string,
46 + refreshToken: string,
47 + ): Promise<IResultObject<IJwtResponse>> {
48 + try {
49 + const response = await apiClient.post<IJwtResponse>('identity/Account/RefreshTokenData', {
50 + jwt,
51 + refreshToken,
52 + })
53 + return { data: response.data }
54 + } catch (e) {
55 + return { errors: AccountService.handleError(e) }
56 + }
57 + }
58 +
59 + static async logoutAsync(refreshToken: string): Promise<void> {
60 + try {
61 + await apiClient.post('identity/Account/Logout', { refreshToken })
62 + } catch {
63 + // Ignore logout errors — token cleanup happens client-side regardless
64 + }
65 + }
66 +
67 + private static handleError(e: unknown): string[] {
68 + if (axios.isAxiosError(e)) {
69 + const data = e.response?.data
70 + if (data?.errors && typeof data.errors === 'object') {
71 + const messages: string[] = []
72 + for (const field of Object.values(data.errors)) {
73 + if (Array.isArray(field)) {
74 + messages.push(...field)
75 + }
76 + }
77 + if (messages.length > 0) return messages
78 + }
79 + if (data?.title) {
80 + return [data.title]
81 + }
82 + return [e.response?.statusText ?? 'Unknown error occurred']
83 + }
84 + return ['Network error or server unavailable']
85 + }
86 +}
added src/services/BudgetCategoryService.ts +58 −0
@@ -0,0 +1,58 @@
1 +import httpClient from '@/services/httpClient'
2 +import type { IResultObject } from '@/types/IResultObject'
3 +import type { IBudgetCategory, IBudgetCategoryCreate } from '@/types/IBudgetCategory'
4 +import axios from 'axios'
5 +
6 +export default class BudgetCategoryService {
7 + static async getByTrip(tripId: string): Promise<IResultObject<IBudgetCategory[]>> {
8 + try {
9 + const response = await httpClient.get<IBudgetCategory[]>(`BudgetCategories/trip/${tripId}`)
10 + return { data: response.data }
11 + } catch (e) {
12 + return { errors: BudgetCategoryService.handleError(e) }
13 + }
14 + }
15 +
16 + static async create(entity: IBudgetCategoryCreate): Promise<IResultObject<IBudgetCategory>> {
17 + try {
18 + const response = await httpClient.post<IBudgetCategory>('BudgetCategories', entity)
19 + return { data: response.data }
20 + } catch (e) {
21 + return { errors: BudgetCategoryService.handleError(e) }
22 + }
23 + }
24 +
25 + static async update(id: string, entity: IBudgetCategoryCreate): Promise<IResultObject<void>> {
26 + try {
27 + await httpClient.put(`BudgetCategories/${id}`, entity)
28 + return { data: undefined }
29 + } catch (e) {
30 + return { errors: BudgetCategoryService.handleError(e) }
31 + }
32 + }
33 +
34 + static async delete(id: string): Promise<IResultObject<void>> {
35 + try {
36 + await httpClient.delete(`BudgetCategories/${id}`)
37 + return { data: undefined }
38 + } catch (e) {
39 + return { errors: BudgetCategoryService.handleError(e) }
40 + }
41 + }
42 +
43 + private static handleError(e: unknown): string[] {
44 + if (axios.isAxiosError(e)) {
45 + const data = e.response?.data
46 + if (data?.errors && typeof data.errors === 'object') {
47 + const messages: string[] = []
48 + for (const field of Object.values(data.errors)) {
49 + if (Array.isArray(field)) messages.push(...field)
50 + }
51 + if (messages.length > 0) return messages
52 + }
53 + if (data?.title) return [data.title]
54 + return [e.response?.statusText ?? 'Unknown error']
55 + }
56 + return ['Network error or server unavailable']
57 + }
58 +}
added src/services/CurrencyService.ts +31 −0
@@ -0,0 +1,31 @@
1 +import httpClient from '@/services/httpClient'
2 +import type { IResultObject } from '@/types/IResultObject'
3 +import type { ICurrency } from '@/types/ICurrency'
4 +import axios from 'axios'
5 +
6 +export default class CurrencyService {
7 + static async getAll(): Promise<IResultObject<ICurrency[]>> {
8 + try {
9 + const response = await httpClient.get<ICurrency[]>('Currencies')
10 + return { data: response.data }
11 + } catch (e) {
12 + return { errors: CurrencyService.handleError(e) }
13 + }
14 + }
15 +
16 + private static handleError(e: unknown): string[] {
17 + if (axios.isAxiosError(e)) {
18 + const data = e.response?.data
19 + if (data?.errors && typeof data.errors === 'object') {
20 + const messages: string[] = []
21 + for (const field of Object.values(data.errors)) {
22 + if (Array.isArray(field)) messages.push(...field)
23 + }
24 + if (messages.length > 0) return messages
25 + }
26 + if (data?.title) return [data.title]
27 + return [e.response?.statusText ?? 'Unknown error']
28 + }
29 + return ['Network error or server unavailable']
30 + }
31 +}
added src/services/ExpenseService.ts +67 −0
@@ -0,0 +1,67 @@
1 +import httpClient from '@/services/httpClient'
2 +import type { IResultObject } from '@/types/IResultObject'
3 +import type { IExpense, IExpenseCreate } from '@/types/IExpense'
4 +import axios from 'axios'
5 +
6 +export default class ExpenseService {
7 + static async getByTrip(tripId: string): Promise<IResultObject<IExpense[]>> {
8 + try {
9 + const response = await httpClient.get<IExpense[]>(`Expenses/trip/${tripId}`)
10 + return { data: response.data }
11 + } catch (e) {
12 + return { errors: ExpenseService.handleError(e) }
13 + }
14 + }
15 +
16 + static async getById(id: string): Promise<IResultObject<IExpense>> {
17 + try {
18 + const response = await httpClient.get<IExpense>(`Expenses/${id}`)
19 + return { data: response.data }
20 + } catch (e) {
21 + return { errors: ExpenseService.handleError(e) }
22 + }
23 + }
24 +
25 + static async create(entity: IExpenseCreate): Promise<IResultObject<IExpense>> {
26 + try {
27 + const response = await httpClient.post<IExpense>('Expenses', entity)
28 + return { data: response.data }
29 + } catch (e) {
30 + return { errors: ExpenseService.handleError(e) }
31 + }
32 + }
33 +
34 + static async update(id: string, entity: IExpenseCreate): Promise<IResultObject<void>> {
35 + try {
36 + await httpClient.put(`Expenses/${id}`, entity)
37 + return { data: undefined }
38 + } catch (e) {
39 + return { errors: ExpenseService.handleError(e) }
40 + }
41 + }
42 +
43 + static async delete(id: string): Promise<IResultObject<void>> {
44 + try {
45 + await httpClient.delete(`Expenses/${id}`)
46 + return { data: undefined }
47 + } catch (e) {
48 + return { errors: ExpenseService.handleError(e) }
49 + }
50 + }
51 +
52 + private static handleError(e: unknown): string[] {
53 + if (axios.isAxiosError(e)) {
54 + const data = e.response?.data
55 + if (data?.errors && typeof data.errors === 'object') {
56 + const messages: string[] = []
57 + for (const field of Object.values(data.errors)) {
58 + if (Array.isArray(field)) messages.push(...field)
59 + }
60 + if (messages.length > 0) return messages
61 + }
62 + if (data?.title) return [data.title]
63 + return [e.response?.statusText ?? 'Unknown error']
64 + }
65 + return ['Network error or server unavailable']
66 + }
67 +}
added src/services/InvitationService.ts +67 −0
@@ -0,0 +1,67 @@
1 +import httpClient from '@/services/httpClient'
2 +import type { IResultObject } from '@/types/IResultObject'
3 +import type { IInvitation, IInvitationCreate } from '@/types/IInvitation'
4 +import axios from 'axios'
5 +
6 +export default class InvitationService {
7 + static async create(entity: IInvitationCreate): Promise<IResultObject<IInvitation>> {
8 + try {
9 + const response = await httpClient.post<IInvitation>('Invitations', entity)
10 + return { data: response.data }
11 + } catch (e) {
12 + return { errors: InvitationService.handleError(e) }
13 + }
14 + }
15 +
16 + static async getByToken(token: string): Promise<IResultObject<IInvitation>> {
17 + try {
18 + const response = await httpClient.get<IInvitation>(`Invitations/${token}`)
19 + return { data: response.data }
20 + } catch (e) {
21 + return { errors: InvitationService.handleError(e) }
22 + }
23 + }
24 +
25 + static async accept(token: string): Promise<IResultObject<void>> {
26 + try {
27 + await httpClient.post(`Invitations/${token}/accept`)
28 + return { data: undefined }
29 + } catch (e) {
30 + return { errors: InvitationService.handleError(e) }
31 + }
32 + }
33 +
34 + static async decline(token: string): Promise<IResultObject<void>> {
35 + try {
36 + await httpClient.post(`Invitations/${token}/decline`)
37 + return { data: undefined }
38 + } catch (e) {
39 + return { errors: InvitationService.handleError(e) }
40 + }
41 + }
42 +
43 + static async revoke(token: string): Promise<IResultObject<void>> {
44 + try {
45 + await httpClient.post(`Invitations/${token}/revoke`)
46 + return { data: undefined }
47 + } catch (e) {
48 + return { errors: InvitationService.handleError(e) }
49 + }
50 + }
51 +
52 + private static handleError(e: unknown): string[] {
53 + if (axios.isAxiosError(e)) {
54 + const data = e.response?.data
55 + if (data?.errors && typeof data.errors === 'object') {
56 + const messages: string[] = []
57 + for (const field of Object.values(data.errors)) {
58 + if (Array.isArray(field)) messages.push(...field)
59 + }
60 + if (messages.length > 0) return messages
61 + }
62 + if (data?.title) return [data.title]
63 + return [e.response?.statusText ?? 'Unknown error']
64 + }
65 + return ['Network error or server unavailable']
66 + }
67 +}
added src/services/PollService.ts +76 −0
@@ -0,0 +1,76 @@
1 +import httpClient from '@/services/httpClient'
2 +import type { IResultObject } from '@/types/IResultObject'
3 +import type { IPoll, IPollCreate } from '@/types/IPoll'
4 +import axios from 'axios'
5 +
6 +export default class PollService {
7 + static async getByTrip(tripId: string): Promise<IResultObject<IPoll[]>> {
8 + try {
9 + const response = await httpClient.get<IPoll[]>(`Polls/trip/${tripId}`)
10 + return { data: response.data }
11 + } catch (e) {
12 + return { errors: PollService.handleError(e) }
13 + }
14 + }
15 +
16 + static async getById(id: string): Promise<IResultObject<IPoll>> {
17 + try {
18 + const response = await httpClient.get<IPoll>(`Polls/${id}`)
19 + return { data: response.data }
20 + } catch (e) {
21 + return { errors: PollService.handleError(e) }
22 + }
23 + }
24 +
25 + static async create(entity: IPollCreate): Promise<IResultObject<IPoll>> {
26 + try {
27 + const response = await httpClient.post<IPoll>('Polls', entity)
28 + return { data: response.data }
29 + } catch (e) {
30 + return { errors: PollService.handleError(e) }
31 + }
32 + }
33 +
34 + static async vote(pollId: string, optionId: string): Promise<IResultObject<void>> {
35 + try {
36 + await httpClient.post(`Polls/${pollId}/vote`, { optionId })
37 + return { data: undefined }
38 + } catch (e) {
39 + return { errors: PollService.handleError(e) }
40 + }
41 + }
42 +
43 + static async close(id: string): Promise<IResultObject<void>> {
44 + try {
45 + await httpClient.post(`Polls/${id}/close`)
46 + return { data: undefined }
47 + } catch (e) {
48 + return { errors: PollService.handleError(e) }
49 + }
50 + }
51 +
52 + static async delete(id: string): Promise<IResultObject<void>> {
53 + try {
54 + await httpClient.delete(`Polls/${id}`)
55 + return { data: undefined }
56 + } catch (e) {
57 + return { errors: PollService.handleError(e) }
58 + }
59 + }
60 +
61 + private static handleError(e: unknown): string[] {
62 + if (axios.isAxiosError(e)) {
63 + const data = e.response?.data
64 + if (data?.errors && typeof data.errors === 'object') {
65 + const messages: string[] = []
66 + for (const field of Object.values(data.errors)) {
67 + if (Array.isArray(field)) messages.push(...field)
68 + }
69 + if (messages.length > 0) return messages
70 + }
71 + if (data?.title) return [data.title]
72 + return [e.response?.statusText ?? 'Unknown error']
73 + }
74 + return ['Network error or server unavailable']
75 + }
76 +}
added src/services/SettlementService.ts +58 −0
@@ -0,0 +1,58 @@
1 +import httpClient from '@/services/httpClient'
2 +import type { IResultObject } from '@/types/IResultObject'
3 +import type { ISettlementPlan, ISettlementSummary, IBalance } from '@/types/ISettlement'
4 +import axios from 'axios'
5 +
6 +export default class SettlementService {
7 + static async getSummary(tripId: string): Promise<IResultObject<ISettlementSummary>> {
8 + try {
9 + const response = await httpClient.get<ISettlementSummary>(`Settlements/trip/${tripId}/summary`)
10 + return { data: response.data }
11 + } catch (e) {
12 + return { errors: SettlementService.handleError(e) }
13 + }
14 + }
15 +
16 + static async getBalances(tripId: string): Promise<IResultObject<IBalance[]>> {
17 + try {
18 + const response = await httpClient.get<IBalance[]>(`Settlements/trip/${tripId}/balances`)
19 + return { data: response.data }
20 + } catch (e) {
21 + return { errors: SettlementService.handleError(e) }
22 + }
23 + }
24 +
25 + static async markPaid(paymentId: string): Promise<IResultObject<void>> {
26 + try {
27 + await httpClient.post(`Settlements/payments/${paymentId}/mark-paid`)
28 + return { data: undefined }
29 + } catch (e) {
30 + return { errors: SettlementService.handleError(e) }
31 + }
32 + }
33 +
34 + static async confirmPayment(paymentId: string): Promise<IResultObject<void>> {
35 + try {
36 + await httpClient.post(`Settlements/payments/${paymentId}/confirm`)
37 + return { data: undefined }
38 + } catch (e) {
39 + return { errors: SettlementService.handleError(e) }
40 + }
41 + }
42 +
43 + private static handleError(e: unknown): string[] {
44 + if (axios.isAxiosError(e)) {
45 + const data = e.response?.data
46 + if (data?.errors && typeof data.errors === 'object') {
47 + const messages: string[] = []
48 + for (const field of Object.values(data.errors)) {
49 + if (Array.isArray(field)) messages.push(...field)
50 + }
51 + if (messages.length > 0) return messages
52 + }
53 + if (data?.title) return [data.title]
54 + return [e.response?.statusText ?? 'Unknown error']
55 + }
56 + return ['Network error or server unavailable']
57 + }
58 +}
added src/services/TripService.ts +94 −0
@@ -0,0 +1,94 @@
1 +import httpClient from '@/services/httpClient'
2 +import type { IResultObject } from '@/types/IResultObject'
3 +import type { ITrip, ITripCreate, ITripUpdate } from '@/types/ITrip'
4 +import axios from 'axios'
5 +
6 +export default class TripService {
7 + static async getAll(): Promise<IResultObject<ITrip[]>> {
8 + try {
9 + const response = await httpClient.get<ITrip[]>('Trips')
10 + return { data: response.data }
11 + } catch (e) {
12 + return { errors: TripService.handleError(e) }
13 + }
14 + }
15 +
16 + static async getById(id: string): Promise<IResultObject<ITrip>> {
17 + try {
18 + const response = await httpClient.get<ITrip>(`Trips/${id}`)
19 + return { data: response.data }
20 + } catch (e) {
21 + return { errors: TripService.handleError(e) }
22 + }
23 + }
24 +
25 + static async create(entity: ITripCreate): Promise<IResultObject<ITrip>> {
26 + try {
27 + const response = await httpClient.post<ITrip>('Trips', entity)
28 + return { data: response.data }
29 + } catch (e) {
30 + return { errors: TripService.handleError(e) }
31 + }
32 + }
33 +
34 + static async update(id: string, entity: ITripUpdate): Promise<IResultObject<void>> {
35 + try {
36 + await httpClient.put(`Trips/${id}`, entity)
37 + return { data: undefined }
38 + } catch (e) {
39 + return { errors: TripService.handleError(e) }
40 + }
41 + }
42 +
43 + static async delete(id: string): Promise<IResultObject<void>> {
44 + try {
45 + await httpClient.delete(`Trips/${id}`)
46 + return { data: undefined }
47 + } catch (e) {
48 + return { errors: TripService.handleError(e) }
49 + }
50 + }
51 +
52 + static async finalize(tripId: string): Promise<IResultObject<void>> {
53 + try {
54 + await httpClient.post(`Trips/${tripId}/finalize`)
55 + return { data: undefined }
56 + } catch (e) {
57 + return { errors: TripService.handleError(e) }
58 + }
59 + }
60 +
61 + static async reopen(tripId: string): Promise<IResultObject<void>> {
62 + try {
63 + await httpClient.post(`Trips/${tripId}/reopen`)
64 + return { data: undefined }
65 + } catch (e) {
66 + return { errors: TripService.handleError(e) }
67 + }
68 + }
69 +
70 + static async getParticipants(tripId: string): Promise<IResultObject<import('@/types/ITrip').ITripParticipant[]>> {
71 + try {
72 + const response = await httpClient.get<import('@/types/ITrip').ITripParticipant[]>(`Trips/${tripId}/participants`)
73 + return { data: response.data }
74 + } catch (e) {
75 + return { errors: TripService.handleError(e) }
76 + }
77 + }
78 +
79 + private static handleError(e: unknown): string[] {
80 + if (axios.isAxiosError(e)) {
81 + const data = e.response?.data
82 + if (data?.errors && typeof data.errors === 'object') {
83 + const messages: string[] = []
84 + for (const field of Object.values(data.errors)) {
85 + if (Array.isArray(field)) messages.push(...field)
86 + }
87 + if (messages.length > 0) return messages
88 + }
89 + if (data?.title) return [data.title]
90 + return [e.response?.statusText ?? 'Unknown error']
91 + }
92 + return ['Network error or server unavailable']
93 + }
94 +}
added src/services/WishlistService.ts +77 −0
@@ -0,0 +1,77 @@
1 +import httpClient from '@/services/httpClient'
2 +import type { IResultObject } from '@/types/IResultObject'
3 +import type { IWishlistItem, IWishlistItemCreate } from '@/types/IWishlist'
4 +import axios from 'axios'
5 +
6 +export default class WishlistService {
7 + static async getByTrip(tripId: string): Promise<IResultObject<IWishlistItem[]>> {
8 + try {
9 + const response = await httpClient.get<IWishlistItem[]>(`Wishlist/trip/${tripId}`)
10 + return { data: response.data }
11 + } catch (e) {
12 + return { errors: WishlistService.handleError(e) }
13 + }
14 + }
15 +
16 + static async create(entity: IWishlistItemCreate): Promise<IResultObject<IWishlistItem>> {
17 + try {
18 + const response = await httpClient.post<IWishlistItem>('Wishlist', entity)
19 + return { data: response.data }
20 + } catch (e) {
21 + return { errors: WishlistService.handleError(e) }
22 + }
23 + }
24 +
25 + static async update(id: string, entity: IWishlistItemCreate): Promise<IResultObject<void>> {
26 + try {
27 + await httpClient.put(`Wishlist/${id}`, entity)
28 + return { data: undefined }
29 + } catch (e) {
30 + return { errors: WishlistService.handleError(e) }
31 + }
32 + }
33 +
34 + static async delete(id: string): Promise<IResultObject<void>> {
35 + try {
36 + await httpClient.delete(`Wishlist/${id}`)
37 + return { data: undefined }
38 + } catch (e) {
39 + return { errors: WishlistService.handleError(e) }
40 + }
41 + }
42 +
43 + static async vote(id: string): Promise<IResultObject<void>> {
44 + try {
45 + await httpClient.post(`Wishlist/${id}/vote`)
46 + return { data: undefined }
47 + } catch (e) {
48 + return { errors: WishlistService.handleError(e) }
49 + }
50 + }
51 +
52 + static async complete(id: string): Promise<IResultObject<void>> {
53 + try {
54 + await httpClient.post(`Wishlist/${id}/complete`)
55 + return { data: undefined }
56 + } catch (e) {
57 + return { errors: WishlistService.handleError(e) }
58 + }
59 + }
60 +
61 +
62 + private static handleError(e: unknown): string[] {
63 + if (axios.isAxiosError(e)) {
64 + const data = e.response?.data
65 + if (data?.errors && typeof data.errors === 'object') {
66 + const messages: string[] = []
67 + for (const field of Object.values(data.errors)) {
68 + if (Array.isArray(field)) messages.push(...field)
69 + }
70 + if (messages.length > 0) return messages
71 + }
72 + if (data?.title) return [data.title]
73 + return [e.response?.statusText ?? 'Unknown error']
74 + }
75 + return ['Network error or server unavailable']
76 + }
77 +}
added src/services/httpClient.ts +57 −0
@@ -0,0 +1,57 @@
1 +import axios from 'axios'
2 +import { useAuthStore } from '@/stores/auth'
3 +import { useLangStore } from '@/stores/lang'
4 +import AccountService from '@/services/AccountService'
5 +import router from '@/router'
6 +
7 +const httpClient = axios.create({
8 + baseURL: import.meta.env.VITE_API_BASE_URL,
9 +})
10 +
11 +httpClient.interceptors.request.use((config) => {
12 + const authStore = useAuthStore()
13 + if (authStore.jwt) {
14 + config.headers.Authorization = `Bearer ${authStore.jwt}`
15 + }
16 + const langStore = useLangStore()
17 + config.headers['Accept-Language'] = langStore.currentLocale
18 + return config
19 +})
20 +
21 +httpClient.interceptors.response.use(
22 + (response) => response,
23 + async (error) => {
24 + const originalRequest = error.config
25 +
26 + if (error.response?.status === 401 && !originalRequest._retry) {
27 + originalRequest._retry = true
28 +
29 + const authStore = useAuthStore()
30 + if (authStore.jwt && authStore.refreshToken) {
31 + const result = await AccountService.refreshTokenAsync(
32 + authStore.jwt,
33 + authStore.refreshToken,
34 + )
35 +
36 + if (result.data) {
37 + authStore.jwt = result.data.jwt
38 + authStore.refreshToken = result.data.refreshToken
39 + authStore.userName = `${result.data.firstName} ${result.data.lastName}`
40 +
41 + originalRequest.headers.Authorization = `Bearer ${result.data.jwt}`
42 + return httpClient(originalRequest)
43 + }
44 + }
45 +
46 + if (authStore.refreshToken) {
47 + await AccountService.logoutAsync(authStore.refreshToken)
48 + }
49 + authStore.logout()
50 + await router.push({ name: 'Login' })
51 + }
52 +
53 + return Promise.reject(error)
54 + },
55 +)
56 +
57 +export default httpClient
added src/stores/auth.ts +33 −0
@@ -0,0 +1,33 @@
1 +import { ref, computed, watch } from 'vue'
2 +import { defineStore } from 'pinia'
3 +
4 +export const useAuthStore = defineStore('auth', () => {
5 + const jwt = ref<string | null>(localStorage.getItem('jwt'))
6 + const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
7 + const userName = ref<string | null>(localStorage.getItem('userName'))
8 +
9 + const isAuthenticated = computed(() => !!jwt.value)
10 +
11 + watch(jwt, (val) => {
12 + if (val) localStorage.setItem('jwt', val)
13 + else localStorage.removeItem('jwt')
14 + })
15 +
16 + watch(refreshToken, (val) => {
17 + if (val) localStorage.setItem('refreshToken', val)
18 + else localStorage.removeItem('refreshToken')
19 + })
20 +
21 + watch(userName, (val) => {
22 + if (val) localStorage.setItem('userName', val)
23 + else localStorage.removeItem('userName')
24 + })
25 +
26 + function logout() {
27 + jwt.value = null
28 + refreshToken.value = null
29 + userName.value = null
30 + }
31 +
32 + return { jwt, refreshToken, userName, isAuthenticated, logout }
33 +})
added src/stores/counter.ts +12 −0
@@ -0,0 +1,12 @@
1 +import { ref, computed } from 'vue'
2 +import { defineStore } from 'pinia'
3 +
4 +export const useCounterStore = defineStore('counter', () => {
5 + const count = ref(0)
6 + const doubleCount = computed(() => count.value * 2)
7 + function increment() {
8 + count.value++
9 + }
10 +
11 + return { count, doubleCount, increment }
12 +})
added src/stores/lang.ts +42 −0
@@ -0,0 +1,42 @@
1 +import { ref, watch } from 'vue'
2 +import { defineStore } from 'pinia'
3 +import i18n, { DEFAULT_LOCALE, SUPPORTED_LOCALES, type AppLocale } from '@/i18n'
4 +
5 +function readCookieCulture(): AppLocale | null {
6 + const match = document.cookie.match(/(?:^|;\s*)\.AspNetCore\.Culture=([^;]+)/)
7 + if (!match) return null
8 + const decoded = decodeURIComponent(match[1]!)
9 + const m = decoded.match(/c=([a-zA-Z-]+)/)
10 + const code = m?.[1]?.toLowerCase()
11 + if (code === 'en' || code === 'et') return code
12 + return null
13 +}
14 +
15 +function writeCookieCulture(locale: AppLocale) {
16 + const value = `c=${locale}|uic=${locale}`
17 + const oneYear = 60 * 60 * 24 * 365
18 + document.cookie = `.AspNetCore.Culture=${encodeURIComponent(value)}; path=/; max-age=${oneYear}; samesite=lax`
19 +}
20 +
21 +export const useLangStore = defineStore('lang', () => {
22 + const stored = (localStorage.getItem('locale') as AppLocale | null) ?? readCookieCulture() ?? DEFAULT_LOCALE
23 + const currentLocale = ref<AppLocale>(stored)
24 +
25 + function apply(locale: AppLocale) {
26 + i18n.global.locale.value = locale
27 + document.documentElement.lang = locale
28 + localStorage.setItem('locale', locale)
29 + writeCookieCulture(locale)
30 + }
31 +
32 + apply(currentLocale.value)
33 +
34 + watch(currentLocale, (val) => apply(val))
35 +
36 + function setLocale(locale: AppLocale) {
37 + if (!SUPPORTED_LOCALES.includes(locale)) return
38 + currentLocale.value = locale
39 + }
40 +
41 + return { currentLocale, setLocale }
42 +})
added src/types/IBudgetCategory.ts +17 −0
@@ -0,0 +1,17 @@
1 +export interface IBudgetCategory {
2 + id: string
3 + tripId: string
4 + name: string
5 + iconName: string | null
6 + plannedAmount: number | null
7 + spentAmount: number
8 + displayOrder: number
9 +}
10 +
11 +export interface IBudgetCategoryCreate {
12 + tripId: string
13 + name: string
14 + iconName: string | null
15 + plannedAmount: number | null
16 + displayOrder: number
17 +}
added src/types/ICurrency.ts +6 −0
@@ -0,0 +1,6 @@
1 +export interface ICurrency {
2 + id: string
3 + code: string
4 + name: string
5 + symbol: string
6 +}
added src/types/IExpense.ts +43 −0
@@ -0,0 +1,43 @@
1 +export interface IExpenseSplit {
2 + id: string
3 + userId: string
4 + userName: string | null
5 + amount: number
6 + percentage: number | null
7 +}
8 +
9 +export interface IExpenseSplitCreate {
10 + userId: string
11 + amount: number
12 + percentage: number | null
13 +}
14 +
15 +export interface IExpense {
16 + id: string
17 + tripId: string
18 + paidByUserId: string
19 + paidByUserName: string | null
20 + budgetCategoryId: string | null
21 + budgetCategoryName: string | null
22 + currencyId: string | null
23 + currencyCode: string | null
24 + currencySymbol: string | null
25 + amount: number
26 + amountInTripCurrency: number | null
27 + description: string | null
28 + expenseDate: string
29 + splitMethod: string
30 + splits: IExpenseSplit[] | null
31 +}
32 +
33 +export interface IExpenseCreate {
34 + tripId: string
35 + paidByUserId?: string | null
36 + budgetCategoryId: string | null
37 + currencyId: string | null
38 + amount: number
39 + description: string | null
40 + expenseDate: string
41 + splitMethod: string
42 + splits: IExpenseSplitCreate[] | null
43 +}
added src/types/IInvitation.ts +13 −0
@@ -0,0 +1,13 @@
1 +export interface IInvitation {
2 + id: string
3 + tripId: string
4 + tripName: string | null
5 + token: string
6 + status: string
7 + expiresAt: string
8 + invitedByUserName: string | null
9 +}
10 +
11 +export interface IInvitationCreate {
12 + tripId: string
13 +}
added src/types/IJwtResponse.ts +6 −0
@@ -0,0 +1,6 @@
1 +export interface IJwtResponse {
2 + jwt: string
3 + refreshToken: string
4 + firstName: string
5 + lastName: string
6 +}
added src/types/IPoll.ts +26 −0
@@ -0,0 +1,26 @@
1 +export interface IPollOption {
2 + id: string
3 + text: string
4 + voteCount: number
5 + votedByCurrentUser: boolean
6 + displayOrder: number
7 +}
8 +
9 +export interface IPoll {
10 + id: string
11 + tripId: string
12 + createdByUserId: string
13 + question: string
14 + allowMultipleVotes: boolean
15 + isAnonymous: boolean
16 + closedAt: string | null
17 + options: IPollOption[] | null
18 +}
19 +
20 +export interface IPollCreate {
21 + tripId: string
22 + question: string
23 + allowMultipleVotes: boolean
24 + isAnonymous: boolean
25 + options: string[]
26 +}
added src/types/IResultObject.ts +4 −0
@@ -0,0 +1,4 @@
1 +export interface IResultObject<TData> {
2 + errors?: string[]
3 + data?: TData
4 +}
added src/types/ISettlement.ts +31 −0
@@ -0,0 +1,31 @@
1 +export interface ISettlementPayment {
2 + id: string
3 + fromUserId: string
4 + fromUserName: string | null
5 + toUserId: string
6 + toUserName: string | null
7 + amount: number
8 + status: string
9 + markedPaidAt: string | null
10 + confirmedAt: string | null
11 +}
12 +
13 +export interface ISettlementPlan {
14 + id: string
15 + tripId: string
16 + totalAmount: number
17 + status: string
18 + completedAt: string | null
19 + payments: ISettlementPayment[] | null
20 +}
21 +
22 +export interface IBalance {
23 + userId: string
24 + userName: string | null
25 + balance: number
26 +}
27 +
28 +export interface ISettlementSummary {
29 + balances: IBalance[]
30 + latestPlan: ISettlementPlan | null
31 +}
added src/types/ITrip.ts +47 −0
@@ -0,0 +1,47 @@
1 +export interface ITripParticipant {
2 + id: string
3 + tripId: string
4 + userId: string
5 + userName: string | null
6 + userEmail: string | null
7 + role: string
8 + nickname: string | null
9 + joinedAt: string
10 + isActive: boolean
11 +}
12 +
13 +export interface ITrip {
14 + id: string
15 + name: string
16 + description: string | null
17 + destination: string | null
18 + startDate: string | null
19 + endDate: string | null
20 + status: string
21 + defaultCurrencyId: string
22 + defaultCurrencyCode: string | null
23 + defaultCurrencySymbol: string | null
24 + createdById: string
25 + participantCount: number
26 + participants: ITripParticipant[] | null
27 +}
28 +
29 +export interface ITripCreate {
30 + name: string
31 + description: string | null
32 + destination: string | null
33 + startDate: string | null
34 + endDate: string | null
35 + defaultCurrencyId: string
36 +}
37 +
38 +export interface ITripUpdate {
39 + id: string
40 + name: string
41 + description: string | null
42 + destination: string | null
43 + startDate: string | null
44 + endDate: string | null
45 + status: string | null
46 + defaultCurrencyId: string
47 +}
added src/types/IWishlist.ts +28 −0
@@ -0,0 +1,28 @@
1 +export interface IWishlistItem {
2 + id: string
3 + tripId: string
4 + addedByUserId: string
5 + addedByUserName: string | null
6 + title: string
7 + description: string | null
8 + category: string
9 + priority: string
10 + estimatedCost: number | null
11 + url: string | null
12 + location: string | null
13 + isCompleted: boolean
14 + voteCount: number
15 + userHasVoted: boolean
16 + displayOrder: number
17 +}
18 +
19 +export interface IWishlistItemCreate {
20 + tripId: string
21 + title: string
22 + description: string | null
23 + category: string
24 + priority: string
25 + estimatedCost: number | null
26 + url: string | null
27 + location: string | null
28 +}
added src/utils/formatCurrency.ts +17 −0
@@ -0,0 +1,17 @@
1 +import i18n from '@/i18n'
2 +
3 +export function formatCurrency(
4 + amount: number,
5 + symbol?: string | null,
6 + decimals: number = 2,
7 +): string {
8 + const locale = i18n.global.locale.value === 'et' ? 'et-EE' : 'en-US'
9 + const abs = Math.abs(amount)
10 + const formatted = abs.toLocaleString(locale, {
11 + minimumFractionDigits: decimals,
12 + maximumFractionDigits: decimals,
13 + })
14 + const sign = amount < 0 ? '-' : ''
15 + const sym = symbol ?? ''
16 + return `${sign}${sym}${formatted}`
17 +}
added src/utils/parseJwt.ts +21 −0
@@ -0,0 +1,21 @@
1 +export function parseJwt(token: string): Record<string, unknown> | null {
2 + try {
3 + const payload = token.split('.')[1]
4 + if (!payload) return null
5 + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
6 + return JSON.parse(json)
7 + } catch {
8 + return null
9 + }
10 +}
11 +
12 +export function getUserIdFromJwt(token: string): string | null {
13 + const claims = parseJwt(token)
14 + if (!claims) return null
15 + // ASP.NET Core uses this claim URI for NameIdentifier
16 + return (
17 + (claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] as string) ??
18 + (claims['sub'] as string) ??
19 + null
20 + )
21 +}
added src/views/HomeView.vue +137 −0
@@ -0,0 +1,137 @@
1 +<script setup lang="ts">
2 +import { computed } from 'vue'
3 +import { useI18n } from 'vue-i18n'
4 +import { useAuthStore } from '@/stores/auth'
5 +
6 +const authStore = useAuthStore()
7 +const { t } = useI18n()
8 +
9 +const features = computed(() => [
10 + { icon: 'bi-receipt-cutoff', title: t('home.features.expenseTitle'), text: t('home.features.expenseText') },
11 + { icon: 'bi-pie-chart-fill', title: t('home.features.budgetTitle'), text: t('home.features.budgetText') },
12 + { icon: 'bi-heart-fill', title: t('home.features.wishlistTitle'), text: t('home.features.wishlistText') },
13 + { icon: 'bi-bar-chart-fill', title: t('home.features.pollsTitle'), text: t('home.features.pollsText') },
14 + { icon: 'bi-arrow-left-right', title: t('home.features.settlementTitle'), text: t('home.features.settlementText') },
15 +])
16 +</script>
17 +
18 +<template>
19 + <div>
20 + <!-- Hero Section -->
21 + <div class="sa-hero">
22 + <div class="sa-hero-content">
23 + <div class="sa-hero-badge sa-animate-fade-in">
24 + <i class="bi bi-airplane-fill me-1"></i> {{ t('home.heroBadge') }}
25 + </div>
26 + <h1 class="sa-hero-title sa-animate-fade-in">
27 + {{ t('home.heroTitle1') }}
28 + <span class="sa-hero-title-soft">{{ t('home.heroTitle2') }}</span>
29 + </h1>
30 + <p class="sa-hero-subtitle sa-animate-slide-up sa-stagger-1">
31 + {{ t('home.heroSubtitle') }}
32 + </p>
33 +
34 + <div class="sa-hero-btns sa-animate-slide-up sa-stagger-2">
35 + <template v-if="authStore.isAuthenticated">
36 + <RouterLink :to="{ name: 'TripsIndex' }" class="sa-hero-btn-white">
37 + <i class="bi bi-suitcase-lg me-1"></i> {{ t('home.myTrips') }}
38 + </RouterLink>
39 + </template>
40 + <template v-else>
41 + <RouterLink :to="{ name: 'Register' }" class="sa-hero-btn-white">
42 + {{ t('home.getStartedFree') }} <i class="bi bi-arrow-right ms-1"></i>
43 + </RouterLink>
44 + <RouterLink :to="{ name: 'Login' }" class="sa-hero-btn-outline">
45 + {{ t('nav.signIn') }}
46 + </RouterLink>
47 + </template>
48 + </div>
49 +
50 + </div>
51 + </div>
52 +
53 + <!-- Features Grid -->
54 + <div class="sa-home-section">
55 + <h2 class="text-center mb-2 sa-animate-fade-in" style="font-size: 1.75rem; font-weight: 800">{{ t('home.featuresTitle') }}</h2>
56 + <p class="text-center mb-5 sa-animate-fade-in" style="color: var(--sa-gray-500); max-width: 520px; margin: 0 auto">
57 + {{ t('home.featuresSubtitle') }}
58 + </p>
59 + <div class="row g-4 mb-5 justify-content-center">
60 + <div
61 + v-for="(feature, index) in features"
62 + :key="feature.title"
63 + class="col-md-6 col-lg-4 sa-animate-slide-up"
64 + :class="`sa-stagger-${index + 1}`"
65 + >
66 + <div class="sa-card sa-feature-card sa-hover-lift h-100">
67 + <div class="sa-card-body">
68 + <div class="sa-feature-icon-wrap">
69 + <i :class="['bi', feature.icon]"></i>
70 + </div>
71 + <div class="sa-feature-title">{{ feature.title }}</div>
72 + <div class="sa-feature-text">{{ feature.text }}</div>
73 + </div>
74 + </div>
75 + </div>
76 + </div>
77 + </div>
78 + </div>
79 +</template>
80 +
81 +<style scoped>
82 +.sa-home-section {
83 + padding-top: var(--sa-space-8);
84 +}
85 +
86 +.sa-hero-badge {
87 + display: inline-block;
88 + background: rgba(255, 255, 255, 0.2);
89 + border: 1px solid rgba(255, 255, 255, 0.3);
90 + border-radius: 9999px;
91 + padding: 6px 16px;
92 + font-size: 0.813rem;
93 + font-weight: 600;
94 + color: #fff;
95 + margin-bottom: 20px;
96 + backdrop-filter: blur(8px);
97 +}
98 +
99 +.sa-hero-title {
100 + font-size: 3rem;
101 + font-weight: 900;
102 + line-height: 1.15;
103 + margin-bottom: 16px;
104 + letter-spacing: -0.03em;
105 + color: #fff;
106 +}
107 +
108 +.sa-hero-title-soft {
109 + color: rgba(255, 255, 255, 0.85);
110 + font-weight: 800;
111 +}
112 +
113 +.sa-hero-subtitle {
114 + font-size: 1.125rem;
115 + line-height: 1.6;
116 + max-width: 520px;
117 +}
118 +
119 +.sa-feature-icon-wrap {
120 + width: 48px;
121 + height: 48px;
122 + border-radius: 14px;
123 + display: flex;
124 + align-items: center;
125 + justify-content: center;
126 + margin: 0 auto 16px;
127 + background: var(--sa-gray-100);
128 + color: var(--sa-primary);
129 + font-size: 1.4rem;
130 +}
131 +
132 +@media (max-width: 767px) {
133 + .sa-hero-title {
134 + font-size: 2rem;
135 + }
136 +}
137 +</style>
added src/views/LoginView.vue +118 −0
@@ -0,0 +1,118 @@
1 +<script setup lang="ts">
2 +import { ref } from 'vue'
3 +import { useRouter } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import { useAuthStore } from '@/stores/auth'
6 +import AccountService from '@/services/AccountService'
7 +
8 +const router = useRouter()
9 +const authStore = useAuthStore()
10 +const { t } = useI18n()
11 +
12 +const email = ref('')
13 +const password = ref('')
14 +const errors = ref<string[]>([])
15 +const isLoading = ref(false)
16 +
17 +async function handleLogin() {
18 + errors.value = []
19 + isLoading.value = true
20 +
21 + const result = await AccountService.loginAsync(email.value, password.value)
22 + if (result.errors) {
23 + errors.value = result.errors
24 + } else if (result.data) {
25 + authStore.jwt = result.data.jwt
26 + authStore.refreshToken = result.data.refreshToken
27 + authStore.userName = `${result.data.firstName} ${result.data.lastName}`
28 + router.push({ name: 'Home' })
29 + }
30 +
31 + isLoading.value = false
32 +}
33 +</script>
34 +
35 +<template>
36 + <div class="sa-auth-page">
37 + <div class="sa-auth-card sa-card sa-animate-fade-in">
38 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)">
39 + <!-- Brand -->
40 + <div class="sa-auth-brand">
41 + <div class="sa-auth-logo">
42 + <i class="bi bi-airplane-fill"></i>
43 + </div>
44 + <h1 class="sa-text-primary">SplitApp</h1>
45 + <p>{{ t('auth.login.welcomeBack') }}</p>
46 + </div>
47 +
48 + <!-- Errors -->
49 + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
50 + <div class="sa-card-body" style="padding: var(--sa-space-3) var(--sa-space-4)">
51 + <div v-for="error in errors" :key="error" style="font-size: 0.875rem">
52 + <i class="bi bi-exclamation-circle me-1"></i>{{ error }}
53 + </div>
54 + </div>
55 + </div>
56 +
57 + <!-- Form -->
58 + <form @submit.prevent="handleLogin">
59 + <div class="mb-3">
60 + <label for="email" class="form-label">{{ t('auth.login.email') }}</label>
61 + <input
62 + id="email"
63 + v-model="email"
64 + type="email"
65 + class="form-control"
66 + :placeholder="t('auth.login.emailPlaceholder')"
67 + required
68 + autocomplete="email"
69 + />
70 + </div>
71 +
72 + <div class="mb-4">
73 + <label for="password" class="form-label">{{ t('auth.login.password') }}</label>
74 + <input
75 + id="password"
76 + v-model="password"
77 + type="password"
78 + class="form-control"
79 + :placeholder="t('auth.login.passwordPlaceholder')"
80 + required
81 + autocomplete="current-password"
82 + />
83 + </div>
84 +
85 + <button
86 + type="submit"
87 + class="sa-btn sa-btn-primary w-100"
88 + :class="{ 'sa-btn-loading': isLoading }"
89 + :disabled="isLoading"
90 + >
91 + {{ isLoading ? t('auth.login.signingIn') : t('auth.login.signIn') }}
92 + </button>
93 + </form>
94 +
95 + <!-- Register link -->
96 + <div class="text-center mt-4" style="font-size: 0.938rem; color: var(--sa-gray-500)">
97 + {{ t('auth.login.noAccount') }}
98 + <RouterLink :to="{ name: 'Register' }" style="font-weight: 600">{{ t('auth.login.createOne') }}</RouterLink>
99 + </div>
100 + </div>
101 + </div>
102 + </div>
103 +</template>
104 +
105 +<style scoped>
106 +.sa-auth-logo {
107 + width: 56px;
108 + height: 56px;
109 + border-radius: 16px;
110 + background: linear-gradient(135deg, #e8604c 0%, #d4456a 100%);
111 + display: flex;
112 + align-items: center;
113 + justify-content: center;
114 + font-size: 1.5rem;
115 + color: #fff;
116 + margin: 0 auto 16px;
117 +}
118 +</style>
added src/views/RegisterView.vue +152 −0
@@ -0,0 +1,152 @@
1 +<script setup lang="ts">
2 +import { ref } from 'vue'
3 +import { useRouter } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import { useAuthStore } from '@/stores/auth'
6 +import AccountService from '@/services/AccountService'
7 +
8 +const router = useRouter()
9 +const authStore = useAuthStore()
10 +const { t } = useI18n()
11 +
12 +const firstName = ref('')
13 +const lastName = ref('')
14 +const email = ref('')
15 +const password = ref('')
16 +const errors = ref<string[]>([])
17 +const isLoading = ref(false)
18 +
19 +async function handleRegister() {
20 + errors.value = []
21 + isLoading.value = true
22 +
23 + const result = await AccountService.registerAsync(
24 + email.value,
25 + password.value,
26 + firstName.value,
27 + lastName.value,
28 + )
29 +
30 + if (result.errors) {
31 + errors.value = result.errors
32 + } else if (result.data) {
33 + authStore.jwt = result.data.jwt
34 + authStore.refreshToken = result.data.refreshToken
35 + authStore.userName = `${result.data.firstName} ${result.data.lastName}`
36 + router.push({ name: 'Home' })
37 + }
38 +
39 + isLoading.value = false
40 +}
41 +</script>
42 +
43 +<template>
44 + <div class="sa-auth-page">
45 + <div class="sa-auth-card sa-card sa-animate-fade-in">
46 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)">
47 + <!-- Brand -->
48 + <div class="sa-auth-brand">
49 + <div class="sa-auth-logo">
50 + <i class="bi bi-airplane-fill"></i>
51 + </div>
52 + <h1 class="sa-text-primary">SplitApp</h1>
53 + <p>{{ t('auth.register.getStarted') }}</p>
54 + </div>
55 +
56 + <!-- Errors -->
57 + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
58 + <div class="sa-card-body" style="padding: var(--sa-space-3) var(--sa-space-4)">
59 + <div v-for="error in errors" :key="error" style="font-size: 0.875rem">
60 + <i class="bi bi-exclamation-circle me-1"></i>{{ error }}
61 + </div>
62 + </div>
63 + </div>
64 +
65 + <!-- Form -->
66 + <form @submit.prevent="handleRegister">
67 + <div class="d-flex gap-3 mb-3">
68 + <div class="flex-fill">
69 + <label for="firstName" class="form-label">{{ t('auth.register.firstName') }}</label>
70 + <input
71 + id="firstName"
72 + v-model="firstName"
73 + type="text"
74 + class="form-control"
75 + :placeholder="t('auth.register.firstNamePlaceholder')"
76 + required
77 + />
78 + </div>
79 + <div class="flex-fill">
80 + <label for="lastName" class="form-label">{{ t('auth.register.lastName') }}</label>
81 + <input
82 + id="lastName"
83 + v-model="lastName"
84 + type="text"
85 + class="form-control"
86 + :placeholder="t('auth.register.lastNamePlaceholder')"
87 + required
88 + />
89 + </div>
90 + </div>
91 +
92 + <div class="mb-3">
93 + <label for="email" class="form-label">{{ t('auth.register.email') }}</label>
94 + <input
95 + id="email"
96 + v-model="email"
97 + type="email"
98 + class="form-control"
99 + :placeholder="t('auth.register.emailPlaceholder')"
100 + required
101 + autocomplete="email"
102 + />
103 + </div>
104 +
105 + <div class="mb-4">
106 + <label for="password" class="form-label">{{ t('auth.register.password') }}</label>
107 + <input
108 + id="password"
109 + v-model="password"
110 + type="password"
111 + class="form-control"
112 + :placeholder="t('auth.register.passwordPlaceholder')"
113 + required
114 + minlength="6"
115 + autocomplete="new-password"
116 + />
117 + </div>
118 +
119 + <button
120 + type="submit"
121 + class="sa-btn sa-btn-primary w-100"
122 + :class="{ 'sa-btn-loading': isLoading }"
123 + :disabled="isLoading"
124 + >
125 + {{ isLoading ? t('auth.register.creatingAccount') : t('auth.register.createAccount') }}
126 + </button>
127 + </form>
128 +
129 + <!-- Login link -->
130 + <div class="text-center mt-4" style="font-size: 0.938rem; color: var(--sa-gray-500)">
131 + {{ t('auth.register.haveAccount') }}
132 + <RouterLink :to="{ name: 'Login' }" style="font-weight: 600">{{ t('auth.register.signIn') }}</RouterLink>
133 + </div>
134 + </div>
135 + </div>
136 + </div>
137 +</template>
138 +
139 +<style scoped>
140 +.sa-auth-logo {
141 + width: 56px;
142 + height: 56px;
143 + border-radius: 16px;
144 + background: linear-gradient(135deg, #e8604c 0%, #d4456a 100%);
145 + display: flex;
146 + align-items: center;
147 + justify-content: center;
148 + font-size: 1.5rem;
149 + color: #fff;
150 + margin: 0 auto 16px;
151 +}
152 +</style>
added src/views/budget-categories/CreateView.vue +118 −0
@@ -0,0 +1,118 @@
1 +<script setup lang="ts">
2 +import { ref } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import BudgetCategoryService from '@/services/BudgetCategoryService'
6 +import { useToast } from '@/composables/useToast'
7 +
8 +const router = useRouter()
9 +const route = useRoute()
10 +const toast = useToast()
11 +const { t } = useI18n()
12 +
13 +const tripId = route.params.tripId as string
14 +
15 +const name = ref('')
16 +const iconName = ref('')
17 +const plannedAmount = ref<number | null>(null)
18 +const displayOrder = ref<number>(0)
19 +const errors = ref<string[]>([])
20 +const isSaving = ref(false)
21 +
22 +async function handleSubmit() {
23 + errors.value = []
24 + isSaving.value = true
25 +
26 + const result = await BudgetCategoryService.create({
27 + tripId,
28 + name: name.value,
29 + iconName: iconName.value || null,
30 + plannedAmount: plannedAmount.value,
31 + displayOrder: displayOrder.value,
32 + })
33 +
34 + if (result.errors) {
35 + errors.value = result.errors
36 + } else {
37 + toast.success(t('budget.create.created'))
38 + router.push({ name: 'BudgetIndex', params: { tripId } })
39 + }
40 +
41 + isSaving.value = false
42 +}
43 +</script>
44 +
45 +<template>
46 + <div class="row justify-content-center">
47 + <div class="col-md-8 col-lg-6">
48 + <h3 class="mb-4"><i class="bi bi-plus-circle me-2 sa-text-primary"></i>{{ t('budget.create.title') }}</h3>
49 +
50 + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
51 + <div class="sa-card-body">
52 + <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div>
53 + </div>
54 + </div>
55 +
56 + <form @submit.prevent="handleSubmit">
57 + <div class="mb-3">
58 + <label for="name" class="form-label">{{ t('budget.create.name') }}</label>
59 + <input id="name" v-model="name" type="text" class="form-control" :placeholder="t('budget.create.namePlaceholder')" required />
60 + </div>
61 +
62 + <div class="mb-3">
63 + <label for="iconName" class="form-label">{{ t('budget.create.iconName') }}</label>
64 + <input
65 + id="iconName"
66 + v-model="iconName"
67 + type="text"
68 + class="form-control"
69 + :placeholder="t('budget.create.iconNamePlaceholder')"
70 + />
71 + <div class="form-text">{{ t('budget.create.iconHelp') }}</div>
72 + </div>
73 +
74 + <div class="mb-3">
75 + <label for="plannedAmount" class="form-label">{{ t('budget.create.plannedAmount') }}</label>
76 + <input
77 + id="plannedAmount"
78 + v-model.number="plannedAmount"
79 + type="number"
80 + step="0.01"
81 + min="0"
82 + class="form-control"
83 + placeholder="0.00"
84 + />
85 + </div>
86 +
87 + <div class="mb-4">
88 + <label for="displayOrder" class="form-label">{{ t('budget.create.displayOrder') }}</label>
89 + <input
90 + id="displayOrder"
91 + v-model.number="displayOrder"
92 + type="number"
93 + min="0"
94 + class="form-control"
95 + />
96 + </div>
97 +
98 + <div class="d-flex gap-2 justify-content-end">
99 + <button
100 + type="button"
101 + class="sa-btn sa-btn-ghost"
102 + @click="router.push({ name: 'BudgetIndex', params: { tripId } })"
103 + >
104 + {{ t('common.cancel') }}
105 + </button>
106 + <button
107 + type="submit"
108 + class="sa-btn sa-btn-primary"
109 + :class="{ 'sa-btn-loading': isSaving }"
110 + :disabled="isSaving"
111 + >
112 + <i class="bi bi-check-lg"></i> {{ t('budget.create.addCategory') }}
113 + </button>
114 + </div>
115 + </form>
116 + </div>
117 + </div>
118 +</template>
added src/views/budget-categories/EditView.vue +170 −0
@@ -0,0 +1,170 @@
1 +<script setup lang="ts">
2 +import { ref, onMounted } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import BudgetCategoryService from '@/services/BudgetCategoryService'
6 +import { useToast } from '@/composables/useToast'
7 +
8 +const router = useRouter()
9 +const route = useRoute()
10 +const toast = useToast()
11 +const { t } = useI18n()
12 +
13 +const tripId = route.params.tripId as string
14 +const categoryId = route.params.id as string
15 +
16 +const name = ref('')
17 +const iconName = ref('')
18 +const plannedAmount = ref<number | null>(null)
19 +const spentAmount = ref<number>(0)
20 +const displayOrder = ref<number>(0)
21 +const errors = ref<string[]>([])
22 +const isSaving = ref(false)
23 +const isLoading = ref(true)
24 +
25 +onMounted(async () => {
26 + const result = await BudgetCategoryService.getByTrip(tripId)
27 + if (result.data) {
28 + const cat = result.data.find((c) => c.id === categoryId)
29 + if (cat) {
30 + name.value = cat.name
31 + iconName.value = cat.iconName || ''
32 + plannedAmount.value = cat.plannedAmount
33 + spentAmount.value = cat.spentAmount
34 + displayOrder.value = cat.displayOrder
35 + } else {
36 + errors.value = [t('budget.edit.notFound')]
37 + }
38 + } else if (result.errors) {
39 + errors.value = result.errors
40 + }
41 + isLoading.value = false
42 +})
43 +
44 +async function handleSubmit() {
45 + errors.value = []
46 + isSaving.value = true
47 +
48 + const result = await BudgetCategoryService.update(categoryId, {
49 + tripId,
50 + name: name.value,
51 + iconName: iconName.value || null,
52 + plannedAmount: plannedAmount.value,
53 + displayOrder: displayOrder.value,
54 + })
55 +
56 + if (result.errors) {
57 + errors.value = result.errors
58 + } else {
59 + toast.success(t('budget.edit.updated'))
60 + router.push({ name: 'BudgetIndex', params: { tripId } })
61 + }
62 +
63 + isSaving.value = false
64 +}
65 +
66 +async function handleDelete() {
67 + if (!confirm(t('budget.edit.confirmDelete'))) return
68 + const result = await BudgetCategoryService.delete(categoryId)
69 + if (result.errors) {
70 + toast.error(result.errors.join(', '))
71 + } else {
72 + toast.success(t('budget.index.deleted'))
73 + router.push({ name: 'BudgetIndex', params: { tripId } })
74 + }
75 +}
76 +</script>
77 +
78 +<template>
79 + <div class="row justify-content-center">
80 + <div v-if="isLoading" class="text-center py-5">
81 + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div>
82 + </div>
83 +
84 + <div v-else class="col-md-8 col-lg-6">
85 + <h3 class="mb-4"><i class="bi bi-pencil me-2 sa-text-primary"></i>{{ t('budget.edit.title') }}</h3>
86 +
87 + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
88 + <div class="sa-card-body">
89 + <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div>
90 + </div>
91 + </div>
92 +
93 + <form @submit.prevent="handleSubmit">
94 + <div class="mb-3">
95 + <label for="name" class="form-label">{{ t('budget.create.name') }}</label>
96 + <input id="name" v-model="name" type="text" class="form-control" required />
97 + </div>
98 +
99 + <div class="mb-3">
100 + <label for="iconName" class="form-label">{{ t('budget.create.iconName') }}</label>
101 + <input
102 + id="iconName"
103 + v-model="iconName"
104 + type="text"
105 + class="form-control"
106 + :placeholder="t('budget.create.iconNamePlaceholder')"
107 + />
108 + <div class="form-text">{{ t('budget.create.iconHelp') }}</div>
109 + </div>
110 +
111 + <div class="mb-3">
112 + <label for="plannedAmount" class="form-label">{{ t('budget.create.plannedAmount') }}</label>
113 + <input
114 + id="plannedAmount"
115 + v-model.number="plannedAmount"
116 + type="number"
117 + step="0.01"
118 + min="0"
119 + class="form-control"
120 + placeholder="0.00"
121 + />
122 + </div>
123 +
124 + <div class="mb-3">
125 + <label class="form-label">{{ t('budget.edit.spentAmount') }}</label>
126 + <input
127 + type="text"
128 + class="form-control"
129 + :value="spentAmount.toFixed(2)"
130 + readonly
131 + disabled
132 + />
133 + </div>
134 +
135 + <div class="mb-4">
136 + <label for="displayOrder" class="form-label">{{ t('budget.create.displayOrder') }}</label>
137 + <input
138 + id="displayOrder"
139 + v-model.number="displayOrder"
140 + type="number"
141 + min="0"
142 + class="form-control"
143 + />
144 + </div>
145 +
146 + <div class="d-flex gap-2">
147 + <button type="button" class="sa-btn sa-btn-danger sa-btn-sm" @click="handleDelete">
148 + <i class="bi bi-trash3"></i> {{ t('common.delete') }}
149 + </button>
150 + <div class="flex-grow-1"></div>
151 + <button
152 + type="button"
153 + class="sa-btn sa-btn-ghost"
154 + @click="router.push({ name: 'BudgetIndex', params: { tripId } })"
155 + >
156 + {{ t('common.cancel') }}
157 + </button>
158 + <button
159 + type="submit"
160 + class="sa-btn sa-btn-primary"
161 + :class="{ 'sa-btn-loading': isSaving }"
162 + :disabled="isSaving"
163 + >
164 + <i class="bi bi-check-lg"></i> {{ t('common.saveChanges') }}
165 + </button>
166 + </div>
167 + </form>
168 + </div>
169 + </div>
170 +</template>
added src/views/budget-categories/IndexView.vue +244 −0
@@ -0,0 +1,244 @@
1 +<script setup lang="ts">
2 +import { ref, computed, onMounted, inject, type ComputedRef } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import BudgetCategoryService from '@/services/BudgetCategoryService'
6 +import type { IBudgetCategory } from '@/types/IBudgetCategory'
7 +import { useToast } from '@/composables/useToast'
8 +import { formatCurrency } from '@/utils/formatCurrency'
9 +
10 +const { t } = useI18n()
11 +
12 +const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol')
13 +const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '')
14 +
15 +const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer')
16 +const isOrganizer = computed(() => _isOrganizer?.value ?? false)
17 +
18 +const router = useRouter()
19 +const route = useRoute()
20 +const toast = useToast()
21 +
22 +const categories = ref<IBudgetCategory[]>([])
23 +const isLoading = ref(true)
24 +const error = ref<string | null>(null)
25 +
26 +const tripId = route.params.tripId as string
27 +
28 +const totalPlanned = computed(() =>
29 + categories.value.reduce((sum, c) => sum + (c.plannedAmount || 0), 0),
30 +)
31 +
32 +const totalSpent = computed(() =>
33 + categories.value.reduce((sum, c) => sum + c.spentAmount, 0),
34 +)
35 +
36 +const remaining = computed(() => totalPlanned.value - totalSpent.value)
37 +
38 +const overallPercent = computed(() => {
39 + if (totalPlanned.value <= 0) return 0
40 + return Math.min(100, Math.round((totalSpent.value / totalPlanned.value) * 100))
41 +})
42 +
43 +function overallBarClass(): string {
44 + const pct = overallPercent.value
45 + if (pct > 85) return 'sa-progress-bar-danger'
46 + if (pct > 60) return 'sa-progress-bar-warning'
47 + return 'sa-progress-bar-success'
48 +}
49 +
50 +function spentStatColor(): string {
51 + const pct = overallPercent.value
52 + if (pct > 85) return 'color: var(--sa-danger)'
53 + if (pct > 60) return 'color: var(--sa-warning)'
54 + return 'color: var(--sa-success)'
55 +}
56 +
57 +onMounted(async () => {
58 + const result = await BudgetCategoryService.getByTrip(tripId)
59 + if (result.data) {
60 + categories.value = result.data
61 + } else if (result.errors) {
62 + error.value = result.errors.join(', ')
63 + }
64 + isLoading.value = false
65 +})
66 +
67 +async function deleteCategory(id: string) {
68 + if (!confirm(t('budget.index.confirmDelete'))) return
69 + error.value = null
70 +
71 + const result = await BudgetCategoryService.delete(id)
72 + if (result.errors) {
73 + toast.error(result.errors.join(', '))
74 + } else {
75 + categories.value = categories.value.filter((c) => c.id !== id)
76 + toast.success(t('budget.index.deleted'))
77 + }
78 +}
79 +
80 +function progressPercent(cat: IBudgetCategory): number {
81 + if (!cat.plannedAmount || cat.plannedAmount <= 0) return 0
82 + return Math.min(100, Math.round((cat.spentAmount / cat.plannedAmount) * 100))
83 +}
84 +
85 +function progressBarClass(cat: IBudgetCategory): string {
86 + const pct = progressPercent(cat)
87 + if (pct > 85) return 'sa-progress-bar-danger'
88 + if (pct > 60) return 'sa-progress-bar-warning'
89 + return 'sa-progress-bar-success'
90 +}
91 +
92 +function isOverBudget(cat: IBudgetCategory): boolean {
93 + return !!cat.plannedAmount && cat.spentAmount > cat.plannedAmount
94 +}
95 +
96 +function categoryIcon(cat: IBudgetCategory): string {
97 + if (cat.iconName) return 'bi-' + cat.iconName
98 + return 'bi-tag-fill'
99 +}
100 +</script>
101 +
102 +<template>
103 + <div>
104 + <!-- Gradient Header -->
105 + <div class="sa-gradient-header sa-gradient-header-green d-flex justify-content-between align-items-center">
106 + <div>
107 + <h3 class="mb-1"><i class="bi bi-pie-chart-fill me-2"></i>{{ t('budget.index.title') }}</h3>
108 + <p v-if="!isLoading && categories.length > 0" class="mb-0" style="font-size: 0.9rem; opacity: 0.85">
109 + {{ t('budget.index.categoryCount', { n: categories.length }, categories.length) }}
110 + </p>
111 + </div>
112 + <button
113 + v-if="isOrganizer"
114 + class="sa-btn sa-btn-primary sa-btn-pill sa-hide-mobile"
115 + style="background: #fff; color: var(--sa-success)"
116 + @click="router.push({ name: 'BudgetCreate', params: { tripId } })"
117 + >
118 + <i class="bi bi-plus-lg"></i> {{ t('budget.index.addCategory') }}
119 + </button>
120 + </div>
121 +
122 + <div v-if="error" class="alert alert-danger">{{ error }}</div>
123 +
124 + <div v-if="isLoading" class="text-center py-5">
125 + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div>
126 + </div>
127 +
128 + <div v-else-if="categories.length === 0" class="sa-empty">
129 + <div class="sa-empty-icon"><i class="bi bi-pie-chart"></i></div>
130 + <div class="sa-empty-title">{{ t('budget.index.emptyTitle') }}</div>
131 + <div class="sa-empty-text">{{ t('budget.index.emptyText') }}</div>
132 + <button
133 + v-if="isOrganizer"
134 + class="sa-btn sa-btn-primary sa-btn-pill"
135 + @click="router.push({ name: 'BudgetCreate', params: { tripId } })"
136 + >
137 + <i class="bi bi-plus-lg"></i> {{ t('budget.index.addCategory') }}
138 + </button>
139 + </div>
140 +
141 + <template v-else>
142 + <!-- Stat Cards -->
143 + <div class="row g-3 mb-4">
144 + <div class="col-4">
145 + <div class="sa-card-static sa-card-body sa-stat">
146 + <div class="sa-stat-icon"><i class="bi bi-calculator" style="color: var(--sa-gray-500)"></i></div>
147 + <div class="sa-stat-value">{{ formatCurrency(totalPlanned, tripCurrencySymbol) }}</div>
148 + <div class="sa-stat-label">{{ t('budget.index.totalPlanned') }}</div>
149 + </div>
150 + </div>
151 + <div class="col-4">
152 + <div class="sa-card-static sa-card-body sa-stat">
153 + <div class="sa-stat-icon"><i class="bi bi-credit-card" :style="spentStatColor()"></i></div>
154 + <div class="sa-stat-value" :style="spentStatColor()">{{ formatCurrency(totalSpent, tripCurrencySymbol) }}</div>
155 + <div class="sa-stat-label">{{ t('budget.index.totalSpent') }}</div>
156 + </div>
157 + </div>
158 + <div class="col-4">
159 + <div class="sa-card-static sa-card-body sa-stat">
160 + <div class="sa-stat-icon">
161 + <i class="bi bi-wallet2" :style="remaining >= 0 ? 'color: var(--sa-success)' : 'color: var(--sa-danger)'"></i>
162 + </div>
163 + <div class="sa-stat-value" :class="remaining >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'">
164 + {{ formatCurrency(remaining, tripCurrencySymbol) }}
165 + </div>
166 + <div class="sa-stat-label">{{ t('budget.index.remaining') }}</div>
167 + </div>
168 + </div>
169 + </div>
170 +
171 + <!-- Overall Progress -->
172 + <div class="sa-card-static mb-4">
173 + <div class="sa-card-body">
174 + <div class="d-flex justify-content-between align-items-center mb-2">
175 + <span style="font-weight: 600; font-size: 0.9rem">{{ t('budget.index.overallBudget') }}</span>
176 + <span style="font-size: 0.85rem; font-weight: 600" :style="overallPercent > 85 ? 'color: var(--sa-danger)' : overallPercent > 60 ? 'color: var(--sa-warning)' : 'color: var(--sa-success)'">
177 + {{ overallPercent }}%
178 + </span>
179 + </div>
180 + <div class="sa-progress">
181 + <div
182 + class="sa-progress-bar"
183 + :class="overallBarClass()"
184 + :style="{ width: overallPercent + '%' }"
185 + ></div>
186 + </div>
187 + </div>
188 + </div>
189 +
190 + <!-- Category List -->
191 + <div class="sa-card-static">
192 + <div v-for="cat in categories" :key="cat.id" class="sa-expense-item">
193 + <div class="sa-expense-icon sa-category-default">
194 + <i :class="['bi', categoryIcon(cat)]"></i>
195 + </div>
196 + <div class="sa-expense-details">
197 + <div class="sa-expense-desc">{{ cat.name }}</div>
198 + <div class="sa-expense-meta">
199 + <span>{{ formatCurrency(cat.spentAmount, tripCurrencySymbol) }}</span>
200 + <span v-if="cat.plannedAmount"> / {{ formatCurrency(cat.plannedAmount, tripCurrencySymbol) }}</span>
201 + <span v-if="cat.plannedAmount" class="ms-1" style="font-size: 0.75rem; font-weight: 600"
202 + :style="progressPercent(cat) > 85 ? 'color: var(--sa-danger)' : progressPercent(cat) > 60 ? 'color: var(--sa-warning)' : 'color: var(--sa-success)'"
203 + >
204 + ({{ progressPercent(cat) }}%)
205 + </span>
206 + </div>
207 + <div v-if="cat.plannedAmount" class="sa-progress sa-progress-sm mt-1">
208 + <div
209 + class="sa-progress-bar"
210 + :class="progressBarClass(cat)"
211 + :style="{ width: progressPercent(cat) + '%' }"
212 + ></div>
213 + </div>
214 + <div v-if="isOverBudget(cat)" style="font-size: 0.75rem; color: var(--sa-danger); font-weight: 600; margin-top: 2px">
215 + <i class="bi bi-exclamation-triangle-fill me-1"></i>{{ t('budget.index.overBudget') }}
216 + </div>
217 + </div>
218 + <div v-if="isOrganizer" class="sa-expense-actions">
219 + <button
220 + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
221 + @click="router.push({ name: 'BudgetEdit', params: { tripId, id: cat.id } })"
222 + :title="t('common.edit')"
223 + >
224 + <i class="bi bi-pencil"></i>
225 + </button>
226 + <button
227 + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
228 + style="color: var(--sa-danger)"
229 + @click="deleteCategory(cat.id)"
230 + :title="t('common.delete')"
231 + >
232 + <i class="bi bi-trash3"></i>
233 + </button>
234 + </div>
235 + </div>
236 + </div>
237 + </template>
238 +
239 + <!-- Mobile FAB -->
240 + <button v-if="isOrganizer" class="sa-fab" @click="router.push({ name: 'BudgetCreate', params: { tripId } })">
241 + <i class="bi bi-plus-lg"></i>
242 + </button>
243 + </div>
244 +</template>
added src/views/expenses/CreateView.vue +188 −0
@@ -0,0 +1,188 @@
1 +<script setup lang="ts">
2 +import { ref, computed, onMounted } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import ExpenseService from '@/services/ExpenseService'
6 +import BudgetCategoryService from '@/services/BudgetCategoryService'
7 +import CurrencyService from '@/services/CurrencyService'
8 +import TripService from '@/services/TripService'
9 +import type { IBudgetCategory } from '@/types/IBudgetCategory'
10 +import type { ICurrency } from '@/types/ICurrency'
11 +import type { ITripParticipant } from '@/types/ITrip'
12 +import type { IExpenseSplitCreate } from '@/types/IExpense'
13 +import { useToast } from '@/composables/useToast'
14 +import { useAuthStore } from '@/stores/auth'
15 +import { getUserIdFromJwt } from '@/utils/parseJwt'
16 +import SplitMethodSelector from '@/components/SplitMethodSelector.vue'
17 +
18 +const router = useRouter()
19 +const route = useRoute()
20 +const toast = useToast()
21 +const auth = useAuthStore()
22 +const { t } = useI18n()
23 +
24 +const tripId = route.params.tripId as string
25 +
26 +const amount = ref<number>(0)
27 +const description = ref('')
28 +const expenseDate = ref(new Date().toISOString().substring(0, 10))
29 +const splitMethod = ref('EqualAll')
30 +const budgetCategoryId = ref('')
31 +const currencyId = ref('')
32 +const paidByUserId = ref('')
33 +
34 +const budgetCategories = ref<IBudgetCategory[]>([])
35 +const currencies = ref<ICurrency[]>([])
36 +const participants = ref<ITripParticipant[]>([])
37 +const splits = ref<IExpenseSplitCreate[]>([])
38 +const splitsValid = ref(true)
39 +const errors = ref<string[]>([])
40 +const isSaving = ref(false)
41 +
42 +const splitMethods = computed(() => [
43 + { value: 'EqualAll', label: t('expenses.splitMethod.EqualAll'), icon: 'bi-people-fill' },
44 + { value: 'EqualSubset', label: t('expenses.splitMethod.EqualSubset'), icon: 'bi-person-check-fill' },
45 + { value: 'ExactAmounts', label: t('expenses.splitMethod.ExactAmounts'), icon: 'bi-hash' },
46 + { value: 'Percentages', label: t('expenses.splitMethod.Percentages'), icon: 'bi-percent' },
47 +])
48 +
49 +onMounted(async () => {
50 + const [catResult, currResult, partResult] = await Promise.all([
51 + BudgetCategoryService.getByTrip(tripId),
52 + CurrencyService.getAll(),
53 + TripService.getParticipants(tripId),
54 + ])
55 +
56 + if (catResult.data) budgetCategories.value = catResult.data
57 + if (currResult.data) currencies.value = currResult.data
58 + if (partResult.data) {
59 + participants.value = partResult.data
60 + const currentUserId = auth.jwt ? getUserIdFromJwt(auth.jwt) : null
61 + const match = partResult.data.find((p) => p.userId === currentUserId)
62 + paidByUserId.value = match?.userId ?? partResult.data[0]?.userId ?? ''
63 + }
64 +})
65 +
66 +async function handleSubmit() {
67 + errors.value = []
68 + isSaving.value = true
69 +
70 + const result = await ExpenseService.create({
71 + tripId,
72 + paidByUserId: paidByUserId.value || null,
73 + amount: amount.value,
74 + description: description.value || null,
75 + expenseDate: new Date(expenseDate.value).toISOString(),
76 + splitMethod: splitMethod.value,
77 + budgetCategoryId: budgetCategoryId.value || null,
78 + currencyId: currencyId.value || null,
79 + splits: splits.value,
80 + })
81 +
82 + if (result.errors) {
83 + errors.value = result.errors
84 + } else {
85 + toast.success(t('expenses.create.added'))
86 + router.push({ name: 'ExpensesIndex', params: { tripId } })
87 + }
88 +
89 + isSaving.value = false
90 +}
91 +</script>
92 +
93 +<template>
94 + <div class="row justify-content-center">
95 + <div class="col-md-8 col-lg-6">
96 + <h3 class="mb-4"><i class="bi bi-plus-circle me-2 sa-text-primary"></i>{{ t('expenses.create.title') }}</h3>
97 +
98 + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
99 + <div class="sa-card-body">
100 + <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div>
101 + </div>
102 + </div>
103 +
104 + <form @submit.prevent="handleSubmit">
105 + <div class="mb-4 text-center">
106 + <label for="amount" class="form-label">{{ t('expenses.create.amount') }}</label>
107 + <input
108 + id="amount"
109 + v-model.number="amount"
110 + type="number"
111 + step="0.01"
112 + min="0.01"
113 + class="form-control sa-amount-input"
114 + placeholder="0.00"
115 + required
116 + />
117 + </div>
118 +
119 + <div class="mb-3">
120 + <label for="description" class="form-label">{{ t('expenses.create.description') }}</label>
121 + <input id="description" v-model="description" type="text" class="form-control" :placeholder="t('expenses.create.descriptionPlaceholder')" />
122 + </div>
123 +
124 + <div class="mb-3">
125 + <label for="expenseDate" class="form-label">{{ t('expenses.create.date') }}</label>
126 + <input id="expenseDate" v-model="expenseDate" type="date" class="form-control" required />
127 + </div>
128 +
129 + <div class="mb-3">
130 + <label for="paidBy" class="form-label">{{ t('expenses.create.paidBy') }}</label>
131 + <select id="paidBy" v-model="paidByUserId" class="form-select" required>
132 + <option value="" disabled>{{ t('expenses.create.selectPayer') }}</option>
133 + <option v-for="p in participants" :key="p.userId" :value="p.userId">
134 + {{ p.nickname || p.userName || p.userEmail }}
135 + </option>
136 + </select>
137 + </div>
138 +
139 + <div class="mb-4">
140 + <label class="form-label">{{ t('expenses.create.splitMethod') }}</label>
141 + <div class="sa-split-methods">
142 + <label v-for="method in splitMethods" :key="method.value" class="sa-split-option">
143 + <input type="radio" :value="method.value" v-model="splitMethod" />
144 + <div class="sa-split-option-label">
145 + <div class="sa-split-option-icon"><i :class="['bi', method.icon]"></i></div>
146 + <div class="sa-split-option-text">{{ method.label }}</div>
147 + </div>
148 + </label>
149 + </div>
150 +
151 + <SplitMethodSelector
152 + v-if="participants.length > 0"
153 + :participants="participants"
154 + :total-amount="amount"
155 + :split-method="splitMethod"
156 + @update:splits="splits = $event"
157 + @update:valid="splitsValid = $event"
158 + />
159 + </div>
160 +
161 + <div class="mb-3">
162 + <label for="budgetCategory" class="form-label">{{ t('expenses.create.budgetCategory') }}</label>
163 + <select id="budgetCategory" v-model="budgetCategoryId" class="form-select">
164 + <option value="">{{ t('common.none') }}</option>
165 + <option v-for="cat in budgetCategories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
166 + </select>
167 + </div>
168 +
169 + <div class="mb-4">
170 + <label for="currency" class="form-label">{{ t('expenses.create.currency') }}</label>
171 + <select id="currency" v-model="currencyId" class="form-select">
172 + <option value="">{{ t('common.default') }}</option>
173 + <option v-for="c in currencies" :key="c.id" :value="c.id">{{ c.code }} — {{ c.name }} ({{ c.symbol }})</option>
174 + </select>
175 + </div>
176 +
177 + <div class="d-flex gap-2 justify-content-end">
178 + <button type="button" class="sa-btn sa-btn-ghost" @click="router.push({ name: 'ExpensesIndex', params: { tripId } })">
179 + {{ t('common.cancel') }}
180 + </button>
181 + <button type="submit" class="sa-btn sa-btn-primary" :class="{ 'sa-btn-loading': isSaving }" :disabled="isSaving || !splitsValid">
182 + <i class="bi bi-check-lg"></i> {{ t('expenses.create.addExpense') }}
183 + </button>
184 + </div>
185 + </form>
186 + </div>
187 + </div>
188 +</template>
added src/views/expenses/EditView.vue +197 −0
@@ -0,0 +1,197 @@
1 +<script setup lang="ts">
2 +import { ref, computed, onMounted } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import ExpenseService from '@/services/ExpenseService'
6 +import BudgetCategoryService from '@/services/BudgetCategoryService'
7 +import CurrencyService from '@/services/CurrencyService'
8 +import TripService from '@/services/TripService'
9 +import type { IBudgetCategory } from '@/types/IBudgetCategory'
10 +import type { ICurrency } from '@/types/ICurrency'
11 +import type { ITripParticipant } from '@/types/ITrip'
12 +import type { IExpenseSplit, IExpenseSplitCreate } from '@/types/IExpense'
13 +import { useToast } from '@/composables/useToast'
14 +import SplitMethodSelector from '@/components/SplitMethodSelector.vue'
15 +
16 +const router = useRouter()
17 +const route = useRoute()
18 +const toast = useToast()
19 +const { t } = useI18n()
20 +
21 +const tripId = route.params.tripId as string
22 +const expenseId = route.params.id as string
23 +
24 +const amount = ref<number>(0)
25 +const description = ref('')
26 +const expenseDate = ref('')
27 +const splitMethod = ref('EqualAll')
28 +const budgetCategoryId = ref('')
29 +const currencyId = ref('')
30 +
31 +const budgetCategories = ref<IBudgetCategory[]>([])
32 +const currencies = ref<ICurrency[]>([])
33 +const participants = ref<ITripParticipant[]>([])
34 +const existingSplits = ref<IExpenseSplit[] | null>(null)
35 +const splits = ref<IExpenseSplitCreate[]>([])
36 +const splitsValid = ref(true)
37 +const errors = ref<string[]>([])
38 +const isSaving = ref(false)
39 +const isLoading = ref(true)
40 +
41 +const splitMethods = computed(() => [
42 + { value: 'EqualAll', label: t('expenses.splitMethod.EqualAll'), icon: 'bi-people-fill' },
43 + { value: 'EqualSubset', label: t('expenses.splitMethod.EqualSubset'), icon: 'bi-person-check-fill' },
44 + { value: 'ExactAmounts', label: t('expenses.splitMethod.ExactAmounts'), icon: 'bi-hash' },
45 + { value: 'Percentages', label: t('expenses.splitMethod.Percentages'), icon: 'bi-percent' },
46 +])
47 +
48 +onMounted(async () => {
49 + const [expResult, catResult, currResult, partResult] = await Promise.all([
50 + ExpenseService.getById(expenseId),
51 + BudgetCategoryService.getByTrip(tripId),
52 + CurrencyService.getAll(),
53 + TripService.getParticipants(tripId),
54 + ])
55 +
56 + if (catResult.data) budgetCategories.value = catResult.data
57 + if (currResult.data) currencies.value = currResult.data
58 + if (partResult.data) participants.value = partResult.data
59 +
60 + if (expResult.data) {
61 + amount.value = expResult.data.amount
62 + description.value = expResult.data.description ?? ''
63 + expenseDate.value = expResult.data.expenseDate.substring(0, 10)
64 + splitMethod.value = expResult.data.splitMethod
65 + budgetCategoryId.value = expResult.data.budgetCategoryId ?? ''
66 + currencyId.value = expResult.data.currencyId ?? ''
67 + existingSplits.value = expResult.data.splits ?? null
68 + } else {
69 + errors.value = expResult.errors ?? [t('expenses.edit.notFound')]
70 + }
71 +
72 + isLoading.value = false
73 +})
74 +
75 +async function handleSubmit() {
76 + errors.value = []
77 + isSaving.value = true
78 +
79 + const result = await ExpenseService.update(expenseId, {
80 + tripId,
81 + amount: amount.value,
82 + description: description.value || null,
83 + expenseDate: new Date(expenseDate.value).toISOString(),
84 + splitMethod: splitMethod.value,
85 + budgetCategoryId: budgetCategoryId.value || null,
86 + currencyId: currencyId.value || null,
87 + splits: splits.value,
88 + })
89 +
90 + if (result.errors) {
91 + errors.value = result.errors
92 + } else {
93 + toast.success(t('expenses.edit.updated'))
94 + router.push({ name: 'ExpensesIndex', params: { tripId } })
95 + }
96 +
97 + isSaving.value = false
98 +}
99 +
100 +async function handleDelete() {
101 + if (!confirm(t('expenses.edit.confirmDelete'))) return
102 + const result = await ExpenseService.delete(expenseId)
103 + if (result.errors) {
104 + errors.value = result.errors
105 + } else {
106 + toast.success(t('expenses.edit.deleted'))
107 + router.push({ name: 'ExpensesIndex', params: { tripId } })
108 + }
109 +}
110 +</script>
111 +
112 +<template>
113 + <div class="row justify-content-center">
114 + <div v-if="isLoading" class="text-center py-5">
115 + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div>
116 + </div>
117 +
118 + <div v-else class="col-md-8 col-lg-6">
119 + <h3 class="mb-4"><i class="bi bi-pencil me-2 sa-text-primary"></i>{{ t('expenses.edit.title') }}</h3>
120 +
121 + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
122 + <div class="sa-card-body">
123 + <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div>
124 + </div>
125 + </div>
126 +
127 + <form @submit.prevent="handleSubmit">
128 + <div class="mb-4 text-center">
129 + <label for="amount" class="form-label">{{ t('expenses.create.amount') }}</label>
130 + <input id="amount" v-model.number="amount" type="number" step="0.01" min="0.01" class="form-control sa-amount-input" required />
131 + </div>
132 +
133 + <div class="mb-3">
134 + <label for="description" class="form-label">{{ t('expenses.create.description') }}</label>
135 + <input id="description" v-model="description" type="text" class="form-control" />
136 + </div>
137 +
138 + <div class="mb-3">
139 + <label for="expenseDate" class="form-label">{{ t('expenses.create.date') }}</label>
140 + <input id="expenseDate" v-model="expenseDate" type="date" class="form-control" required />
141 + </div>
142 +
143 + <div class="mb-4">
144 + <label class="form-label">{{ t('expenses.create.splitMethod') }}</label>
145 + <div class="sa-split-methods">
146 + <label v-for="method in splitMethods" :key="method.value" class="sa-split-option">
147 + <input type="radio" :value="method.value" v-model="splitMethod" />
148 + <div class="sa-split-option-label">
149 + <div class="sa-split-option-icon"><i :class="['bi', method.icon]"></i></div>
150 + <div class="sa-split-option-text">{{ method.label }}</div>
151 + </div>
152 + </label>
153 + </div>
154 +
155 + <SplitMethodSelector
156 + v-if="participants.length > 0"
157 + :participants="participants"
158 + :total-amount="amount"
159 + :split-method="splitMethod"
160 + :existing-splits="existingSplits"
161 + @update:splits="splits = $event"
162 + @update:valid="splitsValid = $event"
163 + />
164 + </div>
165 +
166 + <div class="mb-3">
167 + <label for="budgetCategory" class="form-label">{{ t('expenses.create.budgetCategory') }}</label>
168 + <select id="budgetCategory" v-model="budgetCategoryId" class="form-select">
169 + <option value="">{{ t('common.none') }}</option>
170 + <option v-for="cat in budgetCategories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
171 + </select>
172 + </div>
173 +
174 + <div class="mb-4">
175 + <label for="currency" class="form-label">{{ t('expenses.create.currency') }}</label>
176 + <select id="currency" v-model="currencyId" class="form-select">
177 + <option value="">{{ t('common.default') }}</option>
178 + <option v-for="c in currencies" :key="c.id" :value="c.id">{{ c.code }} — {{ c.name }} ({{ c.symbol }})</option>
179 + </select>
180 + </div>
181 +
182 + <div class="d-flex gap-2">
183 + <button type="button" class="sa-btn sa-btn-danger sa-btn-sm" @click="handleDelete">
184 + <i class="bi bi-trash3"></i> {{ t('common.delete') }}
185 + </button>
186 + <div class="flex-grow-1"></div>
187 + <button type="button" class="sa-btn sa-btn-ghost" @click="router.push({ name: 'ExpensesIndex', params: { tripId } })">
188 + {{ t('common.cancel') }}
189 + </button>
190 + <button type="submit" class="sa-btn sa-btn-primary" :class="{ 'sa-btn-loading': isSaving }" :disabled="isSaving || !splitsValid">
191 + <i class="bi bi-check-lg"></i> {{ t('common.saveChanges') }}
192 + </button>
193 + </div>
194 + </form>
195 + </div>
196 + </div>
197 +</template>
added src/views/expenses/IndexView.vue +168 −0
@@ -0,0 +1,168 @@
1 +<script setup lang="ts">
2 +import { ref, computed, onMounted, inject, type ComputedRef } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import ExpenseService from '@/services/ExpenseService'
6 +import type { IExpense } from '@/types/IExpense'
7 +import { useToast } from '@/composables/useToast'
8 +import { formatCurrency } from '@/utils/formatCurrency'
9 +
10 +const { t, d } = useI18n()
11 +
12 +const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol')
13 +const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '')
14 +
15 +const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer')
16 +const isOrganizer = computed(() => _isOrganizer?.value ?? false)
17 +
18 +const _currentUserId = inject<ComputedRef<string | null>>('currentUserId')
19 +const currentUserId = computed(() => _currentUserId?.value ?? null)
20 +
21 +const _tripStatus = inject<ComputedRef<string>>('tripStatus')
22 +const tripStatus = computed(() => _tripStatus?.value ?? 'Active')
23 +
24 +const isActive = computed(() => tripStatus.value === 'Active')
25 +
26 +function canEditExpense(expense: IExpense): boolean {
27 + return isActive.value && (isOrganizer.value || currentUserId.value === expense.paidByUserId)
28 +}
29 +
30 +const router = useRouter()
31 +const route = useRoute()
32 +const toast = useToast()
33 +
34 +const expenses = ref<IExpense[]>([])
35 +const isLoading = ref(true)
36 +const error = ref<string | null>(null)
37 +
38 +const tripId = route.params.tripId as string
39 +
40 +const totalAmount = computed(() => expenses.value.reduce((sum, e) => sum + (e.amountInTripCurrency ?? e.amount), 0))
41 +
42 +onMounted(async () => {
43 + const result = await ExpenseService.getByTrip(tripId)
44 + if (result.data) {
45 + expenses.value = result.data
46 + } else if (result.errors) {
47 + error.value = result.errors.join(', ')
48 + }
49 + isLoading.value = false
50 +})
51 +
52 +async function deleteExpense(id: string) {
53 + if (!confirm(t('expenses.index.confirmDelete'))) return
54 + const result = await ExpenseService.delete(id)
55 + if (result.errors) {
56 + toast.error(result.errors.join(', '))
57 + } else {
58 + expenses.value = expenses.value.filter((e) => e.id !== id)
59 + toast.success(t('expenses.index.deleted'))
60 + }
61 +}
62 +
63 +function formatDate(dateStr: string) {
64 + return d(new Date(dateStr), 'short')
65 +}
66 +
67 +function categoryIconClass(name: string | null) {
68 + if (!name) return 'sa-category-default'
69 + const lower = name.toLowerCase()
70 + if (lower.includes('food') || lower.includes('meal') || lower.includes('dining')) return 'sa-category-food'
71 + if (lower.includes('accommodation') || lower.includes('hotel')) return 'sa-category-accommodation'
72 + if (lower.includes('transport') || lower.includes('taxi') || lower.includes('flight')) return 'sa-category-transport'
73 + if (lower.includes('activit') || lower.includes('tour')) return 'sa-category-activities'
74 + if (lower.includes('shopping')) return 'sa-category-shopping'
75 + return 'sa-category-default'
76 +}
77 +
78 +function categoryIcon(name: string | null) {
79 + if (!name) return 'bi-tag-fill'
80 + const lower = name.toLowerCase()
81 + if (lower.includes('food') || lower.includes('meal')) return 'bi-cup-hot-fill'
82 + if (lower.includes('accommodation') || lower.includes('hotel')) return 'bi-house-fill'
83 + if (lower.includes('transport') || lower.includes('taxi')) return 'bi-car-front-fill'
84 + if (lower.includes('activit') || lower.includes('tour')) return 'bi-lightning-fill'
85 + if (lower.includes('shopping')) return 'bi-bag-fill'
86 + return 'bi-tag-fill'
87 +}
88 +</script>
89 +
90 +<template>
91 + <div>
92 + <!-- Gradient Header -->
93 + <div class="sa-gradient-header sa-gradient-header-coral d-flex justify-content-between align-items-center">
94 + <div>
95 + <h3 class="mb-1"><i class="bi bi-receipt me-2"></i>{{ t('expenses.index.title') }}</h3>
96 + <p v-if="!isLoading && expenses.length > 0" class="mb-0 text-muted" style="font-size: 0.9rem">
97 + {{ t('expenses.index.total') }} <strong class="sa-amount-lg" style="font-size: 1.3rem; color: #fff">{{ formatCurrency(totalAmount, tripCurrencySymbol) }}</strong>
98 + </p>
99 + </div>
100 + <button
101 + v-if="isActive"
102 + class="sa-btn sa-btn-primary sa-btn-pill sa-hide-mobile"
103 + style="background: #fff; color: var(--sa-primary)"
104 + @click="router.push({ name: 'ExpensesCreate', params: { tripId } })"
105 + >
106 + <i class="bi bi-plus-lg"></i> {{ t('expenses.index.addExpense') }}
107 + </button>
108 + </div>
109 +
110 + <div v-if="error" class="alert alert-danger">{{ error }}</div>
111 +
112 + <div v-if="isLoading" class="text-center py-5">
113 + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div>
114 + </div>
115 +
116 + <div v-else-if="expenses.length === 0" class="sa-empty">
117 + <div class="sa-empty-icon"><i class="bi bi-receipt"></i></div>
118 + <div class="sa-empty-title">{{ t('expenses.index.emptyTitle') }}</div>
119 + <div class="sa-empty-text">{{ t('expenses.index.emptyText') }}</div>
120 + <button v-if="isActive" class="sa-btn sa-btn-primary sa-btn-pill" @click="router.push({ name: 'ExpensesCreate', params: { tripId } })">
121 + <i class="bi bi-plus-lg"></i> {{ t('expenses.index.addExpense') }}
122 + </button>
123 + </div>
124 +
125 + <div v-else class="sa-card-static">
126 + <div v-for="expense in expenses" :key="expense.id" class="sa-expense-item">
127 + <div class="sa-expense-icon" :class="categoryIconClass(expense.budgetCategoryName)">
128 + <i :class="['bi', categoryIcon(expense.budgetCategoryName)]"></i>
129 + </div>
130 + <div class="sa-expense-details">
131 + <div class="sa-expense-desc">{{ expense.description || t('expenses.index.untitled') }}</div>
132 + <div class="sa-expense-meta">
133 + {{ expense.paidByUserName || t('expenses.index.unknown') }} &middot; {{ formatDate(expense.expenseDate) }}
134 + &middot;
135 + <span class="sa-badge sa-badge-neutral" style="font-size: 0.7rem; padding: 1px 6px">
136 + {{ (expense.budgetCategoryName && expense.budgetCategoryName !== 'null') ? expense.budgetCategoryName : t('expenses.index.uncategorized') }}
137 + </span>
138 + </div>
139 + </div>
140 + <div class="sa-expense-amount">{{ formatCurrency(expense.amount, expense.currencySymbol ?? tripCurrencySymbol) }}</div>
141 + <div class="sa-expense-actions">
142 + <template v-if="canEditExpense(expense)">
143 + <button
144 + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
145 + @click="router.push({ name: 'ExpensesEdit', params: { tripId, id: expense.id } })"
146 + :title="t('common.edit')"
147 + >
148 + <i class="bi bi-pencil"></i>
149 + </button>
150 + <button
151 + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
152 + style="color: var(--sa-danger)"
153 + @click="deleteExpense(expense.id)"
154 + :title="t('common.delete')"
155 + >
156 + <i class="bi bi-trash3"></i>
157 + </button>
158 + </template>
159 + </div>
160 + </div>
161 + </div>
162 +
163 + <!-- Mobile FAB -->
164 + <button v-if="isActive" class="sa-fab" @click="router.push({ name: 'ExpensesCreate', params: { tripId } })">
165 + <i class="bi bi-plus-lg"></i>
166 + </button>
167 + </div>
168 +</template>
added src/views/invitations/AcceptView.vue +163 −0
@@ -0,0 +1,163 @@
1 +<script setup lang="ts">
2 +import { ref, onMounted } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import InvitationService from '@/services/InvitationService'
6 +import type { IInvitation } from '@/types/IInvitation'
7 +
8 +const router = useRouter()
9 +const route = useRoute()
10 +const { t, d } = useI18n()
11 +
12 +const token = route.params.token as string
13 +
14 +const invitation = ref<IInvitation | null>(null)
15 +const isLoading = ref(true)
16 +const error = ref<string | null>(null)
17 +const actionDone = ref(false)
18 +const actionMessage = ref('')
19 +const isProcessing = ref(false)
20 +
21 +onMounted(async () => {
22 + const result = await InvitationService.getByToken(token)
23 + if (result.data) {
24 + invitation.value = result.data
25 + } else if (result.errors) {
26 + error.value = result.errors.join(', ')
27 + }
28 + isLoading.value = false
29 +})
30 +
31 +async function acceptInvitation() {
32 + isProcessing.value = true
33 + error.value = null
34 +
35 + const result = await InvitationService.accept(token)
36 + if (result.errors) {
37 + error.value = result.errors.join(', ')
38 + } else {
39 + actionDone.value = true
40 + actionMessage.value = t('invitations.accept.accepted')
41 + }
42 +
43 + isProcessing.value = false
44 +}
45 +
46 +async function declineInvitation() {
47 + isProcessing.value = true
48 + error.value = null
49 +
50 + const result = await InvitationService.decline(token)
51 + if (result.errors) {
52 + error.value = result.errors.join(', ')
53 + } else {
54 + actionDone.value = true
55 + actionMessage.value = t('invitations.accept.declined')
56 + }
57 +
58 + isProcessing.value = false
59 +}
60 +
61 +function formatDate(dateStr: string) {
62 + return d(new Date(dateStr), 'long')
63 +}
64 +</script>
65 +
66 +<template>
67 + <div class="sa-invite-page">
68 + <!-- Loading -->
69 + <div v-if="isLoading" class="text-center py-5">
70 + <div class="spinner-border text-secondary" role="status"></div>
71 + </div>
72 +
73 + <!-- Error / Expired (no invitation loaded) -->
74 + <div v-else-if="error && !invitation" class="sa-card-static sa-invite-card">
75 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)">
76 + <div class="sa-invite-icon" style="color: var(--sa-danger)">
77 + <i class="bi bi-exclamation-triangle"></i>
78 + </div>
79 + <h3 class="mb-3">{{ t('invitations.accept.unavailable') }}</h3>
80 + <p style="color: var(--sa-gray-500); font-size: 0.938rem">{{ error }}</p>
81 + <button
82 + class="sa-btn sa-btn-primary sa-btn-pill"
83 + @click="router.push({ name: 'Home' })"
84 + >
85 + {{ t('common.goHome') }}
86 + </button>
87 + </div>
88 + </div>
89 +
90 + <!-- Action complete -->
91 + <div v-else-if="actionDone" class="sa-card-static sa-invite-card">
92 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)">
93 + <div class="sa-invite-icon" style="color: var(--sa-success)">
94 + <i class="bi bi-check-circle"></i>
95 + </div>
96 + <h3 class="mb-3">{{ t('invitations.accept.allDone') }}</h3>
97 + <p style="color: var(--sa-gray-500); font-size: 0.938rem; margin-bottom: var(--sa-space-6)">
98 + {{ actionMessage }}
99 + </p>
100 + <button
101 + class="sa-btn sa-btn-primary sa-btn-pill"
102 + @click="router.push({ name: 'TripsIndex' })"
103 + >
104 + <i class="bi bi-airplane"></i> {{ t('common.goToTrips') }}
105 + </button>
106 + </div>
107 + </div>
108 +
109 + <!-- Invitation card -->
110 + <div v-else-if="invitation" class="sa-card-static sa-invite-card">
111 + <div class="sa-card-gradient-strip sa-card-gradient-strip-teal"></div>
112 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8)">
113 + <!-- Icon -->
114 + <div class="sa-invite-icon" style="color: var(--sa-secondary)">
115 + <i class="bi bi-envelope-heart"></i>
116 + </div>
117 +
118 + <h3 class="mb-2">{{ t('invitations.accept.title') }}</h3>
119 +
120 + <!-- Trip name -->
121 + <p class="fw-bold mb-2" style="font-size: 1.25rem; color: var(--sa-gray-900)">
122 + {{ invitation.tripName || t('invitations.accept.aTrip') }}
123 + </p>
124 +
125 + <!-- Invited by -->
126 + <p v-if="invitation.invitedByUserName" style="color: var(--sa-gray-500); font-size: 0.938rem; margin-bottom: var(--sa-space-2)">
127 + <i class="bi bi-person me-1"></i> {{ t('invitations.accept.invitedBy', { name: invitation.invitedByUserName }) }}
128 + </p>
129 +
130 + <!-- Expiry -->
131 + <p style="color: var(--sa-gray-400); font-size: 0.85rem; margin-bottom: var(--sa-space-6)">
132 + <i class="bi bi-clock me-1"></i> {{ t('invitations.accept.expires', { date: formatDate(invitation.expiresAt) }) }}
133 + </p>
134 +
135 + <!-- Inline error -->
136 + <div v-if="error" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
137 + <div class="sa-card-body" style="padding: var(--sa-space-3) var(--sa-space-4); font-size: 0.875rem">
138 + <i class="bi bi-exclamation-circle me-1"></i>{{ error }}
139 + </div>
140 + </div>
141 +
142 + <!-- Action buttons -->
143 + <div class="d-flex gap-3 justify-content-center">
144 + <button
145 + class="sa-btn sa-btn-primary sa-btn-pill"
146 + :class="{ 'sa-btn-loading': isProcessing }"
147 + :disabled="isProcessing"
148 + @click="acceptInvitation"
149 + >
150 + <i class="bi bi-check-lg"></i> {{ t('invitations.accept.accept') }}
151 + </button>
152 + <button
153 + class="sa-btn sa-btn-ghost sa-btn-pill"
154 + :disabled="isProcessing"
155 + @click="declineInvitation"
156 + >
157 + {{ t('invitations.accept.decline') }}
158 + </button>
159 + </div>
160 + </div>
161 + </div>
162 + </div>
163 +</template>
added src/views/members/MembersView.vue +212 −0
@@ -0,0 +1,212 @@
1 +<script setup lang="ts">
2 +import { ref, onMounted, computed, inject, type ComputedRef } from 'vue'
3 +import { useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import TripService from '@/services/TripService'
6 +import InvitationService from '@/services/InvitationService'
7 +import type { ITripParticipant } from '@/types/ITrip'
8 +import { useToast } from '@/composables/useToast'
9 +
10 +const { t, d } = useI18n()
11 +
12 +const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer')
13 +const isOrganizer = computed(() => _isOrganizer?.value ?? false)
14 +
15 +const sortedParticipants = computed(() => {
16 + return [...participants.value].sort((a, b) => {
17 + if (a.role === 'Organizer' && b.role !== 'Organizer') return -1
18 + if (a.role !== 'Organizer' && b.role === 'Organizer') return 1
19 + return 0
20 + })
21 +})
22 +
23 +const route = useRoute()
24 +const toast = useToast()
25 +
26 +const tripId = route.params.tripId as string
27 +
28 +const participants = ref<ITripParticipant[]>([])
29 +const isLoading = ref(true)
30 +const error = ref<string | null>(null)
31 +
32 +const invitationToken = ref<string | null>(null)
33 +const isCreatingInvite = ref(false)
34 +
35 +onMounted(async () => {
36 + const result = await TripService.getParticipants(tripId)
37 + if (result.data) {
38 + participants.value = result.data
39 + } else if (result.errors) {
40 + error.value = result.errors.join(', ')
41 + }
42 + isLoading.value = false
43 +})
44 +
45 +async function createInvitation() {
46 + invitationToken.value = null
47 + isCreatingInvite.value = true
48 +
49 + const result = await InvitationService.create({ tripId })
50 + if (result.data) {
51 + invitationToken.value = result.data.token
52 + toast.success(t('members.invitationCreated'))
53 + } else if (result.errors) {
54 + toast.error(result.errors.join(', '))
55 + }
56 +
57 + isCreatingInvite.value = false
58 +}
59 +
60 +function formatDate(dateStr: string) {
61 + return d(new Date(dateStr), 'short')
62 +}
63 +
64 +function invitationLink(): string {
65 + if (!invitationToken.value) return ''
66 + return `${window.location.origin}/invitations/${invitationToken.value}`
67 +}
68 +
69 +function getInitials(name: string | null, email: string | null): string {
70 + if (name) {
71 + return name
72 + .split(' ')
73 + .map((w) => w[0])
74 + .slice(0, 2)
75 + .join('')
76 + .toUpperCase()
77 + }
78 + if (email) return email[0]!.toUpperCase()
79 + return '?'
80 +}
81 +
82 +function avatarClass(index: number): string {
83 + return `sa-avatar-${(index % 8) + 1}`
84 +}
85 +
86 +function copyLink() {
87 + const link = invitationLink()
88 + if (!link) return
89 + navigator.clipboard.writeText(link).then(() => {
90 + toast.success(t('members.copied'))
91 + }).catch(() => {
92 + toast.error(t('members.copyFailed'))
93 + })
94 +}
95 +</script>
96 +
97 +<template>
98 + <div>
99 + <!-- Gradient Header -->
100 + <div class="sa-gradient-header sa-gradient-header-purple d-flex justify-content-between align-items-center">
101 + <div>
102 + <h3 class="mb-1"><i class="bi bi-people-fill me-2"></i>{{ t('members.title') }}</h3>
103 + <p v-if="!isLoading && participants.length > 0" class="mb-0 text-muted" style="font-size: 0.9rem">
104 + {{ t('members.count', { n: participants.length }, participants.length) }}
105 + </p>
106 + </div>
107 + <button
108 + v-if="isOrganizer"
109 + class="sa-btn sa-btn-pill sa-hide-mobile"
110 + style="background: #fff; color: #6366f1"
111 + :class="{ 'sa-btn-loading': isCreatingInvite }"
112 + :disabled="isCreatingInvite"
113 + @click="createInvitation"
114 + >
115 + <i class="bi bi-link-45deg"></i> {{ t('members.invite') }}
116 + </button>
117 + </div>
118 +
119 + <div v-if="error" class="alert alert-danger">{{ error }}</div>
120 +
121 + <!-- Invitation Link -->
122 + <div v-if="invitationToken" class="sa-card-static sa-card-accent sa-card-accent-secondary mb-4">
123 + <div class="sa-card-body">
124 + <div class="d-flex align-items-center mb-2">
125 + <i class="bi bi-check-circle-fill me-2" style="color: var(--sa-success); font-size: 1.1rem"></i>
126 + <strong style="color: var(--sa-gray-800)">{{ t('members.invitationCreated') }}</strong>
127 + </div>
128 + <p class="mb-2" style="font-size: 0.875rem; color: var(--sa-gray-500)">{{ t('members.shareLink') }}</p>
129 + <div class="d-flex align-items-center gap-2">
130 + <code style="
131 + flex: 1;
132 + padding: 8px 12px;
133 + background: var(--sa-success-light);
134 + color: #15803d;
135 + border-radius: var(--sa-radius-sm);
136 + font-size: 0.813rem;
137 + word-break: break-all;
138 + ">{{ invitationLink() }}</code>
139 + <button
140 + class="sa-btn sa-btn-ghost sa-btn-sm sa-btn-icon"
141 + :title="t('members.copyLink')"
142 + @click="copyLink"
143 + >
144 + <i class="bi bi-clipboard"></i>
145 + </button>
146 + </div>
147 + </div>
148 + </div>
149 +
150 + <!-- Loading -->
151 + <div v-if="isLoading" class="text-center py-5">
152 + <div class="spinner-border" style="color: #6366f1" role="status"></div>
153 + </div>
154 +
155 + <!-- Empty State -->
156 + <div v-else-if="participants.length === 0" class="sa-empty">
157 + <div class="sa-empty-icon"><i class="bi bi-people"></i></div>
158 + <div class="sa-empty-title">{{ t('members.emptyTitle') }}</div>
159 + <div class="sa-empty-text">{{ t('members.emptyText') }}</div>
160 + <button
161 + class="sa-btn sa-btn-primary sa-btn-pill"
162 + :disabled="isCreatingInvite"
163 + @click="createInvitation"
164 + >
165 + <i class="bi bi-link-45deg"></i> {{ t('members.createInvitation') }}
166 + </button>
167 + </div>
168 +
169 + <!-- Member List -->
170 + <div v-else class="sa-card-static">
171 + <div
172 + v-for="(member, index) in sortedParticipants"
173 + :key="member.id"
174 + class="d-flex align-items-center gap-3 px-4 py-3"
175 + :style="index < participants.length - 1 ? 'border-bottom: 1px solid var(--sa-gray-100)' : ''"
176 + >
177 + <div class="sa-avatar" :class="avatarClass(index)">
178 + {{ getInitials(member.userName, member.userEmail) }}
179 + </div>
180 + <div class="flex-grow-1">
181 + <div class="fw-bold" style="color: var(--sa-gray-800)">
182 + {{ member.userName || member.userEmail || t('common.unknown') }}
183 + </div>
184 + <small v-if="member.userEmail" style="color: var(--sa-gray-500)">{{ member.userEmail }}</small>
185 + </div>
186 + <div class="text-end">
187 + <span
188 + class="sa-badge"
189 + :class="member.role === 'Organizer' ? 'sa-badge-primary' : 'sa-badge-neutral'"
190 + >
191 + {{ t(`members.role.${member.role === 'Organizer' ? 'Organizer' : 'Participant'}`) }}
192 + </span>
193 + <div class="mt-1">
194 + <small style="color: var(--sa-gray-400); font-size: 0.75rem">
195 + {{ t('members.joined', { date: formatDate(member.joinedAt) }) }}
196 + </small>
197 + </div>
198 + </div>
199 + </div>
200 + </div>
201 +
202 + <!-- Mobile FAB -->
203 + <button
204 + v-if="isOrganizer"
205 + class="sa-fab"
206 + style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4)"
207 + @click="createInvitation"
208 + >
209 + <i class="bi bi-link-45deg"></i>
210 + </button>
211 + </div>
212 +</template>
added src/views/polls/CreateView.vue +171 −0
@@ -0,0 +1,171 @@
1 +<script setup lang="ts">
2 +import { ref } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import PollService from '@/services/PollService'
6 +
7 +const router = useRouter()
8 +const route = useRoute()
9 +const { t } = useI18n()
10 +
11 +const tripId = route.params.tripId as string
12 +
13 +const question = ref('')
14 +const allowMultipleVotes = ref(false)
15 +const isAnonymous = ref(false)
16 +const options = ref<string[]>(['', ''])
17 +const errors = ref<string[]>([])
18 +const isSaving = ref(false)
19 +
20 +function addOption() {
21 + options.value.push('')
22 +}
23 +
24 +function removeOption(index: number) {
25 + if (options.value.length > 2) {
26 + options.value.splice(index, 1)
27 + }
28 +}
29 +
30 +async function handleSubmit() {
31 + errors.value = []
32 +
33 + const filledOptions = options.value.filter((o) => o.trim() !== '')
34 + if (filledOptions.length < 2) {
35 + errors.value = [t('polls.create.atLeastTwo')]
36 + return
37 + }
38 +
39 + isSaving.value = true
40 +
41 + const result = await PollService.create({
42 + tripId,
43 + question: question.value,
44 + allowMultipleVotes: allowMultipleVotes.value,
45 + isAnonymous: isAnonymous.value,
46 + options: filledOptions,
47 + })
48 +
49 + if (result.errors) {
50 + errors.value = result.errors
51 + } else {
52 + router.push({ name: 'PollsIndex', params: { tripId } })
53 + }
54 +
55 + isSaving.value = false
56 +}
57 +</script>
58 +
59 +<template>
60 + <div style="max-width: 600px; margin: 0 auto">
61 + <!-- Header -->
62 + <div class="sa-gradient-header sa-gradient-header-blue">
63 + <h2 class="mb-0">{{ t('polls.create.title') }}</h2>
64 + </div>
65 +
66 + <!-- Errors -->
67 + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
68 + <div class="sa-card-body">
69 + <div v-for="error in errors" :key="error">
70 + <i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
71 + </div>
72 + </div>
73 + </div>
74 +
75 + <!-- Form -->
76 + <div class="sa-card-static">
77 + <div class="sa-card-body">
78 + <form @submit.prevent="handleSubmit">
79 + <!-- Question -->
80 + <div class="mb-4">
81 + <label for="question" class="form-label">{{ t('polls.create.question') }}</label>
82 + <input
83 + id="question"
84 + v-model="question"
85 + type="text"
86 + class="form-control"
87 + :placeholder="t('polls.create.questionPlaceholder')"
88 + required
89 + />
90 + </div>
91 +
92 + <!-- Toggle: Allow Multiple Votes -->
93 + <div class="d-flex align-items-center justify-content-between mb-3 p-3" style="background: var(--sa-gray-50); border-radius: var(--sa-radius-sm)">
94 + <div>
95 + <div class="fw-bold" style="font-size: 0.938rem">{{ t('polls.create.allowMultiple') }}</div>
96 + <div style="font-size: 0.8rem; color: var(--sa-gray-500)">{{ t('polls.create.allowMultipleHelp') }}</div>
97 + </div>
98 + <input
99 + id="allowMultiple"
100 + v-model="allowMultipleVotes"
101 + type="checkbox"
102 + class="sa-toggle"
103 + />
104 + </div>
105 +
106 + <!-- Toggle: Anonymous Voting -->
107 + <div class="d-flex align-items-center justify-content-between mb-4 p-3" style="background: var(--sa-gray-50); border-radius: var(--sa-radius-sm)">
108 + <div>
109 + <div class="fw-bold" style="font-size: 0.938rem">{{ t('polls.create.anonymous') }}</div>
110 + <div style="font-size: 0.8rem; color: var(--sa-gray-500)">{{ t('polls.create.anonymousHelp') }}</div>
111 + </div>
112 + <input
113 + id="isAnonymous"
114 + v-model="isAnonymous"
115 + type="checkbox"
116 + class="sa-toggle"
117 + />
118 + </div>
119 +
120 + <!-- Options -->
121 + <div class="mb-4">
122 + <label class="form-label">{{ t('polls.create.options') }}</label>
123 + <div v-for="(_, index) in options" :key="index" class="d-flex gap-2 mb-2">
124 + <input
125 + v-model="options[index]"
126 + type="text"
127 + class="form-control"
128 + :placeholder="t('polls.create.optionPlaceholder', { n: index + 1 })"
129 + />
130 + <button
131 + v-if="options.length > 2"
132 + type="button"
133 + class="sa-btn sa-btn-icon sa-btn-sm sa-btn-ghost"
134 + style="flex-shrink: 0"
135 + @click="removeOption(index)"
136 + >
137 + <i class="bi bi-x-lg"></i>
138 + </button>
139 + </div>
140 + <button
141 + type="button"
142 + class="sa-btn sa-btn-ghost sa-btn-sm"
143 + @click="addOption"
144 + >
145 + <i class="bi bi-plus-lg"></i> {{ t('polls.create.addOption') }}
146 + </button>
147 + </div>
148 +
149 + <!-- Actions -->
150 + <div class="d-flex gap-2">
151 + <button
152 + type="button"
153 + class="sa-btn sa-btn-ghost"
154 + @click="router.push({ name: 'PollsIndex', params: { tripId } })"
155 + >
156 + {{ t('common.cancel') }}
157 + </button>
158 + <button
159 + type="submit"
160 + class="sa-btn sa-btn-primary"
161 + :class="{ 'sa-btn-loading': isSaving }"
162 + :disabled="isSaving"
163 + >
164 + {{ isSaving ? t('polls.create.creating') : t('polls.create.createPoll') }}
165 + </button>
166 + </div>
167 + </form>
168 + </div>
169 + </div>
170 + </div>
171 +</template>
added src/views/polls/DetailView.vue +194 −0
@@ -0,0 +1,194 @@
1 +<script setup lang="ts">
2 +import { ref, onMounted, computed } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import PollService from '@/services/PollService'
6 +import type { IPoll } from '@/types/IPoll'
7 +import { useToast } from '@/composables/useToast'
8 +
9 +const router = useRouter()
10 +const route = useRoute()
11 +const toast = useToast()
12 +const { t } = useI18n()
13 +
14 +const tripId = route.params.tripId as string
15 +const pollId = route.params.id as string
16 +
17 +const poll = ref<IPoll | null>(null)
18 +const isLoading = ref(true)
19 +const error = ref<string | null>(null)
20 +const actionError = ref<string | null>(null)
21 +
22 +const isClosed = computed(() => !!poll.value?.closedAt)
23 +
24 +const totalVotes = computed(() => {
25 + if (!poll.value?.options) return 0
26 + return poll.value.options.reduce((sum, o) => sum + o.voteCount, 0)
27 +})
28 +
29 +const maxVotes = computed(() => {
30 + if (!poll.value?.options) return 0
31 + return Math.max(...poll.value.options.map((o) => o.voteCount))
32 +})
33 +
34 +onMounted(async () => {
35 + await loadPoll()
36 +})
37 +
38 +async function loadPoll() {
39 + isLoading.value = true
40 + error.value = null
41 +
42 + const result = await PollService.getById(pollId)
43 + if (result.data) {
44 + poll.value = result.data
45 + } else if (result.errors) {
46 + error.value = result.errors.join(', ')
47 + }
48 + isLoading.value = false
49 +}
50 +
51 +async function vote(optionId: string) {
52 + actionError.value = null
53 + const result = await PollService.vote(pollId, optionId)
54 + if (result.errors) {
55 + toast.error(result.errors.join(', '))
56 + } else {
57 + toast.success(t('polls.detail.voteRecorded'))
58 + await loadPoll()
59 + }
60 +}
61 +
62 +async function closePoll() {
63 + if (!confirm(t('polls.detail.confirmClose'))) return
64 + actionError.value = null
65 + const result = await PollService.close(pollId)
66 + if (result.errors) {
67 + toast.error(result.errors.join(', '))
68 + } else {
69 + toast.success(t('polls.detail.closed'))
70 + await loadPoll()
71 + }
72 +}
73 +
74 +async function deletePoll() {
75 + if (!confirm(t('polls.detail.confirmDelete'))) return
76 + const result = await PollService.delete(pollId)
77 + if (result.errors) {
78 + toast.error(result.errors.join(', '))
79 + } else {
80 + toast.success(t('polls.detail.deleted'))
81 + router.push({ name: 'PollsIndex', params: { tripId } })
82 + }
83 +}
84 +
85 +function votePercent(voteCount: number): number {
86 + if (totalVotes.value === 0) return 0
87 + return Math.round((voteCount / totalVotes.value) * 100)
88 +}
89 +
90 +function isWinner(voteCount: number): boolean {
91 + return isClosed.value && voteCount > 0 && voteCount === maxVotes.value
92 +}
93 +</script>
94 +
95 +<template>
96 + <div>
97 + <!-- Loading -->
98 + <div v-if="isLoading" class="text-center py-5">
99 + <div class="spinner-border text-secondary" role="status"></div>
100 + </div>
101 +
102 + <!-- Error -->
103 + <div v-else-if="error" class="sa-card-static sa-card-accent sa-card-accent-danger">
104 + <div class="sa-card-body">
105 + <i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
106 + </div>
107 + </div>
108 +
109 + <!-- Poll Detail -->
110 + <div v-else-if="poll">
111 + <!-- Question heading with status -->
112 + <div class="d-flex align-items-center gap-3 mb-4">
113 + <span
114 + class="sa-status-dot"
115 + :class="isClosed ? 'sa-status-dot-pending' : 'sa-status-dot-active sa-status-dot-pulse'"
116 + ></span>
117 + <h2 class="mb-0" style="flex: 1">{{ poll.question }}</h2>
118 + <span
119 + class="sa-badge"
120 + :class="isClosed ? 'sa-badge-neutral' : 'sa-badge-success'"
121 + >
122 + {{ isClosed ? t('polls.status.Closed') : t('polls.status.Open') }}
123 + </span>
124 + </div>
125 +
126 + <!-- Poll Options -->
127 + <div class="mb-4">
128 + <div
129 + v-for="option in poll.options"
130 + :key="option.id"
131 + class="sa-poll-option"
132 + :class="{
133 + 'sa-poll-option-voted': option.votedByCurrentUser,
134 + 'sa-poll-option-winner': isWinner(option.voteCount),
135 + }"
136 + >
137 + <div class="d-flex justify-content-between align-items-center mb-2">
138 + <div class="d-flex align-items-center gap-2 flex-wrap">
139 + <span class="fw-bold" style="font-size: 1.02rem">{{ option.text }}</span>
140 + <span v-if="option.votedByCurrentUser" class="sa-badge sa-badge-secondary">
141 + <i class="bi bi-check-circle-fill"></i> {{ t('polls.detail.yourVote') }}
142 + </span>
143 + <span v-if="isWinner(option.voteCount)" class="sa-badge sa-badge-accent">
144 + <i class="bi bi-trophy-fill"></i> {{ t('polls.detail.winner') }}
145 + </span>
146 + </div>
147 + <div class="d-flex align-items-center gap-3">
148 + <span class="text-muted" style="font-size: 0.85rem; white-space: nowrap">
149 + {{ t('polls.detail.voteCount', { n: option.voteCount }, option.voteCount) }}
150 + <span class="fw-bold ms-1">{{ votePercent(option.voteCount) }}%</span>
151 + </span>
152 + <button
153 + v-if="!isClosed"
154 + class="sa-btn sa-btn-sm"
155 + :class="option.votedByCurrentUser ? 'sa-btn-ghost' : 'sa-btn-secondary'"
156 + @click="vote(option.id)"
157 + >
158 + {{ option.votedByCurrentUser ? t('polls.detail.unvote') : t('polls.detail.vote') }}
159 + </button>
160 + </div>
161 + </div>
162 + <div class="sa-progress sa-progress-sm">
163 + <div
164 + class="sa-progress-bar"
165 + :class="{ 'sa-progress-bar-primary': isWinner(option.voteCount) }"
166 + :style="{ width: votePercent(option.voteCount) + '%' }"
167 + ></div>
168 + </div>
169 + </div>
170 + </div>
171 +
172 + <!-- Actions -->
173 + <div class="d-flex gap-2 flex-wrap">
174 + <button
175 + v-if="!isClosed"
176 + class="sa-btn sa-btn-accent sa-btn-sm"
177 + @click="closePoll"
178 + >
179 + <i class="bi bi-lock-fill"></i> {{ t('polls.detail.closePoll') }}
180 + </button>
181 + <button class="sa-btn sa-btn-danger sa-btn-sm" @click="deletePoll">
182 + <i class="bi bi-trash3"></i> {{ t('common.delete') }}
183 + </button>
184 + <div class="flex-grow-1"></div>
185 + <button
186 + class="sa-btn sa-btn-ghost sa-btn-sm"
187 + @click="router.push({ name: 'PollsIndex', params: { tripId } })"
188 + >
189 + <i class="bi bi-arrow-left"></i> {{ t('polls.detail.backToPolls') }}
190 + </button>
191 + </div>
192 + </div>
193 + </div>
194 +</template>
added src/views/polls/IndexView.vue +117 −0
@@ -0,0 +1,117 @@
1 +<script setup lang="ts">
2 +import { ref, onMounted } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import PollService from '@/services/PollService'
6 +import type { IPoll } from '@/types/IPoll'
7 +
8 +const router = useRouter()
9 +const route = useRoute()
10 +const { t } = useI18n()
11 +
12 +const polls = ref<IPoll[]>([])
13 +const isLoading = ref(true)
14 +const error = ref<string | null>(null)
15 +
16 +const tripId = route.params.tripId as string
17 +
18 +onMounted(async () => {
19 + const result = await PollService.getByTrip(tripId)
20 + if (result.data) {
21 + polls.value = result.data
22 + } else if (result.errors) {
23 + error.value = result.errors.join(', ')
24 + }
25 + isLoading.value = false
26 +})
27 +</script>
28 +
29 +<template>
30 + <div>
31 + <!-- Gradient Header -->
32 + <div class="sa-gradient-header sa-gradient-header-blue">
33 + <div class="d-flex justify-content-between align-items-center">
34 + <div>
35 + <h2 class="mb-0">{{ t('polls.index.title') }}</h2>
36 + </div>
37 + <button
38 + class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill"
39 + @click="router.push({ name: 'PollsCreate', params: { tripId } })"
40 + >
41 + <i class="bi bi-plus-lg"></i> {{ t('polls.index.newPoll') }}
42 + </button>
43 + </div>
44 + </div>
45 +
46 + <!-- Error -->
47 + <div v-if="error" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
48 + <div class="sa-card-body">
49 + <i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
50 + </div>
51 + </div>
52 +
53 + <!-- Loading -->
54 + <div v-if="isLoading" class="text-center py-5">
55 + <div class="spinner-border text-secondary" role="status"></div>
56 + </div>
57 +
58 + <!-- Empty State -->
59 + <div v-else-if="polls.length === 0" class="sa-empty">
60 + <div class="sa-empty-icon"><i class="bi bi-clipboard2-check"></i></div>
61 + <div class="sa-empty-title">{{ t('polls.index.emptyTitle') }}</div>
62 + <div class="sa-empty-text">{{ t('polls.index.emptyText') }}</div>
63 + <button
64 + class="sa-btn sa-btn-primary sa-btn-pill"
65 + @click="router.push({ name: 'PollsCreate', params: { tripId } })"
66 + >
67 + <i class="bi bi-plus-lg"></i> {{ t('polls.index.createFirst') }}
68 + </button>
69 + </div>
70 +
71 + <!-- Poll Cards -->
72 + <div v-else class="d-flex flex-column gap-3">
73 + <div
74 + v-for="poll in polls"
75 + :key="poll.id"
76 + class="sa-card"
77 + style="cursor: pointer"
78 + @click="router.push({ name: 'PollDetail', params: { tripId, id: poll.id } })"
79 + >
80 + <div class="sa-card-body">
81 + <div class="d-flex justify-content-between align-items-start">
82 + <div class="flex-grow-1">
83 + <div class="fw-bold mb-2" style="font-size: 1.05rem">{{ poll.question }}</div>
84 + <div class="d-flex align-items-center gap-3 flex-wrap">
85 + <!-- Status -->
86 + <span class="d-inline-flex align-items-center gap-1">
87 + <span
88 + class="sa-status-dot"
89 + :class="poll.closedAt ? 'sa-status-dot-pending' : 'sa-status-dot-active sa-status-dot-pulse'"
90 + ></span>
91 + <span style="font-size: 0.85rem; font-weight: 500" :style="{ color: poll.closedAt ? 'var(--sa-gray-500)' : 'var(--sa-success)' }">
92 + {{ poll.closedAt ? t('polls.status.Closed') : t('polls.status.Open') }}
93 + </span>
94 + </span>
95 + <!-- Option count -->
96 + <span class="sa-badge sa-badge-neutral">
97 + <i class="bi bi-list-ul"></i> {{ t('polls.index.optionCount', { n: poll.options?.length ?? 0 }) }}
98 + </span>
99 + <!-- Multiple votes badge -->
100 + <span v-if="poll.allowMultipleVotes" class="sa-badge sa-badge-info">
101 + <i class="bi bi-check2-all"></i> {{ t('polls.index.multipleVotes') }}
102 + </span>
103 + </div>
104 + </div>
105 + <button
106 + class="sa-btn sa-btn-sm"
107 + :class="poll.closedAt ? 'sa-btn-ghost' : 'sa-btn-secondary'"
108 + @click.stop="router.push({ name: 'PollDetail', params: { tripId, id: poll.id } })"
109 + >
110 + {{ poll.closedAt ? t('polls.index.viewResults') : t('polls.index.vote') }}
111 + </button>
112 + </div>
113 + </div>
114 + </div>
115 + </div>
116 + </div>
117 +</template>
added src/views/settlements/SettlementView.vue +510 −0
@@ -0,0 +1,510 @@
1 +<script setup lang="ts">
2 +import { ref, computed, onMounted, inject, type ComputedRef } from 'vue'
3 +import { useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import SettlementService from '@/services/SettlementService'
6 +import TripService from '@/services/TripService'
7 +import type { ISettlementSummary } from '@/types/ISettlement'
8 +import { useToast } from '@/composables/useToast'
9 +import { formatCurrency } from '@/utils/formatCurrency'
10 +import { useAuthStore } from '@/stores/auth'
11 +import { getUserIdFromJwt } from '@/utils/parseJwt'
12 +
13 +const { t } = useI18n()
14 +
15 +const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol')
16 +const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '')
17 +
18 +const _isOrganizer = inject<ComputedRef<boolean>>('isOrganizer')
19 +const isOrganizer = computed(() => _isOrganizer?.value ?? false)
20 +
21 +const _tripStatus = inject<ComputedRef<string>>('tripStatus')
22 +const tripStatus = computed(() => _tripStatus?.value ?? 'Active')
23 +
24 +const isActive = computed(() => tripStatus.value === 'Active')
25 +const isFinalizing = computed(() => tripStatus.value === 'Finalizing')
26 +const isSettled = computed(() => tripStatus.value === 'Settled')
27 +const hasActivePlan = computed(() => isFinalizing.value || isSettled.value)
28 +
29 +const authStore = useAuthStore()
30 +const currentUserId = computed(() =>
31 + authStore.jwt ? getUserIdFromJwt(authStore.jwt) : null,
32 +)
33 +
34 +const route = useRoute()
35 +const toast = useToast()
36 +
37 +const tripId = route.params.tripId as string
38 +
39 +const summary = ref<ISettlementSummary | null>(null)
40 +const isLoading = ref(true)
41 +const error = ref<string | null>(null)
42 +const actionError = ref<string | null>(null)
43 +
44 +onMounted(async () => {
45 + await loadSummary()
46 +})
47 +
48 +async function loadSummary() {
49 + isLoading.value = true
50 + error.value = null
51 +
52 + const result = await SettlementService.getSummary(tripId)
53 + if (result.data) {
54 + summary.value = result.data
55 + } else if (result.errors) {
56 + error.value = result.errors.join(', ')
57 + }
58 + isLoading.value = false
59 +}
60 +
61 +async function finalizeTrip() {
62 + actionError.value = null
63 + const result = await TripService.finalize(tripId)
64 + if (result.errors) {
65 + actionError.value = result.errors.join(', ')
66 + toast.error(t('settlements.finalizeFailed'))
67 + } else {
68 + toast.success(t('settlements.finalizeSuccess'))
69 + window.location.reload()
70 + }
71 +}
72 +
73 +async function reopenTrip() {
74 + actionError.value = null
75 + const result = await TripService.reopen(tripId)
76 + if (result.errors) {
77 + actionError.value = result.errors.join(', ')
78 + toast.error(t('settlements.reopenFailed'))
79 + } else {
80 + toast.success(t('settlements.reopenSuccess'))
81 + window.location.reload()
82 + }
83 +}
84 +
85 +async function markPaid(paymentId: string) {
86 + actionError.value = null
87 + const result = await SettlementService.markPaid(paymentId)
88 + if (result.errors) {
89 + actionError.value = result.errors.join(', ')
90 + toast.error(t('settlements.markPaidFailed'))
91 + } else {
92 + toast.success(t('settlements.markPaidSuccess'))
93 + await loadSummary()
94 + }
95 +}
96 +
97 +async function confirmPayment(paymentId: string) {
98 + actionError.value = null
99 + const result = await SettlementService.confirmPayment(paymentId)
100 + if (result.errors) {
101 + actionError.value = result.errors.join(', ')
102 + toast.error(t('settlements.confirmFailed'))
103 + } else {
104 + toast.success(t('settlements.confirmSuccess'))
105 + await loadSummary()
106 + }
107 +}
108 +
109 +function getInitials(name: string | null) {
110 + if (!name) return '?'
111 + const parts = name.split(' ')
112 + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
113 + return name.substring(0, 2).toUpperCase()
114 +}
115 +
116 +function avatarColor(index: number) {
117 + return `sa-avatar-${(index % 8) + 1}`
118 +}
119 +
120 +const maxBalance = computed(() => {
121 + if (!summary.value || summary.value.balances.length === 0) return 1
122 + return Math.max(...summary.value.balances.map((b) => Math.abs(b.balance)), 1)
123 +})
124 +
125 +function balanceBarWidth(balance: number) {
126 + return Math.min((Math.abs(balance) / maxBalance.value) * 100, 100)
127 +}
128 +
129 +const allSettledUp = computed(() => {
130 + if (!summary.value) return false
131 + const noDebts = summary.value.balances.every((b) => Math.abs(b.balance) < 0.01)
132 + const noPayments =
133 + !summary.value.latestPlan ||
134 + !summary.value.latestPlan.payments ||
135 + summary.value.latestPlan.payments.length === 0 ||
136 + summary.value.latestPlan.payments.every((p) => p.status === 'Confirmed')
137 + return noDebts && noPayments
138 +})
139 +
140 +const pendingPayments = computed(() => {
141 + if (!hasActivePlan.value) return []
142 + if (!summary.value?.latestPlan?.payments) return []
143 + return summary.value.latestPlan.payments
144 +})
145 +
146 +const totalAmount = computed(() => {
147 + return summary.value?.latestPlan?.totalAmount ?? 0
148 +})
149 +
150 +const previewPayments = computed(() => {
151 + if (!isActive.value || !summary.value) return []
152 + const balances = summary.value.balances
153 +
154 + const creditors = balances
155 + .filter((b) => b.balance > 0.01)
156 + .map((b) => ({ name: b.userName ?? t('common.unknown'), amount: b.balance }))
157 + .sort((a, b) => b.amount - a.amount)
158 +
159 + const debtors = balances
160 + .filter((b) => b.balance < -0.01)
161 + .map((b) => ({ name: b.userName ?? t('common.unknown'), amount: -b.balance }))
162 + .sort((a, b) => b.amount - a.amount)
163 +
164 + if (!creditors.length || !debtors.length) return []
165 +
166 + const result: { from: string; to: string; amount: number }[] = []
167 + let ci = 0
168 + let di = 0
169 +
170 + while (ci < creditors.length && di < debtors.length) {
171 + const amount = Math.min(creditors[ci]!.amount, debtors[di]!.amount)
172 + if (amount > 0.01) {
173 + result.push({
174 + from: debtors[di]!.name,
175 + to: creditors[ci]!.name,
176 + amount: Math.round(amount * 100) / 100,
177 + })
178 + }
179 + creditors[ci]!.amount -= amount
180 + debtors[di]!.amount -= amount
181 + if (creditors[ci]!.amount < 0.01) ci++
182 + if (debtors[di]!.amount < 0.01) di++
183 + }
184 +
185 + return result
186 +})
187 +</script>
188 +
189 +<template>
190 + <div>
191 + <!-- Gradient Header -->
192 + <div class="sa-gradient-header sa-gradient-header-green">
193 + <div style="display: flex; align-items: center; justify-content: space-between">
194 + <div>
195 + <h2 class="mb-1">{{ t('settlements.title') }}</h2>
196 + <span v-if="isFinalizing" class="sa-badge sa-badge-warning" style="font-size: 0.75rem">
197 + <i class="bi bi-hourglass-split me-1"></i>{{ t('settlements.finalizing') }}
198 + </span>
199 + <span v-else-if="isSettled" class="sa-badge sa-badge-info" style="font-size: 0.75rem">
200 + <i class="bi bi-lock-fill me-1"></i>{{ t('settlements.tripSettled') }}
201 + </span>
202 + </div>
203 + <div class="d-flex gap-2">
204 + <button
205 + v-if="isOrganizer && isActive"
206 + class="sa-btn sa-btn-sm"
207 + style="background: #fff; color: var(--sa-success)"
208 + @click="finalizeTrip"
209 + >
210 + <i class="bi bi-check-circle"></i> {{ t('settlements.finalize') }}
211 + </button>
212 + <button
213 + v-if="isOrganizer && hasActivePlan && summary?.latestPlan?.status !== 'Completed'"
214 + class="sa-btn sa-btn-sm"
215 + style="background: #fff; color: var(--sa-warning)"
216 + @click="reopenTrip"
217 + >
218 + <i class="bi bi-unlock"></i> {{ t('settlements.reopen') }}
219 + </button>
220 + </div>
221 + </div>
222 + </div>
223 +
224 + <!-- Loading -->
225 + <div v-if="isLoading" class="sa-empty">
226 + <div class="sa-empty-icon">
227 + <div class="spinner-border" role="status"></div>
228 + </div>
229 + <div class="sa-empty-text">{{ t('settlements.loading') }}</div>
230 + </div>
231 +
232 + <!-- Error -->
233 + <div v-else-if="error" class="sa-empty">
234 + <div class="sa-empty-icon" style="color: var(--sa-danger)">
235 + <i class="bi bi-exclamation-triangle" style="font-size: 2rem"></i>
236 + </div>
237 + <div class="sa-empty-title">{{ t('settlements.somethingWrong') }}</div>
238 + <div class="sa-empty-text">{{ error }}</div>
239 + </div>
240 +
241 + <!-- All Settled Up Empty State -->
242 + <div v-else-if="summary && allSettledUp" class="sa-empty">
243 + <div class="sa-empty-icon" style="color: var(--sa-success)">
244 + <i class="bi bi-check-circle" style="font-size: 3rem"></i>
245 + </div>
246 + <div class="sa-empty-title">{{ t('settlements.allSettled') }}</div>
247 + <div class="sa-empty-text">{{ t('settlements.allSettledText') }}</div>
248 + </div>
249 +
250 + <!-- Main Content -->
251 + <div v-else-if="summary">
252 + <!-- Action Error -->
253 + <div
254 + v-if="actionError"
255 + style="
256 + background: var(--sa-danger-light);
257 + color: #dc2626;
258 + padding: var(--sa-space-3) var(--sa-space-4);
259 + border-radius: var(--sa-radius-md);
260 + margin-bottom: var(--sa-space-4);
261 + font-size: 0.9rem;
262 + "
263 + >
264 + <i class="bi bi-exclamation-circle"></i> {{ actionError }}
265 + </div>
266 +
267 + <!-- BALANCES Section -->
268 + <div style="margin-bottom: var(--sa-space-6)">
269 + <h4
270 + style="
271 + font-size: 0.75rem;
272 + font-weight: 700;
273 + text-transform: uppercase;
274 + letter-spacing: 0.08em;
275 + color: var(--sa-gray-400);
276 + margin-bottom: var(--sa-space-4);
277 + "
278 + >
279 + {{ t('settlements.balances') }}
280 + </h4>
281 +
282 + <div v-if="summary.balances.length === 0" class="sa-empty">
283 + <div class="sa-empty-text">{{ t('settlements.noBalances') }}</div>
284 + </div>
285 +
286 + <div
287 + v-for="(bal, i) in summary.balances"
288 + :key="bal.userId"
289 + style="
290 + display: flex;
291 + align-items: center;
292 + gap: var(--sa-space-3);
293 + padding: var(--sa-space-3) 0;
294 + border-bottom: 1px solid var(--sa-gray-100);
295 + "
296 + >
297 + <!-- Avatar -->
298 + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(i)">
299 + {{ getInitials(bal.userName) }}
300 + </span>
301 +
302 + <!-- Name -->
303 + <span
304 + style="
305 + min-width: 90px;
306 + font-size: 0.9rem;
307 + font-weight: 500;
308 + color: var(--sa-gray-700);
309 + flex-shrink: 0;
310 + "
311 + >
312 + {{ bal.userName || t('common.unknown') }}
313 + </span>
314 +
315 + <!-- Balance Bar -->
316 + <div class="sa-balance-bar-container" style="flex: 1">
317 + <div class="sa-balance-bar-track">
318 + <div class="sa-balance-bar-center"></div>
319 + <div
320 + v-if="bal.balance > 0"
321 + class="sa-balance-bar-fill"
322 + style="left: 50%; right: auto; background: var(--sa-success)"
323 + :style="{ width: balanceBarWidth(bal.balance) / 2 + '%' }"
324 + ></div>
325 + <div
326 + v-if="bal.balance < 0"
327 + class="sa-balance-bar-fill"
328 + style="right: 50%; left: auto; background: var(--sa-danger)"
329 + :style="{ width: balanceBarWidth(bal.balance) / 2 + '%' }"
330 + ></div>
331 + </div>
332 + </div>
333 +
334 + <!-- Amount -->
335 + <span
336 + style="min-width: 70px; text-align: right; font-weight: 700; font-size: 0.9rem"
337 + :style="{ color: bal.balance >= 0 ? 'var(--sa-success)' : 'var(--sa-danger)' }"
338 + >
339 + {{ bal.balance >= 0 ? '+' : '' }}{{ formatCurrency(bal.balance, tripCurrencySymbol) }}
340 + </span>
341 + </div>
342 + </div>
343 +
344 + <!-- PREVIEW Section -->
345 + <div v-if="isActive && previewPayments.length > 0" style="margin-bottom: var(--sa-space-6)">
346 + <div class="d-flex justify-content-between align-items-center" style="margin-bottom: var(--sa-space-4)">
347 + <h4 style="font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--sa-gray-400); margin: 0">
348 + {{ t('settlements.suggestedPayments') }}
349 + </h4>
350 + <span class="sa-badge sa-badge-neutral">{{ t('settlements.preview') }}</span>
351 + </div>
352 +
353 + <div style="display: flex; flex-direction: column; gap: var(--sa-space-3)">
354 + <div v-for="(p, pi) in previewPayments" :key="pi" class="sa-settlement-card">
355 + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0">
356 + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi)">{{ getInitials(p.from) }}</span>
357 + <span style="font-weight: 600; font-size: 0.9rem; color: var(--sa-gray-700)">{{ p.from }}</span>
358 + </div>
359 + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex-shrink: 0">
360 + <i class="bi bi-arrow-right sa-settlement-arrow"></i>
361 + <span class="sa-settlement-amount">{{ formatCurrency(p.amount, tripCurrencySymbol) }}</span>
362 + <i class="bi bi-arrow-right sa-settlement-arrow"></i>
363 + </div>
364 + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0; justify-content: flex-end">
365 + <span style="font-weight: 600; font-size: 0.9rem; color: var(--sa-gray-700)">{{ p.to }}</span>
366 + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi + 3)">{{ getInitials(p.to) }}</span>
367 + </div>
368 + </div>
369 + </div>
370 + <p style="font-size: 0.8rem; color: var(--sa-gray-400); margin-top: var(--sa-space-3)">
371 + <i class="bi bi-info-circle me-1"></i>{{ t('settlements.previewHint') }}
372 + </p>
373 + </div>
374 +
375 + <!-- SETTLEMENT PLAN Section -->
376 + <div v-if="hasActivePlan && summary.latestPlan && pendingPayments.length > 0">
377 + <h4
378 + style="
379 + font-size: 0.75rem;
380 + font-weight: 700;
381 + text-transform: uppercase;
382 + letter-spacing: 0.08em;
383 + color: var(--sa-gray-400);
384 + margin-bottom: var(--sa-space-4);
385 + "
386 + >
387 + {{ t('settlements.settlementPlan') }}
388 + </h4>
389 +
390 + <div style="display: flex; flex-direction: column; gap: var(--sa-space-3)">
391 + <div
392 + v-for="(payment, pi) in pendingPayments"
393 + :key="payment.id"
394 + class="sa-settlement-card"
395 + >
396 + <!-- From user -->
397 + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0">
398 + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi)">
399 + {{ getInitials(payment.fromUserName) }}
400 + </span>
401 + <span
402 + style="
403 + font-weight: 600;
404 + font-size: 0.9rem;
405 + color: var(--sa-gray-700);
406 + white-space: nowrap;
407 + overflow: hidden;
408 + text-overflow: ellipsis;
409 + "
410 + >
411 + {{ payment.fromUserName || t('common.unknown') }}
412 + </span>
413 + </div>
414 +
415 + <!-- Arrow + Amount -->
416 + <div
417 + style="
418 + display: flex;
419 + align-items: center;
420 + gap: var(--sa-space-2);
421 + flex-shrink: 0;
422 + "
423 + >
424 + <i class="bi bi-arrow-right sa-settlement-arrow"></i>
425 + <span class="sa-settlement-amount">{{ formatCurrency(payment.amount, tripCurrencySymbol) }}</span>
426 + <i class="bi bi-arrow-right sa-settlement-arrow"></i>
427 + </div>
428 +
429 + <!-- To user -->
430 + <div style="display: flex; align-items: center; gap: var(--sa-space-2); flex: 1; min-width: 0; justify-content: flex-end">
431 + <span
432 + style="
433 + font-weight: 600;
434 + font-size: 0.9rem;
435 + color: var(--sa-gray-700);
436 + white-space: nowrap;
437 + overflow: hidden;
438 + text-overflow: ellipsis;
439 + "
440 + >
441 + {{ payment.toUserName || t('common.unknown') }}
442 + </span>
443 + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(pi + 3)">
444 + {{ getInitials(payment.toUserName) }}
445 + </span>
446 + </div>
447 +
448 + <!-- Status Badge -->
449 + <div style="flex-shrink: 0; min-width: 90px; text-align: center">
450 + <span v-if="payment.status === 'Confirmed'" class="sa-badge sa-badge-success">
451 + <i class="bi bi-check-circle"></i> {{ t('settlements.confirmed') }}
452 + </span>
453 + <span v-else-if="payment.status === 'MarkedPaid'" class="sa-badge sa-badge-info">
454 + {{ t('settlements.markedPaid') }}
455 + </span>
456 + </div>
457 +
458 + <!-- Action Button -->
459 + <div style="flex-shrink: 0">
460 + <button
461 + v-if="!payment.markedPaidAt && payment.fromUserId === currentUserId"
462 + class="sa-btn sa-btn-success sa-btn-sm"
463 + @click="markPaid(payment.id)"
464 + >
465 + {{ t('settlements.markPaid') }}
466 + </button>
467 + <button
468 + v-else-if="payment.markedPaidAt && !payment.confirmedAt && payment.toUserId === currentUserId"
469 + class="sa-btn sa-btn-secondary sa-btn-sm"
470 + @click="confirmPayment(payment.id)"
471 + >
472 + {{ t('settlements.confirm') }}
473 + </button>
474 + <span
475 + v-else-if="payment.markedPaidAt && !payment.confirmedAt"
476 + class="sa-badge sa-badge-warning"
477 + >
478 + {{ t('settlements.awaitingConfirmation') }}
479 + </span>
480 + <span
481 + v-else-if="!payment.markedPaidAt"
482 + class="sa-badge sa-badge-neutral"
483 + >
484 + {{ t('settlements.pending') }}
485 + </span>
486 + </div>
487 + </div>
488 + </div>
489 +
490 + <!-- Total -->
491 + <div
492 + style="
493 + display: flex;
494 + justify-content: flex-end;
495 + align-items: center;
496 + gap: var(--sa-space-2);
497 + margin-top: var(--sa-space-4);
498 + padding-top: var(--sa-space-3);
499 + border-top: 1px solid var(--sa-gray-200);
500 + "
501 + >
502 + <span style="font-size: 0.85rem; color: var(--sa-gray-500); font-weight: 500">{{ t('settlements.total') }}</span>
503 + <span style="font-size: 1.1rem; font-weight: 700; color: var(--sa-gray-900)">
504 + {{ formatCurrency(totalAmount, tripCurrencySymbol) }}
505 + </span>
506 + </div>
507 + </div>
508 + </div>
509 + </div>
510 +</template>
added src/views/trips/CreateView.vue +214 −0
@@ -0,0 +1,214 @@
1 +<script setup lang="ts">
2 +import { ref, computed, onMounted } from 'vue'
3 +import { useRouter } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import TripService from '@/services/TripService'
6 +import CurrencyService from '@/services/CurrencyService'
7 +import type { ICurrency } from '@/types/ICurrency'
8 +
9 +const router = useRouter()
10 +const { t } = useI18n()
11 +
12 +const name = ref('')
13 +const description = ref('')
14 +const destination = ref('')
15 +const startDate = ref('')
16 +const endDate = ref('')
17 +const defaultCurrencyId = ref('')
18 +const currencies = ref<ICurrency[]>([])
19 +const errors = ref<string[]>([])
20 +const isSaving = ref(false)
21 +
22 +// Inline validation: end date must be on or after start date.
23 +// The ISO yyyy-MM-dd format from <input type="date"> compares lexicographically.
24 +const dateError = computed<string | null>(() => {
25 + if (!startDate.value || !endDate.value) return null
26 + return endDate.value < startDate.value
27 + ? t('trips.create.dateError')
28 + : null
29 +})
30 +
31 +const tripNights = computed<number | null>(() => {
32 + if (!startDate.value || !endDate.value || dateError.value) return null
33 + const ms = new Date(endDate.value).getTime() - new Date(startDate.value).getTime()
34 + return Math.max(0, Math.round(ms / 86_400_000))
35 +})
36 +
37 +onMounted(async () => {
38 + const result = await CurrencyService.getAll()
39 + if (result.data) {
40 + currencies.value = result.data
41 + const eur = result.data.find((c) => c.code === 'EUR')
42 + if (eur) defaultCurrencyId.value = eur.id
43 + }
44 +})
45 +
46 +async function handleSubmit() {
47 + if (dateError.value) {
48 + errors.value = [dateError.value]
49 + return
50 + }
51 + errors.value = []
52 + isSaving.value = true
53 +
54 + const result = await TripService.create({
55 + name: name.value,
56 + description: description.value || null,
57 + destination: destination.value || null,
58 + startDate: startDate.value ? new Date(startDate.value).toISOString() : null,
59 + endDate: endDate.value ? new Date(endDate.value).toISOString() : null,
60 + defaultCurrencyId: defaultCurrencyId.value,
61 + })
62 +
63 + if (result.errors) {
64 + errors.value = result.errors
65 + } else {
66 + router.push({ name: 'TripsIndex' })
67 + }
68 +
69 + isSaving.value = false
70 +}
71 +</script>
72 +
73 +<template>
74 + <div class="row justify-content-center">
75 + <div class="col-lg-8 col-xl-6">
76 + <!-- Gradient hero -->
77 + <div class="sa-gradient-header">
78 + <h2 class="mb-1"><i class="bi bi-suitcase-lg me-2"></i>{{ t('trips.create.title') }}</h2>
79 + <p class="mb-0 text-muted" style="font-size: 0.9rem">
80 + {{ t('trips.create.subtitle') }}
81 + </p>
82 + </div>
83 +
84 + <div v-if="errors.length" class="alert alert-danger">
85 + <div v-for="error in errors" :key="error">{{ error }}</div>
86 + </div>
87 +
88 + <!-- Form card -->
89 + <div class="sa-card">
90 + <div class="sa-card-body">
91 + <form @submit.prevent="handleSubmit">
92 + <div class="mb-3">
93 + <label for="name" class="form-label">
94 + <i class="bi bi-pencil-square me-1"></i> {{ t('trips.create.name') }} *
95 + </label>
96 + <input
97 + id="name"
98 + v-model="name"
99 + type="text"
100 + class="form-control"
101 + :placeholder="t('trips.create.namePlaceholder')"
102 + required
103 + />
104 + </div>
105 +
106 + <div class="mb-3">
107 + <label for="destination" class="form-label">
108 + <i class="bi bi-geo-alt me-1"></i> {{ t('trips.create.destination') }}
109 + </label>
110 + <input
111 + id="destination"
112 + v-model="destination"
113 + type="text"
114 + class="form-control"
115 + :placeholder="t('trips.create.destinationPlaceholder')"
116 + />
117 + </div>
118 +
119 + <div class="mb-3">
120 + <label for="description" class="form-label">
121 + <i class="bi bi-card-text me-1"></i> {{ t('trips.create.description') }}
122 + </label>
123 + <textarea
124 + id="description"
125 + v-model="description"
126 + class="form-control"
127 + rows="3"
128 + :placeholder="t('trips.create.descriptionPlaceholder')"
129 + ></textarea>
130 + </div>
131 +
132 + <div class="row g-3 mb-1">
133 + <div class="col-6">
134 + <label for="startDate" class="form-label">
135 + <i class="bi bi-calendar-event me-1"></i> {{ t('trips.create.start') }}
136 + </label>
137 + <input
138 + id="startDate"
139 + v-model="startDate"
140 + type="date"
141 + class="form-control"
142 + />
143 + </div>
144 + <div class="col-6">
145 + <label for="endDate" class="form-label">
146 + <i class="bi bi-calendar-check me-1"></i> {{ t('trips.create.end') }}
147 + </label>
148 + <input
149 + id="endDate"
150 + v-model="endDate"
151 + type="date"
152 + class="form-control"
153 + :class="{ 'is-invalid': dateError }"
154 + :min="startDate || undefined"
155 + />
156 + <div v-if="dateError" class="invalid-feedback">{{ dateError }}</div>
157 + </div>
158 + </div>
159 + <div class="mb-3" style="min-height: 1.25rem">
160 + <small v-if="tripNights !== null" class="text-muted">
161 + <i class="bi bi-moon-stars me-1"></i>
162 + {{ t('trips.create.nightCount', { n: tripNights }, tripNights) }}
163 + </small>
164 + </div>
165 +
166 + <div class="mb-4">
167 + <label for="currency" class="form-label">
168 + <i class="bi bi-currency-exchange me-1"></i> {{ t('trips.create.defaultCurrency') }}
169 + </label>
170 + <select
171 + id="currency"
172 + v-model="defaultCurrencyId"
173 + class="form-select"
174 + required
175 + >
176 + <option value="" disabled>{{ t('trips.create.selectCurrency') }}</option>
177 + <option v-for="c in currencies" :key="c.id" :value="c.id">
178 + {{ c.code }} — {{ c.name }} ({{ c.symbol }})
179 + </option>
180 + </select>
181 + </div>
182 +
183 + <div class="d-flex justify-content-end gap-2">
184 + <button
185 + type="button"
186 + class="sa-btn sa-btn-ghost sa-btn-pill"
187 + @click="router.push({ name: 'TripsIndex' })"
188 + >
189 + {{ t('common.cancel') }}
190 + </button>
191 + <button
192 + type="submit"
193 + class="sa-btn sa-btn-primary sa-btn-pill"
194 + :disabled="isSaving || !!dateError"
195 + >
196 + <span v-if="isSaving">
197 + <span
198 + class="spinner-border spinner-border-sm me-1"
199 + role="status"
200 + aria-hidden="true"
201 + ></span>
202 + {{ t('trips.create.creating') }}
203 + </span>
204 + <span v-else>
205 + <i class="bi bi-check-lg me-1"></i> {{ t('trips.create.createTrip') }}
206 + </span>
207 + </button>
208 + </div>
209 + </form>
210 + </div>
211 + </div>
212 + </div>
213 + </div>
214 +</template>
added src/views/trips/DetailView.vue +404 −0
@@ -0,0 +1,404 @@
1 +<script setup lang="ts">
2 +import { ref, computed, onMounted, provide } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import TripService from '@/services/TripService'
6 +import ExpenseService from '@/services/ExpenseService'
7 +import BudgetCategoryService from '@/services/BudgetCategoryService'
8 +import SettlementService from '@/services/SettlementService'
9 +import type { ITrip, ITripParticipant } from '@/types/ITrip'
10 +import type { IExpense } from '@/types/IExpense'
11 +import type { IBudgetCategory } from '@/types/IBudgetCategory'
12 +import type { IBalance } from '@/types/ISettlement'
13 +import { useAuthStore } from '@/stores/auth'
14 +import { getUserIdFromJwt } from '@/utils/parseJwt'
15 +import { formatCurrency } from '@/utils/formatCurrency'
16 +
17 +const router = useRouter()
18 +const route = useRoute()
19 +const authStore = useAuthStore()
20 +const { t, d } = useI18n()
21 +
22 +const trip = ref<ITrip | null>(null)
23 +const participants = ref<ITripParticipant[]>([])
24 +const expenses = ref<IExpense[]>([])
25 +const categories = ref<IBudgetCategory[]>([])
26 +const balances = ref<IBalance[]>([])
27 +const isLoading = ref(true)
28 +const error = ref<string | null>(null)
29 +
30 +const tripId = route.params.tripId as string
31 +
32 +const currentUserId = computed(() => {
33 + if (!authStore.jwt) return null
34 + return getUserIdFromJwt(authStore.jwt)
35 +})
36 +
37 +const isOrganizer = computed(() => {
38 + if (!currentUserId.value) return false
39 + if (participants.value.length > 0) {
40 + const me = participants.value.find(p => p.userId === currentUserId.value)
41 + return me?.role === 'Organizer'
42 + }
43 + return trip.value?.createdById === currentUserId.value
44 +})
45 +
46 +provide('isOrganizer', isOrganizer)
47 +provide('currentUserId', currentUserId)
48 +provide('tripStatus', computed(() => trip.value?.status ?? 'Active'))
49 +provide('tripCurrencySymbol', computed(() => trip.value?.defaultCurrencySymbol ?? ''))
50 +
51 +const isChildRoute = computed(() => {
52 + return route.name !== 'TripDetail'
53 +})
54 +
55 +const totalExpenses = computed(() => expenses.value.reduce((sum, e) => sum + (e.amountInTripCurrency ?? e.amount), 0))
56 +
57 +const totalPlanned = computed(() => categories.value.reduce((sum, c) => sum + (c.plannedAmount || 0), 0))
58 +
59 +const totalSpent = computed(() => categories.value.reduce((sum, c) => sum + c.spentAmount, 0))
60 +
61 +const budgetPercent = computed(() => {
62 + if (totalPlanned.value === 0) return 0
63 + return Math.round((totalSpent.value / totalPlanned.value) * 100)
64 +})
65 +
66 +const recentExpenses = computed(() => {
67 + return [...expenses.value]
68 + .sort((a, b) => new Date(b.expenseDate).getTime() - new Date(a.expenseDate).getTime())
69 + .slice(0, 5)
70 +})
71 +
72 +const visibleParticipants = computed(() => participants.value.slice(0, 6))
73 +const overflowCount = computed(() => Math.max(0, participants.value.length - 6))
74 +
75 +const myBalance = computed(() => {
76 + if (!currentUserId.value) return 0
77 + return balances.value.find(b => b.userId === currentUserId.value)?.balance ?? 0
78 +})
79 +
80 +onMounted(async () => {
81 + const [tripResult, participantsResult, expensesResult, categoriesResult, balancesResult] =
82 + await Promise.all([
83 + TripService.getById(tripId),
84 + TripService.getParticipants(tripId),
85 + ExpenseService.getByTrip(tripId),
86 + BudgetCategoryService.getByTrip(tripId),
87 + SettlementService.getBalances(tripId),
88 + ])
89 +
90 + if (tripResult.data) trip.value = tripResult.data
91 + else if (tripResult.errors) error.value = tripResult.errors.join(', ')
92 +
93 + if (participantsResult.data) participants.value = participantsResult.data
94 + if (expensesResult.data) expenses.value = expensesResult.data
95 + if (categoriesResult.data) categories.value = categoriesResult.data
96 + if (balancesResult.data) balances.value = balancesResult.data
97 +
98 + isLoading.value = false
99 +})
100 +
101 +function formatDate(dateStr: string | null) {
102 + if (!dateStr) return '—'
103 + return d(new Date(dateStr), 'short')
104 +}
105 +
106 +function getInitials(name: string | null) {
107 + if (!name) return '?'
108 + const parts = name.split(' ')
109 + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
110 + return name.substring(0, 2).toUpperCase()
111 +}
112 +
113 +function avatarColor(index: number) {
114 + return `sa-avatar-${(index % 8) + 1}`
115 +}
116 +
117 +function budgetBarClass(percent: number) {
118 + if (percent > 85) return 'sa-progress-bar-danger'
119 + if (percent > 60) return 'sa-progress-bar-warning'
120 + return 'sa-progress-bar-success'
121 +}
122 +
123 +function categoryIconClass(name: string | null) {
124 + if (!name) return 'sa-category-default'
125 + const lower = name.toLowerCase()
126 + if (lower.includes('food') || lower.includes('meal') || lower.includes('dining')) return 'sa-category-food'
127 + if (lower.includes('accommodation') || lower.includes('hotel') || lower.includes('stay')) return 'sa-category-accommodation'
128 + if (lower.includes('transport') || lower.includes('travel') || lower.includes('taxi') || lower.includes('flight')) return 'sa-category-transport'
129 + if (lower.includes('activit') || lower.includes('tour') || lower.includes('entertainment')) return 'sa-category-activities'
130 + if (lower.includes('shopping') || lower.includes('souvenir')) return 'sa-category-shopping'
131 + return 'sa-category-default'
132 +}
133 +
134 +function categoryIcon(name: string | null) {
135 + if (!name) return 'bi-tag'
136 + const lower = name.toLowerCase()
137 + if (lower.includes('food') || lower.includes('meal') || lower.includes('dining')) return 'bi-cup-hot-fill'
138 + if (lower.includes('accommodation') || lower.includes('hotel')) return 'bi-house-fill'
139 + if (lower.includes('transport') || lower.includes('taxi') || lower.includes('flight')) return 'bi-car-front-fill'
140 + if (lower.includes('activit') || lower.includes('tour')) return 'bi-lightning-fill'
141 + if (lower.includes('shopping') || lower.includes('souvenir')) return 'bi-bag-fill'
142 + return 'bi-tag-fill'
143 +}
144 +
145 +const navItems = computed(() => [
146 + { name: 'ExpensesIndex', label: t('expenses.index.title'), icon: 'bi-receipt', iconClass: 'sa-nav-icon-expenses' },
147 + { name: 'MembersView', label: t('trips.detail.members'), icon: 'bi-people-fill', iconClass: 'sa-nav-icon-members' },
148 + { name: 'BudgetIndex', label: t('budget.index.title'), icon: 'bi-pie-chart-fill', iconClass: 'sa-nav-icon-budget' },
149 + { name: 'WishlistIndex', label: t('wishlist.index.title'), icon: 'bi-heart-fill', iconClass: 'sa-nav-icon-wishlist' },
150 + { name: 'PollsIndex', label: t('polls.index.title'), icon: 'bi-bar-chart-fill', iconClass: 'sa-nav-icon-polls' },
151 + { name: 'SettlementView', label: t('settlements.title'), icon: 'bi-arrow-left-right', iconClass: 'sa-nav-icon-settlement' },
152 +])
153 +</script>
154 +
155 +<template>
156 + <div>
157 + <div v-if="isLoading" class="text-center py-5">
158 + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div>
159 + </div>
160 +
161 + <div v-else-if="error" class="alert alert-danger">{{ error }}</div>
162 +
163 + <div v-else-if="trip">
164 + <!-- Show dashboard when on main detail route, show child view otherwise -->
165 + <template v-if="!isChildRoute">
166 + <!-- Hero Header -->
167 + <div class="sa-gradient-header">
168 + <div class="d-flex justify-content-between align-items-start">
169 + <div>
170 + <div class="d-flex align-items-center gap-2 mb-2">
171 + <h1 class="mb-0" style="font-weight: 800">{{ trip.name }}</h1>
172 + <span class="sa-badge" :class="trip.status === 'Active' ? 'sa-badge-success' : 'sa-badge-info'">
173 + {{ t(`trips.status.${trip.status}`) }}
174 + </span>
175 + <span v-if="isOrganizer" class="sa-badge sa-badge-primary">
176 + <i class="bi bi-shield-fill me-1"></i>{{ t('trips.detail.organizer') }}
177 + </span>
178 + </div>
179 + <p v-if="trip.destination" class="text-muted mb-1">
180 + <i class="bi bi-geo-alt-fill me-1"></i>{{ trip.destination }}
181 + </p>
182 + <p class="text-muted mb-0" style="font-size: 0.9rem">
183 + <i class="bi bi-calendar3 me-1"></i>
184 + {{ formatDate(trip.startDate) }} — {{ formatDate(trip.endDate) }}
185 + <span v-if="trip.defaultCurrencyCode" class="ms-2">
186 + <i class="bi bi-currency-exchange me-1"></i>{{ trip.defaultCurrencyCode }}
187 + </span>
188 + </p>
189 + </div>
190 + <button
191 + v-if="isOrganizer"
192 + class="sa-btn sa-btn-ghost sa-btn-sm"
193 + style="color: #fff; border-color: rgba(255,255,255,0.3)"
194 + @click="router.push({ name: 'TripsEdit', params: { tripId: trip!.id } })"
195 + >
196 + <i class="bi bi-pencil"></i> {{ t('common.edit') }}
197 + </button>
198 + </div>
199 +
200 + <!-- Avatar Stack -->
201 + <div v-if="participants.length > 0" class="d-flex align-items-center gap-3 mt-3">
202 + <div class="sa-avatar-stack">
203 + <span
204 + v-for="(p, i) in visibleParticipants"
205 + :key="p.id"
206 + class="sa-avatar sa-avatar-sm"
207 + :class="avatarColor(i)"
208 + :title="p.userName ?? p.userEmail ?? ''"
209 + >
210 + {{ getInitials(p.userName) }}
211 + </span>
212 + <span v-if="overflowCount > 0" class="sa-avatar sa-avatar-sm sa-avatar-overflow">
213 + +{{ overflowCount }}
214 + </span>
215 + </div>
216 + <span class="text-muted" style="font-size: 0.85rem">{{ t('trips.detail.memberCount', { n: participants.length }, participants.length) }}</span>
217 + </div>
218 + </div>
219 +
220 + <!-- Stats Row -->
221 + <div class="row g-3 mb-4">
222 + <div class="col-6 col-md-3">
223 + <div class="sa-card-static sa-card-body sa-stat">
224 + <div class="sa-stat-icon sa-text-primary"><i class="bi bi-receipt"></i></div>
225 + <div class="sa-stat-value">{{ formatCurrency(totalExpenses, trip?.defaultCurrencySymbol, 0) }}</div>
226 + <div class="sa-stat-label">{{ t('trips.detail.totalExpenses') }}</div>
227 + </div>
228 + </div>
229 + <div class="col-6 col-md-3">
230 + <div class="sa-card-static sa-card-body sa-stat">
231 + <div class="sa-stat-icon" :class="budgetPercent > 85 ? 'sa-text-primary' : 'sa-text-secondary'">
232 + <i class="bi bi-pie-chart-fill"></i>
233 + </div>
234 + <div class="sa-stat-value">{{ totalPlanned > 0 ? budgetPercent + '%' : '—' }}</div>
235 + <div class="sa-stat-label">{{ t('trips.detail.budgetUsed') }}</div>
236 + </div>
237 + </div>
238 + <div class="col-6 col-md-3">
239 + <div class="sa-card-static sa-card-body sa-stat">
240 + <div class="sa-stat-icon"><i class="bi bi-wallet2" style="color: var(--sa-accent)"></i></div>
241 + <div class="sa-stat-value" :class="myBalance >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'">
242 + {{ formatCurrency(myBalance, trip?.defaultCurrencySymbol, 0) }}
243 + </div>
244 + <div class="sa-stat-label">{{ t('trips.detail.yourBalance') }}</div>
245 + </div>
246 + </div>
247 + <div class="col-6 col-md-3">
248 + <div class="sa-card-static sa-card-body sa-stat">
249 + <div class="sa-stat-icon" style="color: #6366f1"><i class="bi bi-people-fill"></i></div>
250 + <div class="sa-stat-value">{{ participants.length }}</div>
251 + <div class="sa-stat-label">{{ t('trips.detail.members') }}</div>
252 + </div>
253 + </div>
254 + </div>
255 +
256 + <!-- Navigation Grid -->
257 + <div class="row g-3 mb-4">
258 + <div v-for="nav in navItems" :key="nav.name" class="col-4 col-md-2">
259 + <div
260 + class="sa-nav-card"
261 + @click="router.push({ name: nav.name, params: { tripId } })"
262 + >
263 + <div class="sa-nav-card-icon" :class="nav.iconClass">
264 + <i :class="['bi', nav.icon]"></i>
265 + </div>
266 + <div class="sa-nav-card-label">{{ nav.label }}</div>
267 + </div>
268 + </div>
269 + </div>
270 +
271 + <!-- Budget Progress + Balances -->
272 + <div v-if="categories.length > 0" class="row g-3 mb-4">
273 + <div class="col-12 col-lg-6">
274 + <div class="sa-card-static h-100">
275 + <div class="sa-card-header d-flex justify-content-between align-items-center">
276 + <span><i class="bi bi-pie-chart-fill me-2" style="color: var(--sa-success)"></i>{{ t('trips.detail.budgetProgress') }}</span>
277 + <RouterLink :to="{ name: 'BudgetIndex', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink>
278 + </div>
279 + <div class="sa-card-body">
280 + <div v-for="cat in categories.slice(0, 4)" :key="cat.id" class="mb-3">
281 + <div class="d-flex justify-content-between mb-1">
282 + <span style="font-weight: 600; font-size: 0.9rem">{{ cat.name }}</span>
283 + <span style="font-size: 0.85rem; color: var(--sa-gray-500)">
284 + {{ formatCurrency(cat.spentAmount, trip?.defaultCurrencySymbol, 0) }} / {{ formatCurrency(cat.plannedAmount || 0, trip?.defaultCurrencySymbol, 0) }}
285 + </span>
286 + </div>
287 + <div class="sa-progress sa-progress-sm">
288 + <div
289 + class="sa-progress-bar"
290 + :class="budgetBarClass(cat.plannedAmount ? (cat.spentAmount / cat.plannedAmount) * 100 : 0)"
291 + :style="{ width: cat.plannedAmount ? Math.min(100, (cat.spentAmount / cat.plannedAmount) * 100) + '%' : '0%' }"
292 + ></div>
293 + </div>
294 + </div>
295 + </div>
296 + </div>
297 + </div>
298 +
299 + <div v-if="balances.length > 0" class="col-12 col-lg-6">
300 + <div class="sa-card-static h-100">
301 + <div class="sa-card-header d-flex justify-content-between align-items-center">
302 + <span><i class="bi bi-arrow-left-right me-2" style="color: var(--sa-secondary)"></i>{{ t('trips.detail.balances') }}</span>
303 + <RouterLink :to="{ name: 'SettlementView', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink>
304 + </div>
305 + <div class="sa-card-body">
306 + <div v-for="(bal, i) in balances.slice(0, 5)" :key="bal.userId" class="d-flex align-items-center gap-3 mb-3">
307 + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(i)">{{ getInitials(bal.userName) }}</span>
308 + <span class="flex-grow-1" style="font-weight: 500; font-size: 0.9rem">{{ bal.userName }}</span>
309 + <span class="sa-amount" :class="bal.balance >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'">
310 + {{ bal.balance >= 0 ? '+' : '' }}{{ formatCurrency(bal.balance, trip?.defaultCurrencySymbol) }}
311 + </span>
312 + </div>
313 + </div>
314 + </div>
315 + </div>
316 + </div>
317 +
318 + <!-- Balances + Recent Expenses (when NO budget) -->
319 + <div v-if="categories.length === 0 && (balances.length > 0 || recentExpenses.length > 0)" class="row g-3 mb-4">
320 + <div v-if="balances.length > 0" class="col-12 col-lg-6">
321 + <div class="sa-card-static h-100">
322 + <div class="sa-card-header d-flex justify-content-between align-items-center">
323 + <span><i class="bi bi-arrow-left-right me-2" style="color: var(--sa-secondary)"></i>{{ t('trips.detail.balances') }}</span>
324 + <RouterLink :to="{ name: 'SettlementView', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink>
325 + </div>
326 + <div class="sa-card-body">
327 + <div v-for="(bal, i) in balances.slice(0, 5)" :key="bal.userId" class="d-flex align-items-center gap-3 mb-3">
328 + <span class="sa-avatar sa-avatar-sm" :class="avatarColor(i)">{{ getInitials(bal.userName) }}</span>
329 + <span class="flex-grow-1" style="font-weight: 500; font-size: 0.9rem">{{ bal.userName }}</span>
330 + <span class="sa-amount" :class="bal.balance >= 0 ? 'sa-amount-positive' : 'sa-amount-negative'">
331 + {{ bal.balance >= 0 ? '+' : '' }}{{ formatCurrency(bal.balance, trip?.defaultCurrencySymbol) }}
332 + </span>
333 + </div>
334 + </div>
335 + </div>
336 + </div>
337 +
338 + <div v-if="recentExpenses.length > 0" class="col-12 col-lg-6">
339 + <div class="sa-card-static h-100">
340 + <div class="sa-card-header d-flex justify-content-between align-items-center">
341 + <span><i class="bi bi-receipt me-2" style="color: var(--sa-primary)"></i>{{ t('trips.detail.recentExpenses') }}</span>
342 + <RouterLink :to="{ name: 'ExpensesIndex', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink>
343 + </div>
344 + <div>
345 + <div v-for="expense in recentExpenses" :key="expense.id" class="sa-expense-item">
346 + <div class="sa-expense-icon" :class="categoryIconClass(expense.budgetCategoryName)">
347 + <i :class="['bi', categoryIcon(expense.budgetCategoryName)]"></i>
348 + </div>
349 + <div class="sa-expense-details">
350 + <div class="sa-expense-desc">{{ expense.description || t('trips.detail.untitledExpense') }}</div>
351 + <div class="sa-expense-meta">
352 + {{ expense.paidByUserName }} &middot; {{ formatDate(expense.expenseDate) }}
353 + </div>
354 + </div>
355 + <div class="sa-expense-amount">{{ formatCurrency(expense.amount, expense.currencySymbol ?? trip?.defaultCurrencySymbol) }}</div>
356 + </div>
357 + </div>
358 + </div>
359 + </div>
360 + </div>
361 +
362 + <!-- Recent Expenses (full-width, when budget exists) -->
363 + <div v-if="categories.length > 0 && recentExpenses.length > 0" class="sa-card-static mb-4">
364 + <div class="sa-card-header d-flex justify-content-between align-items-center">
365 + <span><i class="bi bi-receipt me-2" style="color: var(--sa-primary)"></i>{{ t('trips.detail.recentExpenses') }}</span>
366 + <RouterLink :to="{ name: 'ExpensesIndex', params: { tripId } }" class="sa-btn sa-btn-ghost sa-btn-sm">{{ t('common.viewAll') }}</RouterLink>
367 + </div>
368 + <div>
369 + <div v-for="expense in recentExpenses" :key="expense.id" class="sa-expense-item">
370 + <div class="sa-expense-icon" :class="categoryIconClass(expense.budgetCategoryName)">
371 + <i :class="['bi', categoryIcon(expense.budgetCategoryName)]"></i>
372 + </div>
373 + <div class="sa-expense-details">
374 + <div class="sa-expense-desc">{{ expense.description || t('trips.detail.untitledExpense') }}</div>
375 + <div class="sa-expense-meta">
376 + {{ expense.paidByUserName }} &middot; {{ formatDate(expense.expenseDate) }}
377 + </div>
378 + </div>
379 + <div class="sa-expense-amount">{{ formatCurrency(expense.amount, expense.currencySymbol ?? trip?.defaultCurrencySymbol) }}</div>
380 + </div>
381 + </div>
382 + </div>
383 +
384 + <!-- Description -->
385 + <div v-if="trip.description" class="sa-card-static mb-4">
386 + <div class="sa-card-header"><i class="bi bi-info-circle me-2"></i>{{ t('trips.detail.about') }}</div>
387 + <div class="sa-card-body" style="color: var(--sa-gray-600); line-height: 1.7">
388 + {{ trip.description }}
389 + </div>
390 + </div>
391 + </template>
392 +
393 + <!-- Child Route Content -->
394 + <template v-else>
395 + <div class="d-flex align-items-center gap-3 mb-4">
396 + <button class="sa-btn sa-btn-ghost sa-btn-sm" @click="router.push({ name: 'TripDetail', params: { tripId } })">
397 + <i class="bi bi-arrow-left"></i> {{ t('trips.detail.backTo', { name: trip.name }) }}
398 + </button>
399 + </div>
400 + <RouterView />
401 + </template>
402 + </div>
403 + </div>
404 +</template>
added src/views/trips/EditView.vue +180 −0
@@ -0,0 +1,180 @@
1 +<script setup lang="ts">
2 +import { ref, computed, onMounted } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import TripService from '@/services/TripService'
6 +import CurrencyService from '@/services/CurrencyService'
7 +import type { ICurrency } from '@/types/ICurrency'
8 +
9 +const router = useRouter()
10 +const route = useRoute()
11 +const { t } = useI18n()
12 +
13 +const name = ref('')
14 +const description = ref('')
15 +const destination = ref('')
16 +const startDate = ref('')
17 +const endDate = ref('')
18 +const defaultCurrencyId = ref('')
19 +const status = ref('Active')
20 +const currencies = ref<ICurrency[]>([])
21 +const errors = ref<string[]>([])
22 +const isSaving = ref(false)
23 +const isLoading = ref(true)
24 +
25 +const dateError = computed<string | null>(() => {
26 + if (!startDate.value || !endDate.value) return null
27 + return endDate.value < startDate.value
28 + ? t('trips.create.dateError')
29 + : null
30 +})
31 +
32 +const tripId = route.params.tripId as string
33 +
34 +onMounted(async () => {
35 + const [tripResult, currencyResult] = await Promise.all([
36 + TripService.getById(tripId),
37 + CurrencyService.getAll(),
38 + ])
39 +
40 + if (currencyResult.data) {
41 + currencies.value = currencyResult.data
42 + }
43 +
44 + if (tripResult.data) {
45 + name.value = tripResult.data.name
46 + description.value = tripResult.data.description ?? ''
47 + destination.value = tripResult.data.destination ?? ''
48 + startDate.value = tripResult.data.startDate?.substring(0, 10) ?? ''
49 + endDate.value = tripResult.data.endDate?.substring(0, 10) ?? ''
50 + defaultCurrencyId.value = tripResult.data.defaultCurrencyId
51 + status.value = tripResult.data.status
52 + } else {
53 + errors.value = tripResult.errors ?? [t('trips.edit.notFound')]
54 + }
55 + isLoading.value = false
56 +})
57 +
58 +async function handleSubmit() {
59 + if (dateError.value) {
60 + errors.value = [dateError.value]
61 + return
62 + }
63 + errors.value = []
64 + isSaving.value = true
65 +
66 + const result = await TripService.update(tripId, {
67 + id: tripId,
68 + name: name.value,
69 + description: description.value || null,
70 + destination: destination.value || null,
71 + startDate: startDate.value ? new Date(startDate.value).toISOString() : null,
72 + endDate: endDate.value ? new Date(endDate.value).toISOString() : null,
73 + defaultCurrencyId: defaultCurrencyId.value,
74 + status: status.value,
75 + })
76 +
77 + if (result.errors) {
78 + errors.value = result.errors
79 + } else {
80 + router.push({ name: 'TripsIndex' })
81 + }
82 +
83 + isSaving.value = false
84 +}
85 +
86 +async function handleDelete() {
87 + if (!confirm(t('trips.edit.confirmDelete'))) return
88 + const result = await TripService.delete(tripId)
89 + if (result.errors) {
90 + errors.value = result.errors
91 + } else {
92 + router.push({ name: 'TripsIndex' })
93 + }
94 +}
95 +</script>
96 +
97 +<template>
98 + <div class="row justify-content-center">
99 + <div v-if="isLoading" class="text-center py-5">
100 + <div class="spinner-border" role="status"></div>
101 + </div>
102 +
103 + <div v-else class="col-md-8 col-lg-6">
104 + <h2>{{ t('trips.edit.title') }}</h2>
105 +
106 + <div v-if="errors.length" class="alert alert-danger">
107 + <div v-for="error in errors" :key="error">{{ error }}</div>
108 + </div>
109 +
110 + <form @submit.prevent="handleSubmit">
111 + <div class="mb-3">
112 + <label for="name" class="form-label">{{ t('trips.edit.name') }}</label>
113 + <input id="name" v-model="name" type="text" class="form-control" required />
114 + </div>
115 +
116 + <div class="mb-3">
117 + <label for="destination" class="form-label">{{ t('trips.edit.destination') }}</label>
118 + <input id="destination" v-model="destination" type="text" class="form-control" />
119 + </div>
120 +
121 + <div class="mb-3">
122 + <label for="description" class="form-label">{{ t('trips.edit.description') }}</label>
123 + <textarea id="description" v-model="description" class="form-control" rows="3"></textarea>
124 + </div>
125 +
126 + <div class="row mb-3">
127 + <div class="col-6">
128 + <label for="startDate" class="form-label">{{ t('trips.edit.startDate') }}</label>
129 + <input id="startDate" v-model="startDate" type="date" class="form-control" />
130 + </div>
131 + <div class="col-6">
132 + <label for="endDate" class="form-label">{{ t('trips.edit.endDate') }}</label>
133 + <input
134 + id="endDate"
135 + v-model="endDate"
136 + type="date"
137 + class="form-control"
138 + :class="{ 'is-invalid': dateError }"
139 + :min="startDate || undefined"
140 + />
141 + <div v-if="dateError" class="invalid-feedback">{{ dateError }}</div>
142 + </div>
143 + </div>
144 +
145 + <div class="mb-3">
146 + <label for="currency" class="form-label">{{ t('trips.edit.currency') }}</label>
147 + <select id="currency" v-model="defaultCurrencyId" class="form-select" required>
148 + <option value="" disabled>{{ t('trips.edit.selectCurrency') }}</option>
149 + <option v-for="c in currencies" :key="c.id" :value="c.id">
150 + {{ c.code }} — {{ c.name }} ({{ c.symbol }})
151 + </option>
152 + </select>
153 + </div>
154 +
155 + <div class="mb-3">
156 + <label for="status" class="form-label">{{ t('trips.edit.status') }}</label>
157 + <select id="status" v-model="status" class="form-select">
158 + <option value="Active">{{ t('trips.status.Active') }}</option>
159 + <option value="Completed">{{ t('trips.status.Completed') }}</option>
160 + </select>
161 + </div>
162 +
163 + <div class="d-flex gap-2">
164 + <button type="button" class="btn btn-danger" @click="handleDelete">{{ t('common.delete') }}</button>
165 + <div class="flex-grow-1"></div>
166 + <button
167 + type="button"
168 + class="btn btn-outline-secondary"
169 + @click="router.push({ name: 'TripsIndex' })"
170 + >
171 + {{ t('common.cancel') }}
172 + </button>
173 + <button type="submit" class="btn btn-primary" :disabled="isSaving || !!dateError">
174 + {{ isSaving ? t('common.saving') : t('common.saveChanges') }}
175 + </button>
176 + </div>
177 + </form>
178 + </div>
179 + </div>
180 +</template>
added src/views/trips/IndexView.vue +154 −0
@@ -0,0 +1,154 @@
1 +<script setup lang="ts">
2 +import { ref, onMounted } from 'vue'
3 +import { useRouter } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import TripService from '@/services/TripService'
6 +import type { ITrip } from '@/types/ITrip'
7 +import { useToast } from '@/composables/useToast'
8 +import { useAuthStore } from '@/stores/auth'
9 +import { getUserIdFromJwt } from '@/utils/parseJwt'
10 +
11 +const authStore = useAuthStore()
12 +const { t, d } = useI18n()
13 +
14 +function isUserOrganizer(trip: ITrip): boolean {
15 + if (!authStore.jwt) return false
16 + const userId = getUserIdFromJwt(authStore.jwt)
17 + if (!userId) return false
18 + if (trip.participants) {
19 + const me = trip.participants.find(p => p.userId === userId)
20 + return me?.role === 'Organizer'
21 + }
22 + return trip.createdById === userId
23 +}
24 +
25 +const router = useRouter()
26 +const toast = useToast()
27 +
28 +const trips = ref<ITrip[]>([])
29 +const isLoading = ref(true)
30 +const error = ref<string | null>(null)
31 +
32 +onMounted(async () => {
33 + const result = await TripService.getAll()
34 + if (result.data) {
35 + trips.value = result.data
36 + } else if (result.errors) {
37 + error.value = result.errors.join(', ')
38 + }
39 + isLoading.value = false
40 +})
41 +
42 +async function deleteTrip(id: string) {
43 + if (!confirm(t('trips.index.confirmDelete'))) return
44 + error.value = null
45 +
46 + const result = await TripService.delete(id)
47 + if (result.errors) {
48 + toast.error(result.errors.join(', '))
49 + } else {
50 + trips.value = trips.value.filter((t) => t.id !== id)
51 + toast.success(t('trips.index.deleted'))
52 + }
53 +}
54 +
55 +function formatDate(dateStr: string | null) {
56 + if (!dateStr) return '—'
57 + return d(new Date(dateStr), 'short')
58 +}
59 +</script>
60 +
61 +<template>
62 + <div>
63 + <!-- Gradient Header -->
64 + <div class="sa-gradient-header d-flex justify-content-between align-items-center">
65 + <div>
66 + <h2 class="mb-1"><i class="bi bi-suitcase-lg me-2"></i>{{ t('trips.index.title') }}</h2>
67 + <p class="mb-0 text-muted" style="font-size: 0.9rem">
68 + {{ t('trips.index.count', { n: trips.length }, trips.length) }}
69 + </p>
70 + </div>
71 + <button
72 + class="sa-btn sa-btn-primary sa-btn-pill sa-hide-mobile"
73 + @click="router.push({ name: 'TripsCreate' })"
74 + >
75 + <i class="bi bi-plus-lg"></i> {{ t('trips.index.newTrip') }}
76 + </button>
77 + </div>
78 +
79 + <div v-if="error" class="alert alert-danger">{{ error }}</div>
80 +
81 + <!-- Loading -->
82 + <div v-if="isLoading" class="text-center py-5">
83 + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div>
84 + </div>
85 +
86 + <!-- Empty State -->
87 + <div v-else-if="trips.length === 0" class="sa-empty">
88 + <div class="sa-empty-icon"><i class="bi bi-suitcase-lg"></i></div>
89 + <div class="sa-empty-title">{{ t('trips.index.emptyTitle') }}</div>
90 + <div class="sa-empty-text">{{ t('trips.index.emptyText') }}</div>
91 + <button class="sa-btn sa-btn-primary sa-btn-pill" @click="router.push({ name: 'TripsCreate' })">
92 + <i class="bi bi-plus-lg"></i> {{ t('trips.index.createFirst') }}
93 + </button>
94 + </div>
95 +
96 + <!-- Trip Cards Grid -->
97 + <div v-else class="row g-4">
98 + <div
99 + v-for="(trip, index) in trips"
100 + :key="trip.id"
101 + class="col-md-6 col-lg-4 sa-animate-slide-up"
102 + :class="`sa-stagger-${(index % 6) + 1}`"
103 + >
104 + <div
105 + class="sa-card sa-trip-card h-100"
106 + style="cursor: pointer"
107 + @click="router.push({ name: 'TripDetail', params: { tripId: trip.id } })"
108 + >
109 + <div class="sa-trip-card-gradient"></div>
110 + <div class="sa-card-body">
111 + <h5 class="mb-2" style="font-weight: 700">{{ trip.name }}</h5>
112 + <p v-if="trip.destination" class="mb-1" style="color: var(--sa-gray-500); font-size: 0.9rem">
113 + <i class="bi bi-geo-alt me-1"></i>{{ trip.destination }}
114 + </p>
115 + <p class="mb-3" style="color: var(--sa-gray-400); font-size: 0.85rem">
116 + <i class="bi bi-calendar3 me-1"></i>
117 + {{ formatDate(trip.startDate) }} — {{ formatDate(trip.endDate) }}
118 + </p>
119 + <div class="d-flex flex-wrap gap-2">
120 + <span class="sa-badge" :class="trip.status === 'Active' ? 'sa-badge-success' : 'sa-badge-info'">
121 + {{ t(`trips.status.${trip.status}`) }}
122 + </span>
123 + <span class="sa-badge sa-badge-neutral">
124 + <i class="bi bi-people-fill me-1"></i>{{ trip.participantCount }}
125 + </span>
126 + <span v-if="trip.defaultCurrencyCode" class="sa-badge sa-badge-accent">
127 + {{ trip.defaultCurrencyCode }}
128 + </span>
129 + <span class="sa-badge" :class="isUserOrganizer(trip) ? 'sa-badge-primary' : 'sa-badge-neutral'">
130 + <i :class="isUserOrganizer(trip) ? 'bi bi-shield-fill me-1' : 'bi bi-person me-1'"></i>{{ isUserOrganizer(trip) ? t('trips.detail.organizer') : t('trips.detail.participant') }}
131 + </span>
132 + </div>
133 + </div>
134 + <div v-if="isUserOrganizer(trip)" class="sa-card-footer d-flex gap-2" @click.stop>
135 + <button
136 + class="sa-btn sa-btn-ghost sa-btn-sm"
137 + @click="router.push({ name: 'TripsEdit', params: { tripId: trip.id } })"
138 + >
139 + <i class="bi bi-pencil"></i> {{ t('common.edit') }}
140 + </button>
141 + <button class="sa-btn sa-btn-ghost sa-btn-sm" style="color: var(--sa-danger)" @click="deleteTrip(trip.id)">
142 + <i class="bi bi-trash3"></i> {{ t('common.delete') }}
143 + </button>
144 + </div>
145 + </div>
146 + </div>
147 + </div>
148 +
149 + <!-- Mobile FAB -->
150 + <button class="sa-fab" @click="router.push({ name: 'TripsCreate' })">
151 + <i class="bi bi-plus-lg"></i>
152 + </button>
153 + </div>
154 +</template>
added src/views/wishlist/CreateView.vue +146 −0
@@ -0,0 +1,146 @@
1 +<script setup lang="ts">
2 +import { ref } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import WishlistService from '@/services/WishlistService'
6 +
7 +const router = useRouter()
8 +const route = useRoute()
9 +const { t } = useI18n()
10 +
11 +const tripId = route.params.tripId as string
12 +
13 +const title = ref('')
14 +const description = ref('')
15 +const category = ref('Place')
16 +const priority = ref('NiceToHave')
17 +const estimatedCost = ref<number | null>(null)
18 +const url = ref('')
19 +const location = ref('')
20 +const errors = ref<string[]>([])
21 +const isSaving = ref(false)
22 +
23 +async function handleSubmit() {
24 + errors.value = []
25 + isSaving.value = true
26 +
27 + const result = await WishlistService.create({
28 + tripId,
29 + title: title.value,
30 + description: description.value || null,
31 + category: category.value,
32 + priority: priority.value,
33 + estimatedCost: estimatedCost.value,
34 + url: url.value || null,
35 + location: location.value || null,
36 + })
37 +
38 + if (result.errors) {
39 + errors.value = result.errors
40 + } else {
41 + router.push({ name: 'WishlistIndex', params: { tripId } })
42 + }
43 +
44 + isSaving.value = false
45 +}
46 +</script>
47 +
48 +<template>
49 + <div class="row justify-content-center">
50 + <div class="col-md-8 col-lg-6">
51 + <h4 class="mb-4" style="color: var(--sa-gray-900)">
52 + <i class="bi bi-plus-circle me-2" style="color: var(--sa-accent)"></i>{{ t('wishlist.create.title') }}
53 + </h4>
54 +
55 + <!-- Error Display -->
56 + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
57 + <div class="sa-card-body">
58 + <div class="d-flex align-items-center mb-1">
59 + <i class="bi bi-exclamation-triangle-fill me-2" style="color: var(--sa-danger)"></i>
60 + <strong style="color: var(--sa-danger)">{{ t('wishlist.create.pleaseFix') }}</strong>
61 + </div>
62 + <div v-for="err in errors" :key="err" style="font-size: 0.875rem; color: var(--sa-gray-600)">
63 + {{ err }}
64 + </div>
65 + </div>
66 + </div>
67 +
68 + <div class="sa-card-static">
69 + <div class="sa-card-body">
70 + <form @submit.prevent="handleSubmit">
71 + <div class="mb-3">
72 + <label for="title" class="form-label">{{ t('wishlist.create.itemTitle') }}</label>
73 + <input id="title" v-model="title" type="text" class="form-control" required :placeholder="t('wishlist.create.titlePlaceholder')" />
74 + </div>
75 +
76 + <div class="mb-3">
77 + <label for="description" class="form-label">{{ t('wishlist.create.description') }}</label>
78 + <textarea id="description" v-model="description" class="form-control" rows="3" :placeholder="t('wishlist.create.descriptionPlaceholder')"></textarea>
79 + </div>
80 +
81 + <div class="row mb-3">
82 + <div class="col-6">
83 + <label for="category" class="form-label">{{ t('wishlist.create.category') }}</label>
84 + <select id="category" v-model="category" class="form-select">
85 + <option value="Place">{{ t('wishlist.category.Place') }}</option>
86 + <option value="Activity">{{ t('wishlist.category.Activity') }}</option>
87 + <option value="Restaurant">{{ t('wishlist.category.Restaurant') }}</option>
88 + <option value="Other">{{ t('wishlist.category.Other') }}</option>
89 + </select>
90 + </div>
91 + <div class="col-6">
92 + <label for="priority" class="form-label">{{ t('wishlist.create.priority') }}</label>
93 + <select id="priority" v-model="priority" class="form-select">
94 + <option value="MustDo">{{ t('wishlist.priority.MustDo') }}</option>
95 + <option value="NiceToHave">{{ t('wishlist.priority.NiceToHave') }}</option>
96 + <option value="Optional">{{ t('wishlist.priority.Optional') }}</option>
97 + </select>
98 + </div>
99 + </div>
100 +
101 + <div class="mb-3">
102 + <label for="estimatedCost" class="form-label">{{ t('wishlist.create.estimatedCost') }}</label>
103 + <input
104 + id="estimatedCost"
105 + v-model.number="estimatedCost"
106 + type="number"
107 + step="0.01"
108 + min="0"
109 + class="form-control"
110 + placeholder="0.00"
111 + />
112 + </div>
113 +
114 + <div class="mb-3">
115 + <label for="url" class="form-label">{{ t('wishlist.create.url') }}</label>
116 + <input id="url" v-model="url" type="url" class="form-control" placeholder="https://..." />
117 + </div>
118 +
119 + <div class="mb-4">
120 + <label for="location" class="form-label">{{ t('wishlist.create.location') }}</label>
121 + <input id="location" v-model="location" type="text" class="form-control" :placeholder="t('wishlist.create.locationPlaceholder')" />
122 + </div>
123 +
124 + <div class="d-flex gap-2 justify-content-end">
125 + <button
126 + type="button"
127 + class="sa-btn sa-btn-ghost"
128 + @click="router.push({ name: 'WishlistIndex', params: { tripId } })"
129 + >
130 + {{ t('common.cancel') }}
131 + </button>
132 + <button
133 + type="submit"
134 + class="sa-btn sa-btn-primary"
135 + :class="{ 'sa-btn-loading': isSaving }"
136 + :disabled="isSaving"
137 + >
138 + {{ isSaving ? t('wishlist.create.saving') : t('wishlist.create.addItem') }}
139 + </button>
140 + </div>
141 + </form>
142 + </div>
143 + </div>
144 + </div>
145 + </div>
146 +</template>
added src/views/wishlist/EditView.vue +192 −0
@@ -0,0 +1,192 @@
1 +<script setup lang="ts">
2 +import { ref, onMounted } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import WishlistService from '@/services/WishlistService'
6 +
7 +const router = useRouter()
8 +const route = useRoute()
9 +const { t } = useI18n()
10 +
11 +const tripId = route.params.tripId as string
12 +const itemId = route.params.id as string
13 +
14 +const title = ref('')
15 +const description = ref('')
16 +const category = ref('Place')
17 +const priority = ref('NiceToHave')
18 +const estimatedCost = ref<number | null>(null)
19 +const url = ref('')
20 +const location = ref('')
21 +const errors = ref<string[]>([])
22 +const isSaving = ref(false)
23 +const isLoading = ref(true)
24 +
25 +onMounted(async () => {
26 + const result = await WishlistService.getByTrip(tripId)
27 + if (result.data) {
28 + const item = result.data.find((i) => i.id === itemId)
29 + if (item) {
30 + title.value = item.title
31 + description.value = item.description ?? ''
32 + category.value = item.category
33 + priority.value = item.priority
34 + estimatedCost.value = item.estimatedCost
35 + url.value = item.url ?? ''
36 + location.value = item.location ?? ''
37 + } else {
38 + errors.value = [t('wishlist.edit.notFound')]
39 + }
40 + } else if (result.errors) {
41 + errors.value = result.errors
42 + }
43 + isLoading.value = false
44 +})
45 +
46 +async function handleSubmit() {
47 + errors.value = []
48 + isSaving.value = true
49 +
50 + const result = await WishlistService.update(itemId, {
51 + tripId,
52 + title: title.value,
53 + description: description.value || null,
54 + category: category.value,
55 + priority: priority.value,
56 + estimatedCost: estimatedCost.value,
57 + url: url.value || null,
58 + location: location.value || null,
59 + })
60 +
61 + if (result.errors) {
62 + errors.value = result.errors
63 + } else {
64 + router.push({ name: 'WishlistIndex', params: { tripId } })
65 + }
66 +
67 + isSaving.value = false
68 +}
69 +
70 +async function handleDelete() {
71 + if (!confirm(t('wishlist.edit.confirmDelete'))) return
72 + const result = await WishlistService.delete(itemId)
73 + if (result.errors) {
74 + errors.value = result.errors
75 + } else {
76 + router.push({ name: 'WishlistIndex', params: { tripId } })
77 + }
78 +}
79 +</script>
80 +
81 +<template>
82 + <div class="row justify-content-center">
83 + <!-- Loading -->
84 + <div v-if="isLoading" class="text-center py-5">
85 + <div class="spinner-border" style="color: var(--sa-primary)" role="status"></div>
86 + </div>
87 +
88 + <div v-else class="col-md-8 col-lg-6">
89 + <h4 class="mb-4" style="color: var(--sa-gray-900)">
90 + <i class="bi bi-pencil-square me-2" style="color: var(--sa-accent)"></i>{{ t('wishlist.edit.title') }}
91 + </h4>
92 +
93 + <!-- Error Display -->
94 + <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4">
95 + <div class="sa-card-body">
96 + <div class="d-flex align-items-center mb-1">
97 + <i class="bi bi-exclamation-triangle-fill me-2" style="color: var(--sa-danger)"></i>
98 + <strong style="color: var(--sa-danger)">{{ t('wishlist.edit.somethingWrong') }}</strong>
99 + </div>
100 + <div v-for="err in errors" :key="err" style="font-size: 0.875rem; color: var(--sa-gray-600)">
101 + {{ err }}
102 + </div>
103 + </div>
104 + </div>
105 +
106 + <div class="sa-card-static">
107 + <div class="sa-card-body">
108 + <form @submit.prevent="handleSubmit">
109 + <div class="mb-3">
110 + <label for="title" class="form-label">{{ t('wishlist.create.itemTitle') }}</label>
111 + <input id="title" v-model="title" type="text" class="form-control" required :placeholder="t('wishlist.create.titlePlaceholder')" />
112 + </div>
113 +
114 + <div class="mb-3">
115 + <label for="description" class="form-label">{{ t('wishlist.create.description') }}</label>
116 + <textarea id="description" v-model="description" class="form-control" rows="3" :placeholder="t('wishlist.create.descriptionPlaceholder')"></textarea>
117 + </div>
118 +
119 + <div class="row mb-3">
120 + <div class="col-6">
121 + <label for="category" class="form-label">{{ t('wishlist.create.category') }}</label>
122 + <select id="category" v-model="category" class="form-select">
123 + <option value="Place">{{ t('wishlist.category.Place') }}</option>
124 + <option value="Activity">{{ t('wishlist.category.Activity') }}</option>
125 + <option value="Restaurant">{{ t('wishlist.category.Restaurant') }}</option>
126 + <option value="Other">{{ t('wishlist.category.Other') }}</option>
127 + </select>
128 + </div>
129 + <div class="col-6">
130 + <label for="priority" class="form-label">{{ t('wishlist.create.priority') }}</label>
131 + <select id="priority" v-model="priority" class="form-select">
132 + <option value="MustDo">{{ t('wishlist.priority.MustDo') }}</option>
133 + <option value="NiceToHave">{{ t('wishlist.priority.NiceToHave') }}</option>
134 + <option value="Optional">{{ t('wishlist.priority.Optional') }}</option>
135 + </select>
136 + </div>
137 + </div>
138 +
139 + <div class="mb-3">
140 + <label for="estimatedCost" class="form-label">{{ t('wishlist.create.estimatedCost') }}</label>
141 + <input
142 + id="estimatedCost"
143 + v-model.number="estimatedCost"
144 + type="number"
145 + step="0.01"
146 + min="0"
147 + class="form-control"
148 + placeholder="0.00"
149 + />
150 + </div>
151 +
152 + <div class="mb-3">
153 + <label for="url" class="form-label">{{ t('wishlist.create.url') }}</label>
154 + <input id="url" v-model="url" type="url" class="form-control" placeholder="https://..." />
155 + </div>
156 +
157 + <div class="mb-4">
158 + <label for="location" class="form-label">{{ t('wishlist.create.location') }}</label>
159 + <input id="location" v-model="location" type="text" class="form-control" :placeholder="t('wishlist.create.locationPlaceholder')" />
160 + </div>
161 +
162 + <div class="d-flex gap-2">
163 + <button
164 + type="button"
165 + class="sa-btn sa-btn-danger sa-btn-sm"
166 + @click="handleDelete"
167 + >
168 + <i class="bi bi-trash3"></i> {{ t('common.delete') }}
169 + </button>
170 + <div class="flex-grow-1"></div>
171 + <button
172 + type="button"
173 + class="sa-btn sa-btn-ghost"
174 + @click="router.push({ name: 'WishlistIndex', params: { tripId } })"
175 + >
176 + {{ t('common.cancel') }}
177 + </button>
178 + <button
179 + type="submit"
180 + class="sa-btn sa-btn-primary"
181 + :class="{ 'sa-btn-loading': isSaving }"
182 + :disabled="isSaving"
183 + >
184 + {{ isSaving ? t('wishlist.create.saving') : t('common.saveChanges') }}
185 + </button>
186 + </div>
187 + </form>
188 + </div>
189 + </div>
190 + </div>
191 + </div>
192 +</template>
added src/views/wishlist/IndexView.vue +286 −0
@@ -0,0 +1,286 @@
1 +<script setup lang="ts">
2 +import { ref, onMounted, computed, inject, type ComputedRef } from 'vue'
3 +import { useRouter, useRoute } from 'vue-router'
4 +import { useI18n } from 'vue-i18n'
5 +import WishlistService from '@/services/WishlistService'
6 +import type { IWishlistItem } from '@/types/IWishlist'
7 +import { useToast } from '@/composables/useToast'
8 +import { formatCurrency } from '@/utils/formatCurrency'
9 +
10 +const { t } = useI18n()
11 +
12 +const _tripCurrencySymbol = inject<ComputedRef<string>>('tripCurrencySymbol')
13 +const tripCurrencySymbol = computed(() => _tripCurrencySymbol?.value ?? '')
14 +
15 +const _currentUserId = inject<ComputedRef<string | null>>('currentUserId')
16 +const currentUserId = computed(() => _currentUserId?.value ?? null)
17 +
18 +const router = useRouter()
19 +const route = useRoute()
20 +const toast = useToast()
21 +
22 +const items = ref<IWishlistItem[]>([])
23 +const isLoading = ref(true)
24 +const error = ref<string | null>(null)
25 +
26 +const tripId = route.params.tripId as string
27 +
28 +const sortedItems = computed(() =>
29 + [...items.value].sort((a, b) => {
30 + if (a.isCompleted !== b.isCompleted) return a.isCompleted ? 1 : -1
31 + return b.voteCount - a.voteCount
32 + }),
33 +)
34 +
35 +onMounted(async () => {
36 + await loadItems()
37 +})
38 +
39 +async function loadItems() {
40 + isLoading.value = true
41 + const result = await WishlistService.getByTrip(tripId)
42 + if (result.data) {
43 + items.value = result.data
44 + } else if (result.errors) {
45 + error.value = result.errors.join(', ')
46 + }
47 + isLoading.value = false
48 +}
49 +
50 +async function toggleVote(item: IWishlistItem) {
51 + error.value = null
52 + const result = await WishlistService.vote(item.id)
53 + if (result.errors) {
54 + toast.error(result.errors.join(', '))
55 + } else {
56 + await loadItems()
57 + }
58 +}
59 +
60 +async function markComplete(id: string) {
61 + error.value = null
62 + const result = await WishlistService.complete(id)
63 + if (result.errors) {
64 + toast.error(result.errors.join(', '))
65 + } else {
66 + toast.success(t('wishlist.index.completed'))
67 + await loadItems()
68 + }
69 +}
70 +
71 +async function markUncomplete(id: string) {
72 + error.value = null
73 + const result = await WishlistService.complete(id)
74 + if (result.errors) {
75 + toast.error(result.errors.join(', '))
76 + } else {
77 + toast.success(t('wishlist.index.uncompleted'))
78 + await loadItems()
79 + }
80 +}
81 +
82 +async function deleteItem(id: string) {
83 + if (!confirm(t('wishlist.index.confirmDelete'))) return
84 + error.value = null
85 + const result = await WishlistService.delete(id)
86 + if (result.errors) {
87 + toast.error(result.errors.join(', '))
88 + } else {
89 + items.value = items.value.filter((i) => i.id !== id)
90 + toast.success(t('wishlist.index.deleted'))
91 + }
92 +}
93 +
94 +function categoryStripeStyle(category: string): Record<string, string> {
95 + switch (category) {
96 + case 'Place':
97 + return { height: '4px', background: 'linear-gradient(90deg, #3b82f6, #2176ae)' }
98 + case 'Activity':
99 + return { height: '4px', background: 'linear-gradient(90deg, #22c55e, #16a34a)' }
100 + case 'Restaurant':
101 + return { height: '4px', background: 'linear-gradient(90deg, #f97316, #ea580c)' }
102 + default:
103 + return { height: '4px', background: 'linear-gradient(90deg, var(--sa-gray-300), var(--sa-gray-400))' }
104 + }
105 +}
106 +
107 +function categoryBadgeClass(category: string): string {
108 + switch (category) {
109 + case 'Place': return 'sa-badge-info'
110 + case 'Activity': return 'sa-badge-success'
111 + case 'Restaurant': return 'sa-badge-accent'
112 + default: return 'sa-badge-neutral'
113 + }
114 +}
115 +
116 +function priorityBadgeClass(priority: string): string {
117 + return priority === 'MustDo' ? 'sa-badge-danger' : 'sa-badge-info'
118 +}
119 +
120 +function categoryLabel(category: string): string {
121 + const key = `wishlist.category.${category}`
122 + const translated = t(key)
123 + return translated === key ? category : translated
124 +}
125 +
126 +function priorityLabel(priority: string): string {
127 + const key = `wishlist.priority.${priority}`
128 + const translated = t(key)
129 + return translated === key ? priority : translated
130 +}
131 +</script>
132 +
133 +<template>
134 + <div>
135 + <!-- Gradient Header -->
136 + <div class="sa-gradient-header sa-gradient-header-accent d-flex justify-content-between align-items-center">
137 + <div>
138 + <h3 class="mb-1"><i class="bi bi-stars me-2"></i>{{ t('wishlist.index.title') }}</h3>
139 + <p v-if="!isLoading && items.length > 0" class="mb-0 text-muted" style="font-size: 0.9rem">
140 + {{ t('wishlist.index.itemCount', { n: items.length }, items.length) }}
141 + </p>
142 + </div>
143 + <button
144 + class="sa-btn sa-btn-pill sa-hide-mobile"
145 + style="background: #fff; color: var(--sa-accent-dark)"
146 + @click="router.push({ name: 'WishlistCreate', params: { tripId } })"
147 + >
148 + <i class="bi bi-plus-lg"></i> {{ t('wishlist.index.addItem') }}
149 + </button>
150 + </div>
151 +
152 + <div v-if="error" class="alert alert-danger">{{ error }}</div>
153 +
154 + <!-- Loading -->
155 + <div v-if="isLoading" class="text-center py-5">
156 + <div class="spinner-border" style="color: var(--sa-accent)" role="status"></div>
157 + </div>
158 +
159 + <!-- Empty State -->
160 + <div v-else-if="sortedItems.length === 0" class="sa-empty">
161 + <div class="sa-empty-icon"><i class="bi bi-stars"></i></div>
162 + <div class="sa-empty-title">{{ t('wishlist.index.emptyTitle') }}</div>
163 + <div class="sa-empty-text">{{ t('wishlist.index.emptyText') }}</div>
164 + <button
165 + class="sa-btn sa-btn-primary sa-btn-pill"
166 + @click="router.push({ name: 'WishlistCreate', params: { tripId } })"
167 + >
168 + <i class="bi bi-plus-lg"></i> {{ t('wishlist.index.addItem') }}
169 + </button>
170 + </div>
171 +
172 + <!-- Card Grid -->
173 + <div v-else class="row g-4">
174 + <div v-for="item in sortedItems" :key="item.id" class="col-md-6 col-lg-4">
175 + <div class="sa-card d-flex flex-column h-100">
176 + <!-- Category Color Stripe -->
177 + <div :style="categoryStripeStyle(item.category)"></div>
178 +
179 + <!-- Card Body -->
180 + <div class="sa-card-body flex-grow-1">
181 + <h5
182 + class="mb-2"
183 + :style="item.isCompleted ? 'text-decoration: line-through; opacity: 0.6' : ''"
184 + >
185 + {{ item.title }}
186 + </h5>
187 +
188 + <p
189 + v-if="item.description"
190 + class="mb-3"
191 + style="font-size: 0.875rem; color: var(--sa-gray-500)"
192 + :style="item.isCompleted ? 'opacity: 0.6' : ''"
193 + >
194 + {{ item.description }}
195 + </p>
196 +
197 + <!-- Badges -->
198 + <div class="d-flex flex-wrap gap-1 mb-3">
199 + <span class="sa-badge" :class="categoryBadgeClass(item.category)">
200 + {{ categoryLabel(item.category) }}
201 + </span>
202 + <span class="sa-badge" :class="priorityBadgeClass(item.priority)">
203 + {{ priorityLabel(item.priority) }}
204 + </span>
205 + <span v-if="item.estimatedCost" class="sa-badge sa-badge-neutral">
206 + {{ formatCurrency(item.estimatedCost, tripCurrencySymbol) }}
207 + </span>
208 + </div>
209 +
210 + <!-- Location -->
211 + <div v-if="item.location" class="mb-1" style="font-size: 0.813rem; color: var(--sa-gray-500)">
212 + <i class="bi bi-geo-alt me-1"></i>{{ item.location }}
213 + </div>
214 +
215 + <!-- URL -->
216 + <div v-if="item.url" class="mb-2" style="font-size: 0.813rem">
217 + <i class="bi bi-link-45deg me-1" style="color: var(--sa-gray-400)"></i>
218 + <a :href="item.url" target="_blank" rel="noopener noreferrer" style="color: var(--sa-secondary)">
219 + {{ item.url.replace(/^https?:\/\//, '').substring(0, 30) }}{{ item.url.replace(/^https?:\/\//, '').length > 30 ? '...' : '' }}
220 + </a>
221 + </div>
222 +
223 + <!-- Added by -->
224 + <small style="color: var(--sa-gray-400); font-size: 0.75rem">
225 + {{ t('wishlist.index.addedBy', { name: item.addedByUserName || t('common.unknown') }) }}
226 + </small>
227 + </div>
228 +
229 + <!-- Card Footer -->
230 + <div class="sa-card-footer d-flex align-items-center justify-content-between">
231 + <button
232 + class="sa-vote-btn"
233 + :class="{ 'sa-vote-btn-active': item.userHasVoted }"
234 + @click="toggleVote(item)"
235 + >
236 + <i :class="item.userHasVoted ? 'bi bi-heart-fill' : 'bi bi-heart'"></i>
237 + {{ item.voteCount }}
238 + </button>
239 + <div class="d-flex gap-1">
240 + <button
241 + v-if="!item.isCompleted"
242 + class="sa-btn sa-btn-success sa-btn-sm"
243 + @click="markComplete(item.id)"
244 + >
245 + {{ t('wishlist.index.complete') }}
246 + </button>
247 + <button
248 + v-if="item.isCompleted"
249 + class="sa-btn sa-btn-ghost sa-btn-sm"
250 + @click="markUncomplete(item.id)"
251 + >
252 + {{ t('wishlist.index.undo') }}
253 + </button>
254 + <button
255 + v-if="currentUserId === item.addedByUserId"
256 + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
257 + :title="t('common.edit')"
258 + @click="router.push({ name: 'WishlistEdit', params: { tripId, id: item.id } })"
259 + >
260 + <i class="bi bi-pencil"></i>
261 + </button>
262 + <button
263 + v-if="currentUserId === item.addedByUserId"
264 + class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm"
265 + style="color: var(--sa-danger)"
266 + :title="t('common.delete')"
267 + @click="deleteItem(item.id)"
268 + >
269 + <i class="bi bi-trash3"></i>
270 + </button>
271 + </div>
272 + </div>
273 + </div>
274 + </div>
275 + </div>
276 +
277 + <!-- Mobile FAB -->
278 + <button
279 + class="sa-fab"
280 + style="background: linear-gradient(135deg, var(--sa-accent) 0%, #f97316 100%); box-shadow: 0 4px 16px rgba(244, 166, 35, 0.4)"
281 + @click="router.push({ name: 'WishlistCreate', params: { tripId } })"
282 + >
283 + <i class="bi bi-plus-lg"></i>
284 + </button>
285 + </div>
286 +</template>
added tsconfig.app.json +18 −0
@@ -0,0 +1,18 @@
1 +{
2 + "extends": "@vue/tsconfig/tsconfig.dom.json",
3 + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
4 + "exclude": ["src/**/__tests__/**/*"],
5 + "compilerOptions": {
6 + // Extra safety for array and object lookups, but may have false positives.
7 + "noUncheckedIndexedAccess": true,
8 +
9 + // Path mapping for cleaner imports.
10 + "paths": {
11 + "@/*": ["./src/*"]
12 + },
13 +
14 + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
15 + // Specified here to keep it out of the root directory.
16 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
17 + }
18 +}
added tsconfig.json +14 −0
@@ -0,0 +1,14 @@
1 +{
2 + "files": [],
3 + "references": [
4 + {
5 + "path": "./tsconfig.node.json"
6 + },
7 + {
8 + "path": "./tsconfig.app.json"
9 + },
10 + {
11 + "path": "./tsconfig.vitest.json"
12 + }
13 + ]
14 +}
added tsconfig.node.json +27 −0
@@ -0,0 +1,27 @@
1 +// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
2 +{
3 + "extends": "@tsconfig/node24/tsconfig.json",
4 + "include": [
5 + "vite.config.*",
6 + "vitest.config.*",
7 + "cypress.config.*",
8 + "playwright.config.*",
9 + "eslint.config.*"
10 + ],
11 + "compilerOptions": {
12 + // Most tools use transpilation instead of Node.js's native type-stripping.
13 + // Bundler mode provides a smoother developer experience.
14 + "module": "preserve",
15 + "moduleResolution": "bundler",
16 +
17 + // Include Node.js types and avoid accidentally including other `@types/*` packages.
18 + "types": ["node"],
19 +
20 + // Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
21 + "noEmit": true,
22 +
23 + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
24 + // Specified here to keep it out of the root directory.
25 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
26 + }
27 +}
added tsconfig.vitest.json +19 −0
@@ -0,0 +1,19 @@
1 +{
2 + "extends": "./tsconfig.app.json",
3 +
4 + // Override to include only test files and clear exclusions.
5 + // Application code imported in tests is automatically included via module resolution.
6 + "include": ["src/**/__tests__/**/*", "env.d.ts"],
7 + "exclude": [],
8 +
9 + "compilerOptions": {
10 + // Vitest runs in a different environment than the application code.
11 + // Adjust lib and types accordingly.
12 + "lib": [],
13 + "types": ["node", "jsdom"],
14 +
15 + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
16 + // Specified here to keep it out of the root directory.
17 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
18 + }
19 +}
added vite.config.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { fileURLToPath, URL } from 'node:url'
2 +
3 +import { defineConfig } from 'vite'
4 +import vue from '@vitejs/plugin-vue'
5 +import vueDevTools from 'vite-plugin-vue-devtools'
6 +
7 +// https://vite.dev/config/
8 +export default defineConfig({
9 + plugins: [
10 + vue(),
11 + vueDevTools(),
12 + ],
13 + resolve: {
14 + alias: {
15 + '@': fileURLToPath(new URL('./src', import.meta.url))
16 + },
17 + },
18 +})
added vitest.config.ts +15 −0
@@ -0,0 +1,15 @@
1 +import { fileURLToPath } from 'node:url'
2 +import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
3 +import viteConfig from './vite.config'
4 +
5 +export default mergeConfig(
6 + viteConfig,
7 + defineConfig({
8 + test: {
9 + environment: 'jsdom',
10 + exclude: [...configDefaults.exclude, 'e2e/**'],
11 + root: fileURLToPath(new URL('./', import.meta.url)),
12 + setupFiles: ['./src/__tests__/vitest.setup.ts'],
13 + },
14 + }),
15 +)