diff --git a/frontend/.gitignore b/frontend/.gitignore index 854acd5..aa3ce66 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -38,6 +38,8 @@ yarn-error.log testem.log /typings __screenshots__/ +/playwright-report +/test-results # System files .DS_Store diff --git a/frontend/README.md b/frontend/README.md index 2588dcc..0c18753 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -7,7 +7,7 @@ This project was generated using [Angular CLI](https://github.com/angular/angula To start a local development server, run: ```bash -ng serve +bun run start ``` Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. @@ -41,7 +41,7 @@ This will compile your project and store the build artifacts in the `dist/` dire To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: ```bash -ng test +bun run test -- --watch=false ``` ## Running end-to-end tests @@ -49,10 +49,12 @@ ng test For end-to-end (e2e) testing, run: ```bash -ng e2e +bunx playwright install chromium +bun run e2e ``` -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. +The Playwright suite starts the Angular development server automatically and runs against the +360 px mobile, 768 px tablet, and 1280 px desktop projects defined in `playwright.config.ts`. ## Additional Resources diff --git a/frontend/angular.json b/frontend/angular.json index 20b03df..711e6d7 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -2,7 +2,7 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm", + "packageManager": "bun", "schematicCollections": ["angular-eslint"] }, "newProjectRoot": "projects", @@ -25,7 +25,7 @@ "input": "public" } ], - "styles": ["src/styles.css"] + "styles": ["src/styles.scss"] }, "configurations": { "production": { @@ -37,7 +37,7 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "10kB", + "maximumWarning": "15kB", "maximumError": "20kB" } ], diff --git a/frontend/bun.lock b/frontend/bun.lock index 8f15823..e976174 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -5,10 +5,12 @@ "": { "name": "lmnp-frontend", "dependencies": { + "@angular/cdk": "^22.0.0", "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", "@angular/core": "^22.0.0", "@angular/forms": "^22.0.0", + "@angular/material": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", "rxjs": "~7.8.0", @@ -19,6 +21,8 @@ "@angular/cli": "^22.0.6", "@angular/compiler-cli": "^22.0.0", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.61.1", + "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.1.10", "angular-eslint": "22.1.0", "eslint": "^10.6.0", @@ -85,6 +89,8 @@ "@angular/build": ["@angular/build@22.0.6", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2200.6", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "6.0.12", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.4.2", "browserslist": "^4.26.0", "esbuild": "0.28.1", "https-proxy-agent": "9.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "magic-string": "0.30.21", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.4", "piscina": "5.2.0", "rollup": "4.60.2", "sass": "1.99.0", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.16", "vite": "7.3.5", "watchpack": "2.5.1" }, "optionalDependencies": { "lmdb": "3.5.4" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.0.6", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "tailwindcss", "vitest"] }, "sha512-mIp1snH1haw4pKiJwUmKyp9f31NRUTeUwqrl9W59X3Iv54egOnD1EdCc0jsW7WJVr/JHRZYVt/ooFelUB8uV8g=="], + "@angular/cdk": ["@angular/cdk@22.0.4", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-XaH1ff9tOn4acPh0zWbrmJznfZqZ0KZkw5+DM5BIeM9URmAOI8xdZWmVNyaUpVSmriANFjeGZS+Sfat9S6hLOg=="], + "@angular/cli": ["@angular/cli@22.0.6", "", { "dependencies": { "@angular-devkit/architect": "0.2200.6", "@angular-devkit/core": "22.0.6", "@angular-devkit/schematics": "22.0.6", "@inquirer/prompts": "8.4.2", "@listr2/prompt-adapter-inquirer": "4.2.3", "@modelcontextprotocol/sdk": "1.29.0", "@schematics/angular": "22.0.6", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.52.0", "ini": "6.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "npm-package-arg": "13.0.2", "pacote": "21.5.1", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.7.4", "yargs": "18.0.0", "zod": "4.4.2" }, "bin": { "ng": "bin/ng.js" } }, "sha512-/sEN1hVcywSML+yWrr/T7gbuGfIrZwKC+WhiOv5GksSQVUrSujWnMX4meRJ5ocG/Ie8UN48LJ7LVDsm32NBt1A=="], "@angular/common": ["@angular/common@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-+sC0W0FWGeZ/HKUhueCBX8kXdFQDlStbUSK6WF/32igfNYR/PdUrp2L5bU7q654yjxnMzFWDAB+Uvzv6YI/cQg=="], @@ -97,6 +103,8 @@ "@angular/forms": ["@angular/forms@22.0.6", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0", "zod": "^4.0.10" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-pZGMk1TvT+m8z2ZGm90W0sBlo6fhtMAA2baZbMt3HbGRmY23vZv1Orv9npX3Z6a9CCNDY3XBMBvkuj8g7vde9Q=="], + "@angular/material": ["@angular/material@22.0.4", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/cdk": "22.0.4", "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-htOFlTwMtcB9J4q6yPD9jJIQVJ5LfRub+KAiD4D7UU1iaCknpNuQKRfLrZh/mEdOVACI52jHAOXS3+eTkloXcQ=="], + "@angular/platform-browser": ["@angular/platform-browser@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "22.0.6", "@angular/common": "22.0.6", "@angular/core": "22.0.6" }, "optionalPeers": ["@angular/animations"] }, "sha512-DyyYRx4jXlfaenf0mwsp9jcIak/c17QCFgJKA7OauSkQnk3dL+voMYEwdigfnTIUdw+2Y9wYDywLeuM6NychNQ=="], "@angular/router": ["@angular/router@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-s0BErBBadCXlvilob8aWke4H1FRlXDbnDfyYkjW6RkUaGQ4jzeEAzH56KsG2hf8NtBtYwkRlsLfOX9s5sPwm6w=="], @@ -413,6 +421,8 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], @@ -527,6 +537,8 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="], @@ -1071,6 +1083,10 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], "postcss-media-query-parser": ["postcss-media-query-parser@0.2.3", "", {}, "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig=="], @@ -1219,6 +1235,8 @@ "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -1377,6 +1395,8 @@ "ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts new file mode 100644 index 0000000..4cd65bc --- /dev/null +++ b/frontend/e2e/app.spec.ts @@ -0,0 +1,197 @@ +import { expect, Page, test } from '@playwright/test'; + +async function mockUser(page: Page, authenticated: boolean): Promise { + await page.unroute('**/api/user'); + await page.route('**/api/user', async (route) => { + await route.fulfill({ + contentType: 'application/json', + json: authenticated + ? { authenticated: true, username: 'Alice', email: 'alice@example.com' } + : { authenticated: false, username: '', email: '' }, + }); + }); +} + +async function mockEmptyApartmentList(page: Page): Promise { + await page.route('**/api/apartments', async (route) => { + await route.fulfill({ contentType: 'application/json', json: [] }); + }); +} + +async function expectNoHorizontalOverflow(page: Page): Promise { + const hasHorizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth, + ); + expect(hasHorizontalOverflow).toBe(false); +} + +test.beforeEach(async ({ page }) => { + await mockUser(page, false); +}); + +test('renders the sign-in experience without horizontal overflow', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'LMNP Organizer' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Se connecter avec OIDC' })).toBeVisible(); + + await expectNoHorizontalOverflow(page); +}); + +test('starts the OIDC login flow from the sign-in screen', async ({ page }) => { + await page.route('**/oauth2/authorization/oidc', async (route) => { + await route.fulfill({ contentType: 'text/html', body: '

OIDC provider

' }); + }); + + await page.goto('/'); + await page.getByRole('button', { name: 'Se connecter avec OIDC' }).click(); + + await expect(page).toHaveURL(/\/oauth2\/authorization\/oidc$/); +}); + +test('persists a dark appearance preference from Settings', async ({ page }) => { + await mockUser(page, true); + await mockEmptyApartmentList(page); + + await page.goto('/'); + await page.getByRole('button', { name: 'Réglages' }).click(); + await page.getByRole('radio', { name: 'Sombre' }).check(); + + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + expect(await page.evaluate(() => localStorage.getItem('lmnp-theme'))).toBe('dark'); + + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await page.getByRole('button', { name: 'Réglages' }).click(); + await expect(page.getByRole('radio', { name: 'Sombre' })).toBeChecked(); +}); + +test('adapts authenticated navigation to the viewport', async ({ page }, testInfo) => { + await mockUser(page, true); + await mockEmptyApartmentList(page); + + await page.goto('/'); + + const navigation = page.getByTestId('primary-navigation'); + await expect(navigation).toBeVisible(); + + const navigationBox = await navigation.boundingBox(); + const viewport = page.viewportSize(); + expect(navigationBox).not.toBeNull(); + expect(viewport).not.toBeNull(); + if (testInfo.project.name === 'desktop') { + expect(navigationBox?.x).toBe(0); + expect(navigationBox?.height).toBe(viewport?.height); + } else { + expect(Math.round((navigationBox?.y ?? 0) + (navigationBox?.height ?? 0))).toBe( + viewport?.height, + ); + } + + await navigation.getByRole('button', { name: 'Appartements' }).click(); + await expect(page.getByRole('heading', { name: 'Mes appartements' })).toBeVisible(); + + await expectNoHorizontalOverflow(page); +}); + +test('keeps apartment add and edit workflows usable', async ({ page }) => { + const apartments = [ + { + id: 'apt-1', + name: 'T2 République', + address: '12 rue de la République, Lyon', + purchasePrice: 185000, + description: 'Deuxième étage', + }, + ]; + + await mockUser(page, true); + await page.route('**/api/apartments**', async (route) => { + const request = route.request(); + const method = request.method(); + const id = new URL(request.url()).pathname.split('/').at(-1); + + if (method === 'POST') { + const apartment = { id: 'apt-2', ...request.postDataJSON() }; + apartments.push(apartment); + await route.fulfill({ contentType: 'application/json', json: apartment }); + return; + } + + if (method === 'PUT') { + const index = apartments.findIndex((apartment) => apartment.id === id); + apartments[index] = { id, ...request.postDataJSON() }; + await route.fulfill({ contentType: 'application/json', json: apartments[index] }); + return; + } + + if (method === 'DELETE') { + const index = apartments.findIndex((apartment) => apartment.id === id); + apartments.splice(index, 1); + await route.fulfill({ status: 204 }); + return; + } + + await route.fulfill({ contentType: 'application/json', json: apartments }); + }); + + await page.goto('/'); + await page + .getByTestId('primary-navigation') + .getByRole('button', { name: 'Appartements' }) + .click(); + await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible(); + + await page.getByRole('button', { name: 'Modifier' }).click(); + await page.getByLabel("Nom de l'appartement").fill('T2 République rénové'); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeVisible(); + + await page.getByRole('button', { name: 'Ajouter un appartement' }).click(); + await page.getByLabel("Nom de l'appartement").fill('Studio Croix-Rousse'); + await page.getByLabel('Adresse complète').fill('8 montée de la Boucle, Lyon'); + await page.getByLabel("Prix d'acquisition").fill('125000'); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + await expect(page.getByRole('heading', { name: 'Studio Croix-Rousse' })).toBeVisible(); + + page.once('dialog', (dialog) => dialog.accept()); + const renovatedApartment = page + .getByRole('article') + .filter({ has: page.getByRole('heading', { name: 'T2 République rénové' }) }); + await renovatedApartment.getByRole('button', { name: 'Supprimer' }).click(); + await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeHidden(); + + await expectNoHorizontalOverflow(page); +}); + +test('keeps the secured API check and logout available', async ({ page }) => { + await mockUser(page, true); + await mockEmptyApartmentList(page); + await page.route('**/api/hello', async (route) => { + await route.fulfill({ contentType: 'text/plain', body: 'Service disponible' }); + }); + await page.route('**/logout', async (route) => { + await route.fulfill({ contentType: 'text/html', body: '

Signed out

' }); + }); + + await page.goto('/'); + await page.getByRole('button', { name: 'Tester la connexion' }).click(); + await expect(page.getByText('Service disponible')).toBeVisible(); + + await page.getByTestId('primary-navigation').getByRole('button', { name: 'Réglages' }).click(); + await page.getByTestId('session-settings').getByRole('button', { name: 'Déconnexion' }).click(); + await expect(page).toHaveURL(/\/logout$/); +}); + +test('follows operating-system changes for the System theme', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('lmnp-theme', 'system')); + await page.emulateMedia({ colorScheme: 'dark' }); + await mockUser(page, true); + await mockEmptyApartmentList(page); + + await page.goto('/'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + await page.emulateMedia({ colorScheme: 'light' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); +}); diff --git a/frontend/package.json b/frontend/package.json index e30bc50..3e5723a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,15 +7,20 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", + "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.e2e.json --noEmit", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", "lint": "ng lint" }, "private": true, - "packageManager": "npm@12.0.1", + "packageManager": "bun@1.3.14", "dependencies": { + "@angular/cdk": "^22.0.0", "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", "@angular/core": "^22.0.0", "@angular/forms": "^22.0.0", + "@angular/material": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", "rxjs": "~7.8.0", @@ -26,6 +31,8 @@ "@angular/cli": "^22.0.6", "@angular/compiler-cli": "^22.0.0", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.61.1", + "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.1.10", "angular-eslint": "22.1.0", "eslint": "^10.6.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..eacdbb8 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env['CI']), + retries: process.env['CI'] ? 2 : 0, + workers: process.env['CI'] ? 1 : undefined, + reporter: process.env['CI'] ? 'line' : 'html', + use: { + baseURL: 'http://127.0.0.1:4200', + screenshot: 'only-on-failure', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'mobile', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 360, height: 800 }, + }, + }, + { + name: 'tablet', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 768, height: 1024 }, + }, + }, + { + name: 'desktop', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 800 }, + }, + }, + ], + webServer: { + command: 'bun run start -- --host 127.0.0.1 --port 4200', + url: 'http://127.0.0.1:4200', + reuseExistingServer: !process.env['CI'], + timeout: 120_000, + }, +}); diff --git a/frontend/src/app/app.css b/frontend/src/app/app.css index 9e38d61..0f77fda 100644 --- a/frontend/src/app/app.css +++ b/frontend/src/app/app.css @@ -1,668 +1,1007 @@ -@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap'); - :host { display: block; - font-family: 'Outfit', sans-serif; - --bg-primary: #0a0b10; - --bg-card: rgba(18, 20, 29, 0.6); - --border-card: rgba(255, 255, 255, 0.08); - --color-primary: #6366f1; - --color-primary-glow: rgba(99, 102, 241, 0.3); - --color-secondary: #ec4899; - --color-text: #f3f4f6; - --color-text-muted: #9ca3af; - --color-success: #10b981; + min-height: 100vh; + color: var(--text); } -.app-container { +svg { + display: block; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +.brand-mark { + display: grid; + width: 42px; + height: 42px; + flex: 0 0 42px; + place-items: center; + border-radius: 14px; + background: var(--accent); + color: var(--accent-contrast); +} + +.primary-action.mat-mdc-unelevated-button { + --mdc-filled-button-container-color: var(--accent); + --mdc-filled-button-label-text-color: var(--accent-contrast); + min-height: 44px; + border-radius: 13px; + padding-inline: 20px; + font-weight: 700; +} + +.eyebrow, +.context-label, +.story-kicker, +.property-label { + margin-bottom: 8px; + color: var(--accent); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +/* Sign in */ +.sign-in-shell { min-height: 100vh; - background-color: var(--bg-primary); - color: var(--color-text); + background: var(--canvas); +} + +.sign-in-panel { + display: flex; + min-height: 100vh; + flex-direction: column; + justify-content: center; + padding: 32px 24px; +} + +.brand-lockup { display: flex; align-items: center; - justify-content: center; - padding: 2rem; - position: relative; - overflow: hidden; + gap: 13px; + margin-bottom: clamp(64px, 14vh, 120px); } -.background-decorations { - position: absolute; - top: 0; - left: 0; +.brand-lockup h1 { + margin-bottom: 2px; + font-size: 1.02rem; + font-weight: 800; + letter-spacing: -0.02em; +} + +.brand-lockup p { + margin-bottom: 0; + color: var(--text-muted); + font-size: 0.76rem; +} + +.sign-in-copy { + max-width: 510px; +} + +.sign-in-copy h2 { + max-width: 480px; + margin-bottom: 18px; + font-size: clamp(2.35rem, 9vw, 4.8rem); + font-weight: 750; + letter-spacing: -0.065em; + line-height: 0.98; +} + +.sign-in-copy > p { + max-width: 470px; + margin-bottom: 30px; + color: var(--text-muted); + font-size: 1rem; + line-height: 1.7; +} + +.sign-in-action { + width: 100%; + max-width: 360px; +} + +.sign-in-action svg { + width: 19px; + height: 19px; + margin-left: 8px; +} + +.security-note { + display: flex; + align-items: center; + gap: 8px; + margin: 17px 0 0; + color: var(--text-subtle); + font-size: 0.78rem; +} + +.security-note span { + color: var(--accent); + font-size: 0.55rem; +} + +.sign-in-story { + display: none; +} + +/* App shell and navigation */ +.workspace-shell { + min-height: 100vh; + background: var(--canvas); +} + +.app-navigation { + position: fixed; + z-index: 20; right: 0; bottom: 0; - pointer-events: none; - z-index: 0; + left: 0; + height: calc(72px + env(safe-area-inset-bottom)); + padding: 6px 10px env(safe-area-inset-bottom); + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: 0 -10px 30px rgba(25, 40, 35, 0.06); + backdrop-filter: blur(18px); } -.blob { - position: absolute; +.sidebar-brand, +.sidebar-user { + display: none; +} + +.navigation-items { + display: grid; + height: 100%; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; +} + +.navigation-item.mat-mdc-button { + --mdc-text-button-label-text-color: var(--text-muted); + min-width: 0; + height: 60px; + border-radius: 14px; + padding: 5px 4px; + font-weight: 650; + letter-spacing: 0; +} + +.navigation-item .mat-mdc-button-touch-target { + height: 60px; +} + +.navigation-item svg { + width: 21px; + height: 21px; + margin: 0 auto 4px; +} + +.navigation-item span { + display: block; + overflow: hidden; + font-size: 0.66rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.navigation-item.active { + --mdc-text-button-label-text-color: var(--accent); + background: var(--surface-accent); +} + +.workspace-main { + min-width: 0; + min-height: 100vh; +} + +.mobile-header { + display: flex; + height: 66px; + align-items: center; + justify-content: space-between; + padding: 0 20px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.mobile-brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.92rem; +} + +.mobile-brand .brand-mark { + width: 34px; + height: 34px; + flex-basis: 34px; + border-radius: 11px; +} + +.compact-avatar, +.large-avatar { + display: grid; + place-items: center; border-radius: 50%; - filter: blur(80px); - opacity: 0.15; - transition: all 1s ease-in-out; + background: var(--surface-accent); + color: var(--accent); + font-weight: 800; } -.blob-1 { - width: 400px; - height: 400px; - background: var(--color-primary); - top: -10%; - right: 10%; +.compact-avatar { + width: 34px; + height: 34px; + flex: 0 0 34px; + font-size: 0.82rem; } -.blob-2 { - width: 500px; - height: 500px; - background: var(--color-secondary); - bottom: -10%; - left: -5%; -} - -.main-card { - position: relative; - z-index: 1; +.page-container { width: 100%; - max-width: 480px; - background: var(--bg-card); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border: 1px solid var(--border-card); - border-radius: 24px; - padding: 3rem 2.5rem 2rem; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); - text-align: center; + max-width: 1240px; + margin: 0 auto; + padding: 30px 20px 112px; } -.card-header { - margin-bottom: 2.5rem; +.page-section { + animation: page-in 180ms ease-out; } -.logo-wrapper { - display: inline-flex; - align-items: center; - justify-content: center; - width: 64px; - height: 64px; - border-radius: 16px; - background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); - margin-bottom: 1.5rem; - box-shadow: 0 8px 16px var(--color-primary-glow); +@keyframes page-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.logo-icon { - width: 32px; - height: 32px; - color: white; +.page-heading { + margin-bottom: 30px; } -h1 { - font-size: 2rem; - font-weight: 700; - margin: 0 0 0.5rem; - letter-spacing: -0.5px; - background: linear-gradient(to right, #ffffff, #d1d5db); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; +.page-heading h1 { + margin-bottom: 8px; + font-size: clamp(2rem, 7vw, 3.2rem); + font-weight: 750; + letter-spacing: -0.055em; + line-height: 1.02; } -.subtitle { - color: var(--color-text-muted); - font-size: 0.95rem; - margin: 0; - line-height: 1.5; -} - -.auth-state { - display: flex; - flex-direction: column; - align-items: center; -} - -.shield-icon { - width: 80px; - height: 80px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.05); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 1.5rem; - color: var(--color-primary); -} - -.shield-icon svg { - width: 36px; - height: 36px; -} - -.auth-state h2 { - font-size: 1.25rem; - font-weight: 600; - margin: 0 0 0.75rem; -} - -.auth-state p { - color: var(--color-text-muted); - font-size: 0.9rem; +.page-heading > div > p:last-child, +.page-heading > p { + margin-bottom: 0; + color: var(--text-muted); line-height: 1.6; - margin: 0 0 2rem; } -.btn { - font-family: inherit; - font-weight: 600; - font-size: 0.95rem; - padding: 0.85rem 1.75rem; - border-radius: 12px; - border: none; - cursor: pointer; +.status-pill, +.role-chip { display: inline-flex; align-items: center; - justify-content: center; - gap: 0.5rem; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); - width: 100%; + width: fit-content; + border-radius: 999px; + background: var(--surface-accent); + color: var(--accent-strong); + font-size: 0.76rem; + font-weight: 750; } -.btn-primary { - background: var(--color-primary); - color: white; +.status-pill { + gap: 7px; + margin-top: 18px; + padding: 8px 12px; } -.btn-glow { - box-shadow: 0 4px 20px var(--color-primary-glow); +.status-pill span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); } -.btn-glow:hover { - background-color: #4f46e5; - transform: translateY(-2px); - box-shadow: 0 8px 25px rgba(99, 102, 241, 0.5); +/* Dashboard */ +.dashboard-grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; } -.btn-arrow { - width: 16px; - height: 16px; - transition: transform 0.2s ease; +.feature-card, +.apartment-card, +.empty-state, +.apartment-form, +.settings-card { + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04); } -.btn-glow:hover .btn-arrow { - transform: translateX(4px); +.feature-card { + padding: 22px; +} + +.feature-card h2, +.settings-card h2 { + margin-bottom: 9px; + font-size: 1.35rem; + letter-spacing: -0.035em; +} + +.feature-card p:not(.eyebrow), +.settings-card p:not(.eyebrow) { + color: var(--text-muted); + line-height: 1.55; +} + +.feature-card-primary { + display: grid; + gap: 18px; + border-color: transparent; + background: var(--surface-accent); +} + +.feature-card-primary .mat-mdc-button { + --mdc-text-button-label-text-color: var(--accent-strong); + width: fit-content; + padding-inline: 0; + font-weight: 750; +} + +.feature-icon, +.settings-icon, +.empty-illustration { + display: grid; + place-items: center; + border-radius: 16px; + background: var(--surface); + color: var(--accent); +} + +.feature-icon { + width: 48px; + height: 48px; +} + +.feature-icon svg, +.settings-icon svg { + width: 24px; + height: 24px; } .user-profile { display: flex; align-items: center; - gap: 1rem; - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(255, 255, 255, 0.05); - padding: 1.25rem; - border-radius: 16px; - width: 100%; - text-align: left; - margin-bottom: 2rem; + gap: 16px; } -.avatar { - width: 48px; - height: 48px; - border-radius: 50%; - background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); - color: white; - font-weight: 700; +.large-avatar { + width: 58px; + height: 58px; + flex: 0 0 58px; font-size: 1.25rem; - display: flex; - align-items: center; - justify-content: center; } .user-details { - display: flex; - flex-direction: column; - gap: 0.25rem; + min-width: 0; } .user-details h3 { - font-size: 1.05rem; - font-weight: 600; - margin: 0; + margin-bottom: 3px; + font-size: 1.15rem; } .user-email { - color: var(--color-text-muted); - font-size: 0.8rem; - margin: 0; + overflow: hidden; + margin-bottom: 11px; + font-size: 0.84rem; + text-overflow: ellipsis; + white-space: nowrap; } -.badge { - font-size: 0.75rem; - font-weight: 600; - padding: 0.2rem 0.6rem; - border-radius: 20px; - align-self: flex-start; -} - -.badge-success { - background: rgba(16, 185, 129, 0.1); - color: var(--color-success); - border: 1px solid rgba(16, 185, 129, 0.2); +.role-chip { + padding: 5px 9px; } .api-demo-section { - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 16px; - padding: 1.5rem; - width: 100%; - margin-bottom: 2rem; - text-align: left; + display: grid; + gap: 16px; } -.api-demo-section h4 { - font-size: 1rem; - font-weight: 600; - margin: 0 0 0.5rem; -} - -.api-demo-section p { - font-size: 0.8rem; - color: var(--color-text-muted); - line-height: 1.5; - margin: 0 0 1.25rem; -} - -.btn-secondary { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - color: var(--color-text); -} - -.btn-secondary:hover { - background: rgba(255, 255, 255, 0.1); - transform: translateY(-1px); +.api-actions .mat-mdc-outlined-button { + --mdc-outlined-button-label-text-color: var(--accent); + --mdc-outlined-button-outline-color: var(--border-strong); + border-radius: 12px; } .api-response-wrapper { - margin-top: 1.25rem; - background: rgba(0, 0, 0, 0.2); - border-radius: 10px; - padding: 1rem; - border: 1px solid rgba(255, 255, 255, 0.03); + padding: 14px; + border-radius: 13px; + background: var(--surface-soft); } -.response-label { +.api-response-wrapper > span { display: block; - font-size: 0.75rem; - color: var(--color-text-muted); - margin-bottom: 0.5rem; + margin-bottom: 5px; + color: var(--text-subtle); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; } .api-response-code { - font-family: monospace; - font-size: 0.85rem; - color: var(--color-success); - word-break: break-all; + color: var(--accent-strong); + font-size: 0.83rem; + overflow-wrap: anywhere; } -.btn-outline { - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.1); - color: var(--color-text-muted); +/* Apartments */ +.page-heading .primary-action { + width: 100%; + margin-top: 20px; } -.btn-outline:hover { - border-color: rgba(255, 255, 255, 0.3); - color: var(--color-text); - transform: translateY(-1px); -} - -/* Tab Navigation */ -.tab-nav { - display: flex; - gap: 0.5rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); - margin-bottom: 2rem; - padding-bottom: 0.5rem; -} - -.tab-btn { - background: transparent; - border: none; - color: var(--color-text-muted); - padding: 0.75rem 1.25rem; - font-weight: 500; - font-size: 0.95rem; - cursor: pointer; - display: flex; - align-items: center; - gap: 0.5rem; - border-radius: 8px; - transition: all var(--transition-speed); -} - -.tab-btn:hover { - background: rgba(255, 255, 255, 0.03); - color: var(--color-text); -} - -.tab-btn.active { - background: rgba(255, 255, 255, 0.05); - color: var(--color-primary); - border: 1px solid rgba(255, 255, 255, 0.05); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); -} - -.tab-icon { - width: 18px; - height: 18px; -} - -.tab-pane { - animation: fadeIn var(--transition-speed) ease; -} - -/* Section Header */ -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.section-header h2 { +.button-plus { + margin-right: 4px; font-size: 1.25rem; - font-weight: 600; - margin: 0; - color: var(--color-text); + font-weight: 400; } -/* Empty State */ .empty-state { + padding: 46px 22px; text-align: center; - padding: 3rem 1.5rem; - background: rgba(255, 255, 255, 0.02); +} + +.empty-illustration { + width: 68px; + height: 68px; + margin: 0 auto 22px; + background: var(--surface-accent); +} + +.empty-illustration svg { + width: 34px; + height: 34px; +} + +.empty-state h2 { + margin-bottom: 8px; + font-size: 1.45rem; +} + +.empty-state > p:not(.eyebrow) { + max-width: 440px; + margin: 0 auto 22px; + color: var(--text-muted); + line-height: 1.6; +} + +.empty-state .mat-mdc-outlined-button { + --mdc-outlined-button-label-text-color: var(--accent); + --mdc-outlined-button-outline-color: var(--border-strong); border-radius: 12px; - border: 1px dashed rgba(255, 255, 255, 0.1); } -.empty-icon { - width: 48px; - height: 48px; - color: var(--color-text-muted); - margin-bottom: 1rem; - opacity: 0.5; -} - -.empty-state h3 { - font-size: 1.1rem; - margin-bottom: 0.5rem; - color: var(--color-text); -} - -.empty-state p { - font-size: 0.875rem; - color: var(--color-text-muted); - margin: 0; -} - -/* Apartments Grid & Cards */ .apartments-grid { display: grid; grid-template-columns: 1fr; - gap: 1rem; + gap: 16px; } .apartment-card { - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 12px; - padding: 1.25rem; + padding: 20px; +} + +.apartment-card-topline { display: flex; + align-items: baseline; justify-content: space-between; - align-items: flex-start; - transition: all var(--transition-speed); + gap: 14px; + margin-bottom: 22px; } -.apartment-card:hover { - background: rgba(255, 255, 255, 0.04); - border-color: rgba(255, 255, 255, 0.1); - transform: translateY(-2px); +.property-label { + margin: 0; } -.apt-info { - flex: 1; - padding-right: 1rem; +.property-value { + font-size: 1rem; + font-weight: 800; } -.apt-info h3 { - font-size: 1.1rem; - margin: 0 0 0.5rem 0; - color: var(--color-text); +.apt-info h2 { + margin-bottom: 9px; + font-size: 1.35rem; + letter-spacing: -0.035em; } .apt-address { - font-size: 0.85rem; - color: var(--color-text-muted); - margin: 0 0 0.5rem 0; display: flex; - align-items: center; - gap: 0.25rem; + align-items: flex-start; + gap: 8px; + margin-bottom: 12px; + color: var(--text-muted); + font-size: 0.88rem; + line-height: 1.5; } -.inline-icon { - width: 14px; - height: 14px; - flex-shrink: 0; -} - -.apt-price { - font-size: 1.15rem; - font-weight: 700; - color: var(--color-primary); - margin: 0 0 0.5rem 0; +.apt-address svg { + width: 17px; + height: 17px; + flex: 0 0 17px; + margin-top: 2px; } .apt-desc { - font-size: 0.85rem; - color: var(--color-text-muted); - margin: 0; + margin-bottom: 0; + color: var(--text-muted); + font-size: 0.88rem; + line-height: 1.55; } .apt-actions { display: flex; - flex-direction: column; - gap: 0.5rem; - flex-shrink: 0; + gap: 4px; + justify-content: flex-end; + margin-top: 20px; + padding-top: 12px; + border-top: 1px solid var(--border); } -/* CRUD Form Styling */ -.form-container h2 { - font-size: 1.25rem; - margin: 0 0 1.5rem 0; +.apt-actions .mat-mdc-button { + --mdc-text-button-label-text-color: var(--accent); } -.crud-form { - display: flex; - flex-direction: column; - gap: 1.25rem; +.apt-actions .danger-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--danger); } -.form-group { - display: flex; - flex-direction: column; - gap: 0.5rem; +/* Apartment form */ +.back-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--text-muted); + margin: 0 0 20px -16px; } -.form-group label { - font-size: 0.85rem; - color: var(--color-text-muted); - font-weight: 500; +.apartment-form { + padding: 20px; } -.form-group input, -.form-group textarea { - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - padding: 0.75rem; - color: var(--color-text); - font-family: inherit; - font-size: 0.9rem; - transition: border-color var(--transition-speed); +.form-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 2px; } -.form-group input:focus, -.form-group textarea:focus { - outline: none; - border-color: var(--color-primary); +.form-grid mat-form-field { + width: 100%; +} + +.alert-error { + margin-bottom: 18px; + padding: 13px 15px; + border: 1px solid color-mix(in srgb, var(--danger) 28%, transparent); + border-radius: 12px; + background: var(--danger-soft); + color: var(--danger); + font-size: 0.87rem; } .form-actions { display: flex; - justify-content: flex-end; - gap: 0.75rem; - margin-top: 1rem; + flex-direction: column-reverse; + gap: 8px; + margin-top: 6px; } -/* Warnings and Alerts */ -.alert { - padding: 0.75rem 1rem; - border-radius: 8px; - font-size: 0.875rem; - margin-bottom: 1.25rem; - border: 1px solid transparent; -} - -.alert-error { - background: rgba(239, 68, 68, 0.1); - border-color: rgba(239, 68, 68, 0.2); - color: #f87171; -} - -.btn-warning { - border-color: rgba(245, 158, 11, 0.2); - color: #fbbf24; -} - -.btn-warning:hover { - border-color: rgba(245, 158, 11, 0.5); - color: #fbbf24; -} - -.btn-danger { - border-color: rgba(239, 68, 68, 0.2); - color: #f87171; -} - -.btn-danger:hover { - border-color: rgba(239, 68, 68, 0.5); - color: #f87171; -} - -.btn-logout { +.form-actions button { width: 100%; - margin-top: 2rem; } -.card-footer { - margin-top: 2.5rem; - border-top: 1px solid rgba(255, 255, 255, 0.05); - padding-top: 1.5rem; +/* Settings */ +.settings-card { + overflow: hidden; } -.card-footer p { - color: rgba(255, 255, 255, 0.25); - font-size: 0.75rem; - margin: 0; +.settings-copy { + display: flex; + gap: 15px; + padding: 22px; + border-bottom: 1px solid var(--border); } -@media (max-width: 480px) { - .app-container { - padding: 1rem; +.settings-icon { + width: 46px; + height: 46px; + flex: 0 0 46px; + background: var(--surface-accent); +} + +.settings-copy h2 { + margin-bottom: 5px; +} + +.settings-copy p:last-child { + margin-bottom: 0; + font-size: 0.88rem; +} + +.theme-options { + display: grid; + padding: 8px; +} + +.theme-options mat-radio-button { + border-radius: 14px; +} + +.theme-options mat-radio-button:hover { + background: var(--surface-soft); +} + +.theme-option-copy { + display: flex; + flex-direction: column; + padding: 12px 8px; +} + +.theme-option-copy strong { + color: var(--text); +} + +.theme-option-copy small { + margin-top: 3px; + color: var(--text-muted); + white-space: normal; +} + +.session-settings { + margin-top: 16px; +} + +.session-action { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 18px; + padding: 22px; +} + +.session-action > div { + display: flex; + min-width: 0; + flex-direction: column; +} + +.session-action strong, +.session-action span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-action span { + margin-top: 3px; + color: var(--text-muted); + font-size: 0.82rem; +} + +.session-action .mat-mdc-outlined-button { + --mdc-outlined-button-label-text-color: var(--danger); + --mdc-outlined-button-outline-color: var(--border-strong); + border-radius: 12px; +} + +@media (min-width: 700px) { + .page-container { + padding: 42px 36px 116px; } - .main-card { - padding: 2rem 1.5rem 1.5rem; - border-radius: 20px; + .page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; } - h1 { - font-size: 1.75rem; + .page-heading .primary-action { + width: auto; + flex: 0 0 auto; + margin-top: 0; } - .logo-wrapper { - width: 56px; - height: 56px; - margin-bottom: 1.25rem; + .dashboard-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .logo-icon { - width: 28px; - height: 28px; + .feature-card-primary, + .api-demo-section { + grid-column: span 2; } - .card-header { - margin-bottom: 2rem; + .apartments-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .btn { - padding: 0.75rem 1.5rem; - font-size: 0.9rem; + .apartment-form { + padding: 30px; + } + + .form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 18px; + } + + .full-field { + grid-column: span 2; + } + + .form-actions { + flex-direction: row; + justify-content: flex-end; + } + + .form-actions button { + width: auto; + } +} + +@media (min-width: 960px) { + .sign-in-shell { + display: grid; + grid-template-columns: minmax(520px, 0.92fr) minmax(480px, 1.08fr); + } + + .sign-in-panel { + padding: 56px clamp(56px, 7vw, 112px); + } + + .sign-in-story { + display: grid; + min-height: 100vh; + place-items: end start; + padding: 64px; + background: + radial-gradient(circle at 75% 20%, rgba(99, 199, 188, 0.24), transparent 30%), + linear-gradient(145deg, #153d38, #0e2925 55%, #0a1d1a); + color: #effbf8; + } + + .story-card { + max-width: 560px; + } + + .story-kicker { + color: #7ed6cc; + } + + .story-card h2 { + margin-bottom: 42px; + font-size: clamp(2.4rem, 4vw, 4.5rem); + letter-spacing: -0.06em; + line-height: 1; + } + + .story-list { + display: grid; + gap: 0; + } + + .story-list > div { + display: grid; + grid-template-columns: 42px 1fr; + gap: 12px; + padding: 17px 0; + border-top: 1px solid rgba(255, 255, 255, 0.13); + } + + .story-list span { + color: #7ed6cc; + font-size: 0.75rem; + font-weight: 800; + } + + .story-list p { + margin-bottom: 0; + color: rgba(239, 251, 248, 0.76); + } + + .workspace-shell { + display: block; + } + + .app-navigation { + top: 0; + right: auto; + bottom: auto; + width: 256px; + height: 100vh; + padding: 26px 18px; + border-top: 0; + border-right: 1px solid var(--border); + background: var(--surface); + box-shadow: none; + backdrop-filter: none; + } + + .sidebar-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px 32px; + } + + .sidebar-brand > div { + display: flex; + flex-direction: column; + font-size: 0.86rem; + line-height: 1.15; + } + + .sidebar-brand strong { + font-size: 1rem; + } + + .sidebar-brand span:last-child { + color: var(--text-muted); + } + + .navigation-items { + display: flex; + height: auto; + flex-direction: column; + gap: 6px; + } + + .navigation-item.mat-mdc-button { + width: 100%; + height: 48px; + justify-content: flex-start; + border-radius: 13px; + padding: 0 14px; + } + + .navigation-item .mat-mdc-button-touch-target { + height: 48px; + } + + .navigation-item .mdc-button__label { + display: flex; + align-items: center; + gap: 12px; + } + + .navigation-item svg { + width: 20px; + height: 20px; + margin: 0; + } + + .navigation-item span { + font-size: 0.86rem; + } + + .sidebar-user { + position: absolute; + right: 18px; + bottom: 24px; + left: 18px; + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + gap: 10px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface-soft); + } + + .sidebar-user > div:nth-child(2) { + display: flex; + min-width: 0; + flex-direction: column; + justify-content: center; + } + + .sidebar-user strong, + .sidebar-user span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sidebar-user strong { + font-size: 0.82rem; + } + + .sidebar-user span { + color: var(--text-muted); + font-size: 0.69rem; + } + + .logout-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--text-muted); + grid-column: 1 / -1; + height: 34px; + border-top: 1px solid var(--border); + border-radius: 0; + font-size: 0.75rem; + } + + .workspace-main { + margin-left: 256px; + } + + .mobile-header { + display: none; + } + + .page-container { + padding: 64px clamp(48px, 6vw, 82px); + } + + .dashboard-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .feature-card-primary { + grid-column: span 2; + min-height: 250px; } .user-profile { - padding: 1rem; - margin-bottom: 1.5rem; + grid-column: span 1; } .api-demo-section { - padding: 1.25rem; - margin-bottom: 1.5rem; + grid-column: span 3; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; } - .tab-nav { - flex-wrap: wrap; + .api-response-wrapper { + grid-column: 1 / -1; + } + + .settings-card { + display: grid; + grid-template-columns: minmax(280px, 0.8fr) minmax(360px, 1.2fr); + } + + .settings-copy { + border-right: 1px solid var(--border); + border-bottom: 0; + } + + .session-action { + align-items: flex-start; justify-content: center; } - - .tab-btn { - flex: 1; - min-width: 110px; - justify-content: center; - font-size: 0.85rem; - padding: 0.6rem 0.8rem; - } - - .section-header { - flex-direction: column; - align-items: stretch; - gap: 1rem; - text-align: center; - } - - .apartment-card { - flex-direction: column; - align-items: stretch; - gap: 1rem; - } - - .apt-info { - padding-right: 0; - } - - .apt-actions { - flex-direction: row; - width: 100%; - gap: 0.5rem; - } - - .apt-actions button { - flex: 1; - padding: 0.5rem; - font-size: 0.8rem; - } } +@media (min-width: 1180px) { + .apartments-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .form-page { + max-width: 900px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 6513c63..46eb439 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1,208 +1,470 @@ -
-
-
-
-
- -
-
-
- - - - -
-

LMNP Organizer

-

Votre assistant de gestion et fiscalité immobilière

-
- -
- @if (!isLoggedIn) { -
-
- - - - -
-

Connexion Sécurisée Requise

-

Connectez-vous avec Keycloak / voidAuth pour accéder à votre espace de gestion locative.

- +@if (!isLoggedIn) { +
+ + + +
+} @else { +
+ + +
+
+
+ + LMNP Organizer +
+
{{ userInitial() }}
+
+ +
+ @if (activeTab() === 'dashboard') { +
+
+
+

Bonjour {{ username }}

+

Tableau de bord

+

Retrouvez rapidement les actions utiles à votre gestion locative.

+
+ Espace sécurisé +
+ +
+
+ +
+

Votre portefeuille

+

+ {{ apartments().length }} appartement{{ apartments().length > 1 ? 's' : '' }} +

+

Centralisez les informations essentielles de vos biens.

+
+ +
+ +
+ -
-

Vérification API Sécurisée

-

Testez la transmission et la validation du jeton JWT auprès du backend Java 25.

+
+
+

État du service

+

Connexion au serveur

+

Vérifiez que votre espace communique correctement avec le service sécurisé.

+
-
- @if (helloMessage()) {
- Réponse du backend : + Réponse du serveur {{ helloMessage() }}
} -
+
- } +
+ } - - @if (activeTab() === 'apartments') { -
- @if (!showForm()) { -
-

Mon Portefeuille de Biens

- + @if (activeTab() === 'apartments') { +
+ @if (!showForm()) { +
+
+

Portefeuille

+

Mes appartements

+

Les biens rattachés à votre activité LMNP.

+ +
- @if (apartments().length === 0) { -
- - - + @if (apartments().length === 0) { +
+
- } @else { -
- @for (apt of apartments(); track apt.id) { -
-
-

{{ apt.name }}

-

- - - - - {{ apt.address }} -

-

{{ apt.purchasePrice.toLocaleString('fr-FR') }} €

- @if (apt.description) { -

{{ apt.description }}

- } -
-
- - @if (apt.id) { - - } -
-
- } -
- } +

Commencez ici

+

Aucun appartement enregistré

+

Ajoutez votre premier bien pour construire votre portefeuille.

+ + } @else { -
-

{{ editingApartment() ? 'Modifier l\'appartement' : 'Ajouter un appartement' }}

- - @if (formError) { -
- {{ formError }} -
+
+ @for (apt of apartments(); track apt.id) { +
+
+ Bien immobilier + {{ apt.purchasePrice.toLocaleString('fr-FR') }} € +
+
+

{{ apt.name }}

+

+ + {{ apt.address }} +

+ @if (apt.description) { +

{{ apt.description }}

+ } +
+
+ + @if (apt.id) { + + } +
+
} - -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
} -
- } + } @else { +
+
+
+ +

Portefeuille

+

+ {{ editingApartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }} +

+

Renseignez les informations principales du bien.

+
+
- -
- } -
+
+ @if (formError) { + + } -
-

LMNP Organizer © 2026. Sécurisé avec OpenID Connect et Spring Boot 4.

-
-
-
+
+ + Nom de l'appartement + + + + + Prix d'acquisition + + € + + + + Adresse complète + + + + + Description ou notes + + +
+ +
+ + +
+ + + } + + } + + @if (activeTab() === 'settings') { +
+
+
+

Préférences

+

Réglages

+

Adaptez LMNP Organizer à votre façon de travailler.

+
+
+ +
+
+ +
+

Interface

+

Apparence

+

Le réglage est mémorisé uniquement sur cet appareil.

+
+
+ + + + ClairUne interface lumineuse et douce. + + + SombreUn contraste réduit en faible lumière. + + + SystèmeSuit automatiquement votre appareil. + + +
+ +
+
+ +
+

Compte

+

Session

+

Gérez la session ouverte sur cet appareil.

+
+
+
+
+ {{ username }} + {{ email }} +
+ +
+
+
+ } + + + +} diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 49433b5..cf163c1 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,20 +1,33 @@ -import { Component, signal, inject, OnInit, effect } from '@angular/core'; +import { Component, computed, signal, inject, OnInit, effect } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { ApartmentService, Apartment } from './apartment.service'; import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +import { ThemeService } from './theme.service'; +import { BrandMark } from './brand-mark'; @Component({ selector: 'app-root', - imports: [FormsModule], + imports: [ + BrandMark, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + ], templateUrl: './app.html', - styleUrl: './app.css' + styleUrl: './app.css', }) export class App implements OnInit { protected readonly title = signal('LMNP Organizer'); readonly helloMessage = signal(''); // Tab & CRUD States - readonly activeTab = signal<'dashboard' | 'apartments'>('dashboard'); + readonly activeTab = signal<'dashboard' | 'apartments' | 'settings'>('dashboard'); readonly apartments = signal([]); readonly showForm = signal(false); readonly editingApartment = signal(null); @@ -29,9 +42,12 @@ export class App implements OnInit { private readonly _isLoggedIn = signal(false); private readonly _username = signal(''); private readonly _email = signal(''); + readonly userInitial = computed(() => this._username().charAt(0).toUpperCase()); private readonly http = inject(HttpClient); private readonly apartmentService = inject(ApartmentService); + protected readonly themeService = inject(ThemeService); + readonly themePreference = this.themeService.preference; constructor() { effect(() => { @@ -42,25 +58,27 @@ export class App implements OnInit { } ngOnInit() { - this.http.get<{ authenticated: boolean, username?: string, email?: string }>('/api/user').subscribe({ - next: (user) => { - if (user.authenticated) { - this._isLoggedIn.set(true); - this._username.set(user.username || 'User'); - this._email.set(user.email || ''); - } else { + this.http + .get<{ authenticated: boolean; username?: string; email?: string }>('/api/user') + .subscribe({ + next: (user) => { + if (user.authenticated) { + this._isLoggedIn.set(true); + this._username.set(user.username || 'User'); + this._email.set(user.email || ''); + } else { + this._isLoggedIn.set(false); + this._username.set(''); + this._email.set(''); + } + }, + error: (err) => { + console.error('Error fetching user authentication status', err); this._isLoggedIn.set(false); this._username.set(''); this._email.set(''); - } - }, - error: (err) => { - console.error('Error fetching user authentication status', err); - this._isLoggedIn.set(false); - this._username.set(''); - this._email.set(''); - } - }); + }, + }); } get isLoggedIn(): boolean { @@ -89,7 +107,7 @@ export class App implements OnInit { error: (err) => { console.error(err); this.helloMessage.set('Failed to connect to backend: ' + err.statusText); - } + }, }); } @@ -97,7 +115,7 @@ export class App implements OnInit { loadApartments() { this.apartmentService.getApartments().subscribe({ next: (data) => this.apartments.set(data), - error: (err) => console.error('Error loading apartments', err) + error: (err) => console.error('Error loading apartments', err), }); } @@ -136,7 +154,7 @@ export class App implements OnInit { name: this.formName.trim(), address: this.formAddress.trim(), purchasePrice: this.formPurchasePrice, - description: this.formDescription.trim() + description: this.formDescription.trim(), }; const editMode = this.editingApartment(); @@ -149,7 +167,7 @@ export class App implements OnInit { error: (err) => { console.error(err); this.formError = 'Erreur lors de la modification.'; - } + }, }); } else { this.apartmentService.createApartment(apartmentData).subscribe({ @@ -160,7 +178,7 @@ export class App implements OnInit { error: (err) => { console.error(err); this.formError = 'Erreur lors de la création.'; - } + }, }); } } @@ -169,7 +187,7 @@ export class App implements OnInit { if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) { this.apartmentService.deleteApartment(id).subscribe({ next: () => this.loadApartments(), - error: (err) => console.error('Error deleting apartment', err) + error: (err) => console.error('Error deleting apartment', err), }); } } diff --git a/frontend/src/app/brand-mark.ts b/frontend/src/app/brand-mark.ts new file mode 100644 index 0000000..5edebd6 --- /dev/null +++ b/frontend/src/app/brand-mark.ts @@ -0,0 +1,24 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-brand-mark', + template: ` + + + + + `, + styles: ` + :host { + display: grid; + place-items: center; + } + + svg { + width: 55%; + height: 55%; + } + `, + host: { 'aria-hidden': 'true' }, +}) +export class BrandMark {} diff --git a/frontend/src/app/theme.service.ts b/frontend/src/app/theme.service.ts new file mode 100644 index 0000000..fa2ef6f --- /dev/null +++ b/frontend/src/app/theme.service.ts @@ -0,0 +1,45 @@ +import { DOCUMENT } from '@angular/common'; +import { effect, inject, Injectable, signal } from '@angular/core'; + +export type ThemePreference = 'light' | 'dark' | 'system'; + +const THEME_STORAGE_KEY = 'lmnp-theme'; + +@Injectable({ providedIn: 'root' }) +export class ThemeService { + private readonly document = inject(DOCUMENT); + private readonly colorSchemeQuery = + typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-color-scheme: dark)') + : null; + private readonly systemUsesDark = signal(this.colorSchemeQuery?.matches ?? false); + + readonly preference = signal(this.readPreference()); + + constructor() { + this.colorSchemeQuery?.addEventListener('change', (event) => + this.systemUsesDark.set(event.matches), + ); + + effect(() => { + const preference = this.preference(); + const resolvedTheme = + preference === 'system' ? (this.systemUsesDark() ? 'dark' : 'light') : preference; + + localStorage.setItem(THEME_STORAGE_KEY, preference); + this.document.documentElement.dataset['theme'] = resolvedTheme; + this.document.documentElement.dataset['themePreference'] = preference; + }); + } + + setPreference(preference: ThemePreference): void { + this.preference.set(preference); + } + + private readPreference(): ThemePreference { + const storedPreference = localStorage.getItem(THEME_STORAGE_KEY); + return storedPreference === 'dark' || storedPreference === 'system' + ? storedPreference + : 'light'; + } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css deleted file mode 100644 index d37d038..0000000 --- a/frontend/src/styles.css +++ /dev/null @@ -1,4 +0,0 @@ -/* You can add global styles to this file, and also import other style files */ -body { - margin: 0; -} \ No newline at end of file diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss new file mode 100644 index 0000000..9f1b5ed --- /dev/null +++ b/frontend/src/styles.scss @@ -0,0 +1,95 @@ +@use '@angular/material' as mat; + +@include mat.core(); + +html { + @include mat.theme( + ( + color: ( + primary: mat.$cyan-palette, + tertiary: mat.$azure-palette, + theme-type: color-scheme, + ), + typography: ( + Inter, + ui-sans-serif, + system-ui, + sans-serif, + ), + density: 0, + ) + ); + + color-scheme: light; + --canvas: #f4f6f2; + --surface: #ffffff; + --surface-raised: #ffffff; + --surface-soft: #e9efeb; + --surface-accent: #dcefeb; + --text: #17211e; + --text-muted: #687670; + --text-subtle: #8c9994; + --accent: #0f766e; + --accent-strong: #0a5f59; + --accent-contrast: #ffffff; + --border: #dce5df; + --border-strong: #c7d3cd; + --danger: #b42318; + --danger-soft: #fee9e7; + --shadow: 0 24px 60px rgba(28, 49, 42, 0.09); +} + +html[data-theme='dark'] { + color-scheme: dark; + --canvas: #101513; + --surface: #171d1b; + --surface-raised: #1b2320; + --surface-soft: #202a26; + --surface-accent: #173a36; + --text: #f0f5f2; + --text-muted: #a7b5af; + --text-subtle: #7f8e88; + --accent: #63c7bc; + --accent-strong: #86d7cf; + --accent-contrast: #08201d; + --border: #2c3733; + --border-strong: #3a4944; + --danger: #ffb4ab; + --danger-soft: #4a2421; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.3); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + overflow-x: hidden; +} + +body { + background: var(--canvas); + color: var(--text); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + text-rendering: optimizeLegibility; + transition: + background-color 180ms ease, + color 180ms ease; +} + +button, +input, +textarea { + font: inherit; +} diff --git a/frontend/tsconfig.e2e.json b/frontend/tsconfig.e2e.json new file mode 100644 index 0000000..42834b1 --- /dev/null +++ b/frontend/tsconfig.e2e.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "e2e/**/*.ts"] +}