Compare commits

...

7 Commits

Author SHA1 Message Date
mohamad
12e2890a4a Add project documentation and production deployment guide
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m6s
- Introduced comprehensive project documentation for the Shared Household Management PWA, detailing project overview, goals, features, user experience philosophy, technology stack, and development roadmap.
- Added a production deployment guide using Docker Compose and Gitea Actions, outlining setup, configuration, and deployment processes.
- Updated favicon and icon assets for improved branding and user experience across devices.
2025-06-02 00:19:54 +02:00
mohamad
f98bdb6b11 Update OAuth redirect URIs to production environment
- Changed the Google and Apple redirect URIs in the configuration to point to the production URLs.
- This update ensures that the application correctly redirects users to the appropriate authentication endpoints in the live environment.
2025-06-02 00:19:54 +02:00
mohamad
5d50606fc2 Update OAuth redirect URIs and API routing structure
- Changed the Google and Apple redirect URIs in the configuration to include the API version in the path.
- Reorganized the inclusion of OAuth routes in the main application to ensure they are properly prefixed and accessible.

These updates aim to enhance the API structure and ensure consistency in the authentication flow.
2025-06-02 00:19:54 +02:00
mohamad
30af7ab692 Refactor API routing and update login URLs
- Updated the OAuth routes to be included under the main API prefix for better organization.
- Changed the Google login URL in the SocialLoginButtons component to reflect the new API structure.

These changes aim to improve the clarity and consistency of the API routing and enhance the login flow for users.
2025-06-02 00:19:53 +02:00
google-labs-jules[bot]
4effbf5c03 feat(i18n): Internationalize remaining app pages
This commit completes the internationalization (i18n) for several key pages
within the frontend application.

The following pages have been updated to support multiple languages:
- AccountPage.vue
- SignupPage.vue
- ListDetailPage.vue (including items, OCR, expenses, and cost summary)
- MyChoresPage.vue
- PersonalChoresPage.vue
- IndexPage.vue

Key changes include:
- Extraction of all user-facing strings from these Vue components.
- Addition of new translation keys and their English values to `fe/src/i18n/en.json`.
- Modification of the Vue components to use the Vue I18n plugin's `$t()` (template)
  and `t()` (script) functions for displaying translated strings.
- Dynamic messages, notifications, and form validation messages are now also
  internationalized.
- The language files `de.json`, `es.json`, and `fr.json` have been updated
  with the new keys, using the English text as placeholders for future
  translation.

This effort significantly expands the i18n coverage of the application,
making it more accessible to a wider audience.
2025-06-02 00:19:26 +02:00
google-labs-jules[bot]
5c9ba3f38c feat: Internationalize AuthCallback, Chores, ErrorNotFound, GroupDetail pages
This commit introduces internationalization for several pages:
- AuthCallbackPage.vue
- ChoresPage.vue (a comprehensive page with many elements)
- ErrorNotFound.vue
- GroupDetailPage.vue (including sub-sections for members, invites, chores summary, and expenses summary)

Key changes:
- Integrated `useI18n` in each listed page to handle translatable strings.
- Replaced hardcoded text in templates and relevant script sections (notifications, dynamic messages, fallbacks, etc.) with `t('key')` calls.
- Added new translation keys, organized under page-specific namespaces (e.g., `authCallbackPage`, `choresPage`, `errorNotFoundPage`, `groupDetailPage`), to `fe/src/i18n/en.json`.
- Added corresponding keys with placeholder translations (prefixed with DE:, FR:, ES:) to `fe/src/i18n/de.json`, `fe/src/i18n/fr.json`, and `fe/src/i18n/es.json`.
- Reused existing translation keys (e.g., for chore frequency options) where applicable.
2025-06-02 00:19:26 +02:00
google-labs-jules[bot]
8034824c97 Fix: Resolve Google OAuth redirection issue
This commit addresses an issue where you, when clicking the "Continue with Google"
button, were redirected back to the login page instead of to Google's
authentication page.

The following changes were made:

1.  **Frontend Redirect:**
    *   Modified `fe/src/components/SocialLoginButtons.vue` to make the "Continue with Google" button redirect to the correct backend API endpoint (`/auth/google/login`) using the configured `API_BASE_URL`.

2.  **Backend Route Confirmation:**
    *   Verified that the backend OAuth routes in `be/app/api/auth/oauth.py` are correctly included in `be/app/main.py` under the `/auth` prefix, making them accessible.

3.  **OAuth Credentials Configuration:**
    *   Added `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` placeholders to `env.production.template` to guide you in setting up your OAuth credentials.
    *   Added instructional comments in `be/app/config.py` regarding the necessity of these environment variables and the correct configuration of `GOOGLE_REDIRECT_URI`.

With these changes, and assuming the necessary Google Cloud OAuth credentials
(Client ID, Client Secret) and redirect URIs are correctly configured in the
environment, the Google OAuth flow should now function as expected.
2025-06-02 00:19:26 +02:00
41 changed files with 2882 additions and 695 deletions

View File

@ -113,15 +113,20 @@ Organic Bananas
AUTH_HEADER_PREFIX: str = "Bearer"
# OAuth Settings
# IMPORTANT: For Google OAuth to work, you MUST set the following environment variables
# (e.g., in your .env file):
# GOOGLE_CLIENT_ID: Your Google Cloud project's OAuth 2.0 Client ID
# GOOGLE_CLIENT_SECRET: Your Google Cloud project's OAuth 2.0 Client Secret
# Ensure the GOOGLE_REDIRECT_URI below matches the one configured in your Google Cloud Console.
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/api/v1/auth/google/callback"
GOOGLE_REDIRECT_URI: str = "https://mitlistbe.mohamad.dev/api/v1/auth/google/callback"
APPLE_CLIENT_ID: str = ""
APPLE_TEAM_ID: str = ""
APPLE_KEY_ID: str = ""
APPLE_PRIVATE_KEY: str = ""
APPLE_REDIRECT_URI: str = "http://localhost:8000/api/v1/auth/apple/callback"
APPLE_REDIRECT_URI: str = "https://mitlistbe.mohamad.dev/api/v1/auth/apple/callback"
# Session Settings
SESSION_SECRET_KEY: str = "your-session-secret-key" # Change this in production

View File

@ -30,9 +30,9 @@ VITE_API_URL=https://yourdomain.com/api
VITE_SENTRY_DSN=your_frontend_sentry_dsn_here
VITE_ROUTER_MODE=history
# OAuth Configuration (if using)
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Google OAuth Configuration - Replace with your actual credentials
GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID_HERE"
GOOGLE_CLIENT_SECRET="YOUR_GOOGLE_CLIENT_SECRET_HERE"
GOOGLE_REDIRECT_URI=https://yourdomain.com/auth/google/callback
APPLE_CLIENT_ID=your_apple_client_id

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <!-- Or your favicon -->
<link rel="icon" type="image/svg+xml" href="/fe/public/favicon.ico" /> <!-- Or your favicon -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="mitlist pwa">
<meta name="format-detection" content="telephone=no">

189
fe/package-lock.json generated
View File

@ -23,7 +23,7 @@
"workbox-background-sync": "^7.3.0"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.51.1",
"@storybook/addon-docs": "^9.0.2",
"@storybook/addon-onboarding": "^9.0.2",
@ -2558,14 +2558,13 @@
}
},
"node_modules/@intlify/bundle-utils": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-10.0.1.tgz",
"integrity": "sha512-WkaXfSevtpgtUR4t8K2M6lbR7g03mtOxFeh+vXp5KExvPqS12ppaRj1QxzwRuRI5VUto54A22BjKoBMLyHILWQ==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-8.0.0.tgz",
"integrity": "sha512-1B++zykRnMwQ+20SpsZI1JCnV/YJt9Oq7AGlEurzkWJOFtFAVqaGc/oV36PBRYeiKnTbY9VYfjBimr2Vt42wLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "^11.1.2",
"@intlify/shared": "^11.1.2",
"@intlify/message-compiler": "^9.4.0",
"@intlify/shared": "^9.4.0",
"acorn": "^8.8.2",
"escodegen": "^2.1.0",
"estree-walker": "^2.0.2",
@ -2575,7 +2574,7 @@
"yaml-eslint-parser": "^1.2.2"
},
"engines": {
"node": ">= 18"
"node": ">= 14.16"
},
"peerDependenciesMeta": {
"petite-vue-i18n": {
@ -2587,13 +2586,12 @@
}
},
"node_modules/@intlify/bundle-utils/node_modules/@intlify/message-compiler": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.3.tgz",
"integrity": "sha512-7rbqqpo2f5+tIcwZTAG/Ooy9C8NDVwfDkvSeDPWUPQW+Dyzfw2o9H103N5lKBxO7wxX9dgCDjQ8Umz73uYw3hw==",
"version": "9.14.4",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.4.tgz",
"integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.1.3",
"@intlify/shared": "9.14.4",
"source-map-js": "^1.0.2"
},
"engines": {
@ -2604,11 +2602,10 @@
}
},
"node_modules/@intlify/bundle-utils/node_modules/@intlify/shared": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.3.tgz",
"integrity": "sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==",
"version": "9.14.4",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.4.tgz",
"integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
},
@ -2661,19 +2658,15 @@
}
},
"node_modules/@intlify/unplugin-vue-i18n": {
"version": "6.0.8",
"resolved": "https://registry.npmjs.org/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-6.0.8.tgz",
"integrity": "sha512-Vvm3KhjE6TIBVUQAk37rBiaYy2M5OcWH0ZcI1XKEsOTeN1o0bErk+zeuXmcrcMc/73YggfI8RoxOUz9EB/69JQ==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-4.0.0.tgz",
"integrity": "sha512-q2Mhqa/mLi0tulfLFO4fMXXvEbkSZpI5yGhNNsLTNJJ41icEGUuyDe+j5zRZIKSkOJRgX6YbCyibTDJdRsukmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@intlify/bundle-utils": "^10.0.1",
"@intlify/shared": "^11.1.2",
"@intlify/vue-i18n-extensions": "^8.0.0",
"@intlify/bundle-utils": "^8.0.0",
"@intlify/shared": "^9.4.0",
"@rollup/pluginutils": "^5.1.0",
"@typescript-eslint/scope-manager": "^8.13.0",
"@typescript-eslint/typescript-estree": "^8.13.0",
"@vue/compiler-sfc": "^3.2.47",
"debug": "^4.3.3",
"fast-glob": "^3.2.12",
"js-yaml": "^4.1.0",
@ -2681,16 +2674,15 @@
"pathe": "^1.0.0",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2",
"unplugin": "^1.1.0",
"vue": "^3.4"
"unplugin": "^1.1.0"
},
"engines": {
"node": ">= 18"
"node": ">= 14.16"
},
"peerDependencies": {
"petite-vue-i18n": "*",
"vue": "^3.2.25",
"vue-i18n": "*"
"vue-i18n": "*",
"vue-i18n-bridge": "*"
},
"peerDependenciesMeta": {
"petite-vue-i18n": {
@ -2698,15 +2690,17 @@
},
"vue-i18n": {
"optional": true
},
"vue-i18n-bridge": {
"optional": true
}
}
},
"node_modules/@intlify/unplugin-vue-i18n/node_modules/@intlify/shared": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.3.tgz",
"integrity": "sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==",
"version": "9.14.4",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.4.tgz",
"integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
},
@ -2744,117 +2738,6 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@intlify/vue-i18n-extensions": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@intlify/vue-i18n-extensions/-/vue-i18n-extensions-8.0.0.tgz",
"integrity": "sha512-w0+70CvTmuqbskWfzeYhn0IXxllr6mU+IeM2MU0M+j9OW64jkrvqY+pYFWrUnIIC9bEdij3NICruicwd5EgUuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.24.6",
"@intlify/shared": "^10.0.0",
"@vue/compiler-dom": "^3.2.45",
"vue-i18n": "^10.0.0"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@intlify/shared": "^9.0.0 || ^10.0.0 || ^11.0.0",
"@vue/compiler-dom": "^3.0.0",
"vue": "^3.0.0",
"vue-i18n": "^9.0.0 || ^10.0.0 || ^11.0.0"
},
"peerDependenciesMeta": {
"@intlify/shared": {
"optional": true
},
"@vue/compiler-dom": {
"optional": true
},
"vue": {
"optional": true
},
"vue-i18n": {
"optional": true
}
}
},
"node_modules/@intlify/vue-i18n-extensions/node_modules/@intlify/core-base": {
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.7.tgz",
"integrity": "sha512-mE71aUH5baH0me8duB4FY5qevUJizypHsYw3eCvmOx07QvmKppgOONx3dYINxuA89Z2qkAGb/K6Nrpi7aAMwew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "10.0.7",
"@intlify/shared": "10.0.7"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/vue-i18n-extensions/node_modules/@intlify/message-compiler": {
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.7.tgz",
"integrity": "sha512-nrC4cDL/UHZSUqd8sRbVz+DPukzZ8NnG5OK+EB/nlxsH35deyzyVkXP/QuR8mFZrISJ+4hCd6VtCQCcT+RO+5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/shared": "10.0.7",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/vue-i18n-extensions/node_modules/@intlify/shared": {
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.7.tgz",
"integrity": "sha512-oeoq0L5+5P4ShXa6jBQcx+BT+USe3MjX0xJexZO1y7rfDJdwZ9+QP3jO4tcS1nxhBYYdjvFTqe4bmnLijV0GxQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/vue-i18n-extensions/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"dev": true,
"license": "MIT"
},
"node_modules/@intlify/vue-i18n-extensions/node_modules/vue-i18n": {
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.7.tgz",
"integrity": "sha512-bKsk0PYwP9gdYF4nqSAT0kDpnLu1gZzlxFl885VH4mHVhEnqP16+/mAU05r1U6NIrc0fGDWP89tZ8GzeJZpe+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/core-base": "10.0.7",
"@intlify/shared": "10.0.7",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -6040,8 +5923,7 @@
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
"dev": true
},
"node_modules/config-chain": {
"version": "1.1.13",
@ -6848,7 +6730,6 @@
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
@ -9092,7 +8973,6 @@
"resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.0.tgz",
"integrity": "sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.5.0",
"eslint-visitor-keys": "^3.0.0",
@ -9111,7 +8991,6 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.9.0",
"acorn-jsx": "^5.3.2",
@ -9444,7 +9323,6 @@
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"pathe": "^2.0.1",
@ -10118,7 +9996,6 @@
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
@ -12273,8 +12150,7 @@
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"dev": true,
"license": "MIT"
"dev": true
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
@ -13868,7 +13744,6 @@
"resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz",
"integrity": "sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.0.0",
"yaml": "^2.0.0"

View File

@ -34,7 +34,7 @@
"workbox-background-sync": "^7.3.0"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.51.1",
"@storybook/addon-docs": "^9.0.2",
"@storybook/addon-onboarding": "^9.0.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -35,11 +35,12 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { API_BASE_URL } from '@/config/api-config';
const router = useRouter();
const handleGoogleLogin = () => {
window.location.href = '/auth/google/login';
window.location.href = `${API_BASE_URL}/auth/google/login`;
};
const handleAppleLogin = () => {

561
fe/src/i18n/de.json Normal file
View File

@ -0,0 +1,561 @@
{
"message": {
"hello": "Hallo"
},
"loginPage": {
"emailLabel": "DE: Email",
"passwordLabel": "DE: Password",
"togglePasswordVisibilityLabel": "DE: Toggle password visibility",
"loginButton": "DE: Login",
"signupLink": "DE: Don't have an account? Sign up",
"errors": {
"emailRequired": "DE: Email is required",
"emailInvalid": "DE: Invalid email format",
"passwordRequired": "DE: Password is required",
"loginFailed": "DE: Login failed. Please check your credentials."
},
"notifications": {
"loginSuccess": "DE: Login successful"
}
},
"listsPage": {
"retryButton": "DE: Retry",
"emptyState": {
"noListsForGroup": "DE: No lists found for this group.",
"noListsYet": "DE: You have no lists yet.",
"personalGlobalInfo": "DE: Create a personal list or join a group to see shared lists.",
"groupSpecificInfo": "DE: This group doesn't have any lists yet."
},
"createNewListButton": "DE: Create New List",
"loadingLists": "DE: Loading lists...",
"noDescription": "DE: No description",
"addItemPlaceholder": "DE: Add new item...",
"createCard": {
"title": "DE: + Create a new list"
},
"pageTitle": {
"forGroup": "DE: Lists for {groupName}",
"forGroupId": "DE: Lists for Group {groupId}",
"myLists": "DE: My Lists"
},
"errors": {
"fetchFailed": "DE: Failed to fetch lists."
}
},
"groupsPage": {
"retryButton": "DE: Retry",
"emptyState": {
"title": "DE: No Groups Yet!",
"description": "DE: You are not a member of any groups yet. Create one or join using an invite code.",
"createButton": "DE: Create New Group"
},
"groupCard": {
"newListButton": "DE: List"
},
"createCard": {
"title": "DE: + Group"
},
"joinGroup": {
"title": "DE: Join a Group with Invite Code",
"inputLabel": "DE: Enter Invite Code",
"inputPlaceholder": "DE: Enter Invite Code",
"joinButton": "DE: Join"
},
"createDialog": {
"title": "DE: Create New Group",
"closeButtonLabel": "DE: Close",
"groupNameLabel": "DE: Group Name",
"cancelButton": "DE: Cancel",
"createButton": "DE: Create"
},
"errors": {
"fetchFailed": "DE: Failed to load groups",
"groupNameRequired": "DE: Group name is required",
"createFailed": "DE: Failed to create group. Please try again.",
"inviteCodeRequired": "DE: Invite code is required",
"joinFailed": "DE: Failed to join group. Please check the invite code and try again."
},
"notifications": {
"groupCreatedSuccess": "DE: Group '{groupName}' created successfully.",
"joinSuccessNamed": "DE: Successfully joined group '{groupName}'.",
"joinSuccessGeneric": "DE: Successfully joined group.",
"listCreatedSuccess": "DE: List '{listName}' created successfully."
}
},
"authCallbackPage": {
"redirecting": "DE: Redirecting...",
"errors": {
"authenticationFailed": "DE: Authentication failed"
}
},
"choresPage": {
"title": "DE: Chores",
"tabs": {
"overdue": "DE: Overdue",
"today": "DE: Today",
"upcoming": "DE: Upcoming",
"allPending": "DE: All Pending",
"completed": "DE: Completed"
},
"viewToggle": {
"calendarLabel": "DE: Calendar View",
"calendarText": "DE: Calendar",
"listLabel": "DE: List View",
"listText": "DE: List"
},
"newChoreButtonLabel": "DE: New Chore",
"newChoreButtonText": "DE: New Chore",
"loadingState": {
"loadingChores": "DE: Loading chores..."
},
"calendar": {
"prevMonthLabel": "DE: Previous month",
"nextMonthLabel": "DE: Next month",
"weekdays": {
"sun": "DE: Sun",
"mon": "DE: Mon",
"tue": "DE: Tue",
"wed": "DE: Wed",
"thu": "DE: Thu",
"fri": "DE: Fri",
"sat": "DE: Sat"
},
"addChoreToDayLabel": "DE: Add chore to this day",
"emptyState": "DE: No chores to display for this period."
},
"listView": {
"choreTypePersonal": "DE: Personal",
"choreTypeGroupFallback": "DE: Group",
"completedDatePrefix": "DE: Completed:",
"actions": {
"doneTitle": "DE: Mark as Done",
"doneText": "DE: Done",
"undoTitle": "DE: Mark as Not Done",
"undoText": "DE: Undo",
"editTitle": "DE: Edit",
"editLabel": "DE: Edit chore",
"editText": "DE: Edit",
"deleteTitle": "DE: Delete",
"deleteLabel": "DE: Delete chore",
"deleteText": "DE: Delete"
},
"emptyState": {
"message": "DE: No chores in this view. Well done!",
"viewAllButton": "DE: View All Pending"
}
},
"choreModal": {
"editTitle": "DE: Edit Chore",
"newTitle": "DE: New Chore",
"closeButtonLabel": "DE: Close modal",
"nameLabel": "DE: Name",
"namePlaceholder": "DE: Enter chore name",
"typeLabel": "DE: Type",
"typePersonal": "DE: Personal",
"typeGroup": "DE: Group",
"groupLabel": "DE: Group",
"groupSelectDefault": "DE: Select a group",
"descriptionLabel": "DE: Description",
"descriptionPlaceholder": "DE: Add a description (optional)",
"frequencyLabel": "DE: Frequency",
"intervalLabel": "DE: Interval (days)",
"intervalPlaceholder": "DE: e.g. 3",
"dueDateLabel": "DE: Due Date",
"quickDueDateToday": "DE: Today",
"quickDueDateTomorrow": "DE: Tomorrow",
"quickDueDateNextWeek": "DE: Next Week",
"cancelButton": "DE: Cancel",
"saveButton": "DE: Save"
},
"deleteDialog": {
"title": "DE: Delete Chore",
"confirmationText": "DE: Are you sure you want to delete this chore? This action cannot be undone.",
"deleteButton": "DE: Delete"
},
"shortcutsModal": {
"title": "DE: Keyboard Shortcuts",
"descNewChore": "DE: New Chore",
"descToggleView": "DE: Toggle View (List/Calendar)",
"descToggleShortcuts": "DE: Show/Hide Shortcuts",
"descCloseModal": "DE: Close any open Modal/Dialog"
},
"frequencyOptions": {
"oneTime": "DE: One Time",
"daily": "DE: Daily",
"weekly": "DE: Weekly",
"monthly": "DE: Monthly",
"custom": "DE: Custom"
},
"formatters": {
"noDueDate": "DE: No due date",
"dueToday": "DE: Due Today",
"dueTomorrow": "DE: Due Tomorrow",
"overdueFull": "DE: Overdue: {date}",
"dueFull": "DE: Due {date}",
"invalidDate": "DE: Invalid Date"
},
"notifications": {
"loadFailed": "DE: Failed to load chores",
"updateSuccess": "DE: Chore '{name}' updated successfully",
"createSuccess": "DE: Chore '{name}' created successfully",
"updateFailed": "DE: Failed to update chore",
"createFailed": "DE: Failed to create chore",
"deleteSuccess": "DE: Chore '{name}' deleted successfully",
"deleteFailed": "DE: Failed to delete chore",
"markedDone": "DE: {name} marked as done.",
"markedNotDone": "DE: {name} marked as not done.",
"statusUpdateFailed": "DE: Failed to update chore status."
},
"validation": {
"nameRequired": "DE: Chore name is required.",
"groupRequired": "DE: Please select a group for group chores.",
"intervalRequired": "DE: Custom interval must be at least 1 day.",
"dueDateRequired": "DE: Due date is required.",
"invalidDueDate": "DE: Invalid due date format."
},
"unsavedChangesConfirmation": "DE: You have unsaved changes in the chore form. Are you sure you want to leave?"
},
"errorNotFoundPage": {
"errorCode": "DE: 404",
"errorMessage": "DE: Oops. Nothing here...",
"goHomeButton": "DE: Go Home"
},
"groupDetailPage": {
"loadingLabel": "DE: Loading group details...",
"retryButton": "DE: Retry",
"groupNotFound": "DE: Group not found or an error occurred.",
"members": {
"title": "DE: Group Members",
"defaultRole": "DE: Member",
"removeButton": "DE: Remove",
"emptyState": "DE: No members found."
},
"invites": {
"title": "DE: Invite Members",
"regenerateButton": "DE: Regenerate Invite Code",
"generateButton": "DE: Generate Invite Code",
"activeCodeLabel": "DE: Current Active Invite Code:",
"copyButtonLabel": "DE: Copy invite code",
"copySuccess": "DE: Invite code copied to clipboard!",
"emptyState": "DE: No active invite code. Click the button above to generate one.",
"errors": {
"newDataInvalid": "DE: New invite code data is invalid."
}
},
"chores": {
"title": "DE: Group Chores",
"manageButton": "DE: Manage Chores",
"duePrefix": "DE: Due:",
"emptyState": "DE: No chores scheduled. Click \"Manage Chores\" to create some!"
},
"expenses": {
"title": "DE: Group Expenses",
"manageButton": "DE: Manage Expenses",
"emptyState": "DE: No expenses recorded. Click \"Manage Expenses\" to add some!",
"splitTypes": {
"equal": "DE: Equal",
"exactAmounts": "DE: Exact Amounts",
"percentage": "DE: Percentage",
"shares": "DE: Shares",
"itemBased": "DE: Item Based"
}
},
"notifications": {
"fetchDetailsFailed": "DE: Failed to fetch group details.",
"fetchInviteFailed": "DE: Failed to fetch active invite code.",
"generateInviteSuccess": "DE: New invite code generated successfully!",
"generateInviteError": "DE: Failed to generate invite code.",
"clipboardNotSupported": "DE: Clipboard not supported or no code to copy.",
"copyInviteFailed": "DE: Failed to copy invite code.",
"removeMemberSuccess": "DE: Member removed successfully",
"removeMemberFailed": "DE: Failed to remove member"
}
},
"accountPage": {
"title": "Account Settings",
"loadingProfile": "Loading profile...",
"retryButton": "Retry",
"profileSection": {
"header": "Profile Information",
"nameLabel": "Name",
"emailLabel": "Email",
"saveButton": "Save Changes"
},
"passwordSection": {
"header": "Change Password",
"currentPasswordLabel": "Current Password",
"newPasswordLabel": "New Password",
"changeButton": "Change Password"
},
"notificationsSection": {
"header": "Notification Preferences",
"emailNotificationsLabel": "Email Notifications",
"emailNotificationsDescription": "Receive email notifications for important updates",
"listUpdatesLabel": "List Updates",
"listUpdatesDescription": "Get notified when lists are updated",
"groupActivitiesLabel": "Group Activities",
"groupActivitiesDescription": "Receive notifications for group activities"
},
"notifications": {
"profileLoadFailed": "Failed to load profile",
"profileUpdateSuccess": "Profile updated successfully",
"profileUpdateFailed": "Failed to update profile",
"passwordFieldsRequired": "Please fill in both current and new password fields.",
"passwordTooShort": "New password must be at least 8 characters long.",
"passwordChangeSuccess": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"preferencesUpdateSuccess": "Preferences updated successfully",
"preferencesUpdateFailed": "Failed to update preferences"
},
"saving": "Saving..."
},
"signupPage": {
"header": "Sign Up",
"fullNameLabel": "Full Name",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm Password",
"togglePasswordVisibility": "Toggle password visibility",
"submitButton": "Sign Up",
"loginLink": "Already have an account? Login",
"validation": {
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"passwordLength": "Password must be at least 8 characters",
"confirmPasswordRequired": "Please confirm your password",
"passwordsNoMatch": "Passwords do not match"
},
"notifications": {
"signupFailed": "Signup failed. Please try again.",
"signupSuccess": "Account created successfully. Please login."
}
},
"listDetailPage": {
"loading": {
"list": "Loading list...",
"items": "Loading items...",
"ocrProcessing": "Processing image...",
"addingOcrItems": "Adding OCR items...",
"costSummary": "Loading summary...",
"expenses": "Loading expenses...",
"settlement": "Processing settlement..."
},
"errors": {
"fetchFailed": "Failed to load list details.",
"genericLoadFailure": "Group not found or an error occurred.",
"ocrNoItems": "No items extracted from the image.",
"ocrFailed": "Failed to process image.",
"addItemFailed": "Failed to add item.",
"updateItemFailed": "Failed to update item.",
"updateItemPriceFailed": "Failed to update item price.",
"deleteItemFailed": "Failed to delete item.",
"addOcrItemsFailed": "Failed to add OCR items.",
"fetchItemsFailed": "Failed to load items: {errorMessage}",
"loadCostSummaryFailed": "Failed to load cost summary."
},
"retryButton": "Retry",
"buttons": {
"addViaOcr": "Add via OCR",
"addItem": "Add",
"addItems": "Add Items",
"cancel": "Cancel",
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
},
"badges": {
"groupList": "Group List",
"personalList": "Personal List"
},
"items": {
"emptyState": {
"title": "No Items Yet!",
"message": "Add some items using the form below."
},
"addItemForm": {
"placeholder": "Add a new item",
"quantityPlaceholder": "Qty",
"itemNameSrLabel": "New item name",
"quantitySrLabel": "Quantity"
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
},
"modals": {
"ocr": {
"title": "Add Items via OCR",
"uploadLabel": "Upload Image"
},
"confirmation": {
"title": "Confirmation"
},
"editItem": {
"title": "Edit Item",
"nameLabel": "Item Name",
"quantityLabel": "Quantity"
},
"costSummary": {
"title": "List Cost Summary",
"totalCostLabel": "Total List Cost:",
"equalShareLabel": "Equal Share Per User:",
"participantsLabel": "Participating Users:",
"userBalancesHeader": "User Balances",
"tableHeaders": {
"user": "User",
"itemsAddedValue": "Items Added Value",
"amountDue": "Amount Due",
"balance": "Balance"
},
"emptyState": "No cost summary available."
},
"settleShare": {
"title": "Settle Share",
"settleAmountFor": "Settle amount for {userName}:",
"amountLabel": "Amount",
"errors": {
"enterAmount": "Please enter an amount.",
"positiveAmount": "Please enter a positive amount.",
"exceedsRemaining": "Amount cannot exceed remaining: {amount}.",
"noSplitSelected": "Error: No split selected."
}
}
},
"confirmations": {
"updateMessage": "Mark '{itemName}' as {status}?",
"statusComplete": "complete",
"statusIncomplete": "incomplete",
"deleteMessage": "Delete '{itemName}'? This cannot be undone."
},
"notifications": {
"itemAddedSuccess": "Item added successfully.",
"itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.",
"itemUpdatedSuccess": "Item updated successfully.",
"itemDeleteSuccess": "Item deleted successfully.",
"enterItemName": "Please enter an item name.",
"costSummaryLoadFailed": "Failed to load cost summary.",
"cannotSettleOthersShares": "You can only settle your own shares.",
"settlementDataMissing": "Cannot process settlement: missing data.",
"settleShareSuccess": "Share settled successfully!",
"settleShareFailed": "Failed to settle share."
},
"expensesSection": {
"title": "Expenses",
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
"paidAmount": "Paid:",
"activityLabel": "Activity:",
"byUser": "by",
"settleShareButton": "Settle My Share",
"retryButton": "Retry"
},
"status": {
"settled": "Settled",
"partiallySettled": "Partially Settled",
"unsettled": "Unsettled",
"paid": "Paid",
"partiallyPaid": "Partially Paid",
"unpaid": "Unpaid",
"unknown": "Unknown Status"
}
},
"myChoresPage": {
"title": "My Assigned Chores",
"showCompletedToggle": "Show Completed",
"timelineHeaders": {
"overdue": "Overdue",
"today": "Due Today",
"thisWeek": "This Week",
"later": "Later",
"completed": "Completed"
},
"choreCard": {
"personal": "Personal",
"group": "Group",
"duePrefix": "Due",
"completedPrefix": "Completed",
"dueToday": "Due Today",
"markCompleteButton": "Mark Complete"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"unknownDate": "Unknown Date"
},
"emptyState": {
"title": "No Assignments Yet!",
"noAssignmentsPending": "You have no pending chore assignments.",
"noAssignmentsAll": "You have no chore assignments (completed or pending).",
"viewAllChoresButton": "View All Chores"
},
"notifications": {
"loadFailed": "Failed to load assignments",
"markedComplete": "Marked \"{choreName}\" as complete!",
"markCompleteFailed": "Failed to mark assignment as complete"
}
},
"personalChoresPage": {
"title": "Personal Chores",
"newChoreButton": "New Chore",
"editButton": "Edit",
"deleteButton": "Delete",
"cancelButton": "Cancel",
"saveButton": "Save",
"modals": {
"editChoreTitle": "Edit Chore",
"newChoreTitle": "New Chore",
"deleteChoreTitle": "Delete Chore"
},
"form": {
"nameLabel": "Name",
"descriptionLabel": "Description",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"dueDateLabel": "Next Due Date"
},
"deleteDialog": {
"confirmationText": "Are you sure you want to delete this chore?"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"duePrefix": "Due"
},
"notifications": {
"loadFailed": "Failed to load personal chores",
"updateSuccess": "Personal chore updated successfully",
"createSuccess": "Personal chore created successfully",
"saveFailed": "Failed to save personal chore",
"deleteSuccess": "Personal chore deleted successfully",
"deleteFailed": "Failed to delete personal chore"
}
},
"indexPage": {
"welcomeMessage": "Welcome to Valerie UI App",
"mainPageInfo": "This is the main index page.",
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
}
}

View File

@ -1,7 +0,0 @@
// This is just an example,
// so you can safely delete all default props below
export default {
failed: 'Action failed',
success: 'Action was successful'
};

561
fe/src/i18n/en.json Normal file
View File

@ -0,0 +1,561 @@
{
"message": {
"hello": "Hello"
},
"loginPage": {
"emailLabel": "Email",
"passwordLabel": "Password",
"togglePasswordVisibilityLabel": "Toggle password visibility",
"loginButton": "Login",
"signupLink": "Don't have an account? Sign up",
"errors": {
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"loginFailed": "Login failed. Please check your credentials."
},
"notifications": {
"loginSuccess": "Login successful"
}
},
"listsPage": {
"retryButton": "Retry",
"emptyState": {
"noListsForGroup": "No lists found for this group.",
"noListsYet": "You have no lists yet.",
"personalGlobalInfo": "Create a personal list or join a group to see shared lists.",
"groupSpecificInfo": "This group doesn't have any lists yet."
},
"createNewListButton": "Create New List",
"loadingLists": "Loading lists...",
"noDescription": "No description",
"addItemPlaceholder": "Add new item...",
"createCard": {
"title": "+ Create a new list"
},
"pageTitle": {
"forGroup": "Lists for {groupName}",
"forGroupId": "Lists for Group {groupId}",
"myLists": "My Lists"
},
"errors": {
"fetchFailed": "Failed to fetch lists."
}
},
"groupsPage": {
"retryButton": "Retry",
"emptyState": {
"title": "No Groups Yet!",
"description": "You are not a member of any groups yet. Create one or join using an invite code.",
"createButton": "Create New Group"
},
"groupCard": {
"newListButton": "List"
},
"createCard": {
"title": "+ Group"
},
"joinGroup": {
"title": "Join a Group with Invite Code",
"inputLabel": "Enter Invite Code",
"inputPlaceholder": "Enter Invite Code",
"joinButton": "Join"
},
"createDialog": {
"title": "Create New Group",
"closeButtonLabel": "Close",
"groupNameLabel": "Group Name",
"cancelButton": "Cancel",
"createButton": "Create"
},
"errors": {
"fetchFailed": "Failed to load groups",
"groupNameRequired": "Group name is required",
"createFailed": "Failed to create group. Please try again.",
"inviteCodeRequired": "Invite code is required",
"joinFailed": "Failed to join group. Please check the invite code and try again."
},
"notifications": {
"groupCreatedSuccess": "Group '{groupName}' created successfully.",
"joinSuccessNamed": "Successfully joined group '{groupName}'.",
"joinSuccessGeneric": "Successfully joined group.",
"listCreatedSuccess": "List '{listName}' created successfully."
}
},
"authCallbackPage": {
"redirecting": "Redirecting...",
"errors": {
"authenticationFailed": "Authentication failed"
}
},
"choresPage": {
"title": "Chores",
"tabs": {
"overdue": "Overdue",
"today": "Today",
"upcoming": "Upcoming",
"allPending": "All Pending",
"completed": "Completed"
},
"viewToggle": {
"calendarLabel": "Calendar View",
"calendarText": "Calendar",
"listLabel": "List View",
"listText": "List"
},
"newChoreButtonLabel": "New Chore",
"newChoreButtonText": "New Chore",
"loadingState": {
"loadingChores": "Loading chores..."
},
"calendar": {
"prevMonthLabel": "Previous month",
"nextMonthLabel": "Next month",
"weekdays": {
"sun": "Sun",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat"
},
"addChoreToDayLabel": "Add chore to this day",
"emptyState": "No chores to display for this period."
},
"listView": {
"choreTypePersonal": "Personal",
"choreTypeGroupFallback": "Group",
"completedDatePrefix": "Completed:",
"actions": {
"doneTitle": "Mark as Done",
"doneText": "Done",
"undoTitle": "Mark as Not Done",
"undoText": "Undo",
"editTitle": "Edit",
"editLabel": "Edit chore",
"editText": "Edit",
"deleteTitle": "Delete",
"deleteLabel": "Delete chore",
"deleteText": "Delete"
},
"emptyState": {
"message": "No chores in this view. Well done!",
"viewAllButton": "View All Pending"
}
},
"choreModal": {
"editTitle": "Edit Chore",
"newTitle": "New Chore",
"closeButtonLabel": "Close modal",
"nameLabel": "Name",
"namePlaceholder": "Enter chore name",
"typeLabel": "Type",
"typePersonal": "Personal",
"typeGroup": "Group",
"groupLabel": "Group",
"groupSelectDefault": "Select a group",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Add a description (optional)",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"intervalPlaceholder": "e.g. 3",
"dueDateLabel": "Due Date",
"quickDueDateToday": "Today",
"quickDueDateTomorrow": "Tomorrow",
"quickDueDateNextWeek": "Next Week",
"cancelButton": "Cancel",
"saveButton": "Save"
},
"deleteDialog": {
"title": "Delete Chore",
"confirmationText": "Are you sure you want to delete this chore? This action cannot be undone.",
"deleteButton": "Delete"
},
"shortcutsModal": {
"title": "Keyboard Shortcuts",
"descNewChore": "New Chore",
"descToggleView": "Toggle View (List/Calendar)",
"descToggleShortcuts": "Show/Hide Shortcuts",
"descCloseModal": "Close any open Modal/Dialog"
},
"frequencyOptions": {
"oneTime": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom"
},
"formatters": {
"noDueDate": "No due date",
"dueToday": "Due Today",
"dueTomorrow": "Due Tomorrow",
"overdueFull": "Overdue: {date}",
"dueFull": "Due {date}",
"invalidDate": "Invalid Date"
},
"notifications": {
"loadFailed": "Failed to load chores",
"updateSuccess": "Chore '{name}' updated successfully",
"createSuccess": "Chore '{name}' created successfully",
"updateFailed": "Failed to update chore",
"createFailed": "Failed to create chore",
"deleteSuccess": "Chore '{name}' deleted successfully",
"deleteFailed": "Failed to delete chore",
"markedDone": "{name} marked as done.",
"markedNotDone": "{name} marked as not done.",
"statusUpdateFailed": "Failed to update chore status."
},
"validation": {
"nameRequired": "Chore name is required.",
"groupRequired": "Please select a group for group chores.",
"intervalRequired": "Custom interval must be at least 1 day.",
"dueDateRequired": "Due date is required.",
"invalidDueDate": "Invalid due date format."
},
"unsavedChangesConfirmation": "You have unsaved changes in the chore form. Are you sure you want to leave?"
},
"errorNotFoundPage": {
"errorCode": "404",
"errorMessage": "Oops. Nothing here...",
"goHomeButton": "Go Home"
},
"groupDetailPage": {
"loadingLabel": "Loading group details...",
"retryButton": "Retry",
"groupNotFound": "Group not found or an error occurred.",
"members": {
"title": "Group Members",
"defaultRole": "Member",
"removeButton": "Remove",
"emptyState": "No members found."
},
"invites": {
"title": "Invite Members",
"regenerateButton": "Regenerate Invite Code",
"generateButton": "Generate Invite Code",
"activeCodeLabel": "Current Active Invite Code:",
"copyButtonLabel": "Copy invite code",
"copySuccess": "Invite code copied to clipboard!",
"emptyState": "No active invite code. Click the button above to generate one.",
"errors": {
"newDataInvalid": "New invite code data is invalid."
}
},
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
"duePrefix": "Due:",
"emptyState": "No chores scheduled. Click \"Manage Chores\" to create some!"
},
"expenses": {
"title": "Group Expenses",
"manageButton": "Manage Expenses",
"emptyState": "No expenses recorded. Click \"Manage Expenses\" to add some!",
"splitTypes": {
"equal": "Equal",
"exactAmounts": "Exact Amounts",
"percentage": "Percentage",
"shares": "Shares",
"itemBased": "Item Based"
}
},
"notifications": {
"fetchDetailsFailed": "Failed to fetch group details.",
"fetchInviteFailed": "Failed to fetch active invite code.",
"generateInviteSuccess": "New invite code generated successfully!",
"generateInviteError": "Failed to generate invite code.",
"clipboardNotSupported": "Clipboard not supported or no code to copy.",
"copyInviteFailed": "Failed to copy invite code.",
"removeMemberSuccess": "Member removed successfully",
"removeMemberFailed": "Failed to remove member"
}
},
"accountPage": {
"title": "Account Settings",
"loadingProfile": "Loading profile...",
"retryButton": "Retry",
"profileSection": {
"header": "Profile Information",
"nameLabel": "Name",
"emailLabel": "Email",
"saveButton": "Save Changes"
},
"passwordSection": {
"header": "Change Password",
"currentPasswordLabel": "Current Password",
"newPasswordLabel": "New Password",
"changeButton": "Change Password"
},
"notificationsSection": {
"header": "Notification Preferences",
"emailNotificationsLabel": "Email Notifications",
"emailNotificationsDescription": "Receive email notifications for important updates",
"listUpdatesLabel": "List Updates",
"listUpdatesDescription": "Get notified when lists are updated",
"groupActivitiesLabel": "Group Activities",
"groupActivitiesDescription": "Receive notifications for group activities"
},
"notifications": {
"profileLoadFailed": "Failed to load profile",
"profileUpdateSuccess": "Profile updated successfully",
"profileUpdateFailed": "Failed to update profile",
"passwordFieldsRequired": "Please fill in both current and new password fields.",
"passwordTooShort": "New password must be at least 8 characters long.",
"passwordChangeSuccess": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"preferencesUpdateSuccess": "Preferences updated successfully",
"preferencesUpdateFailed": "Failed to update preferences"
},
"saving": "Saving..."
},
"signupPage": {
"header": "Sign Up",
"fullNameLabel": "Full Name",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm Password",
"togglePasswordVisibility": "Toggle password visibility",
"submitButton": "Sign Up",
"loginLink": "Already have an account? Login",
"validation": {
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"passwordLength": "Password must be at least 8 characters",
"confirmPasswordRequired": "Please confirm your password",
"passwordsNoMatch": "Passwords do not match"
},
"notifications": {
"signupFailed": "Signup failed. Please try again.",
"signupSuccess": "Account created successfully. Please login."
}
},
"listDetailPage": {
"loading": {
"list": "Loading list...",
"items": "Loading items...",
"ocrProcessing": "Processing image...",
"addingOcrItems": "Adding OCR items...",
"costSummary": "Loading summary...",
"expenses": "Loading expenses...",
"settlement": "Processing settlement..."
},
"errors": {
"fetchFailed": "Failed to load list details.",
"genericLoadFailure": "Group not found or an error occurred.",
"ocrNoItems": "No items extracted from the image.",
"ocrFailed": "Failed to process image.",
"addItemFailed": "Failed to add item.",
"updateItemFailed": "Failed to update item.",
"updateItemPriceFailed": "Failed to update item price.",
"deleteItemFailed": "Failed to delete item.",
"addOcrItemsFailed": "Failed to add OCR items.",
"fetchItemsFailed": "Failed to load items: {errorMessage}",
"loadCostSummaryFailed": "Failed to load cost summary."
},
"retryButton": "Retry",
"buttons": {
"addViaOcr": "Add via OCR",
"addItem": "Add",
"addItems": "Add Items",
"cancel": "Cancel",
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
},
"badges": {
"groupList": "Group List",
"personalList": "Personal List"
},
"items": {
"emptyState": {
"title": "No Items Yet!",
"message": "Add some items using the form below."
},
"addItemForm": {
"placeholder": "Add a new item",
"quantityPlaceholder": "Qty",
"itemNameSrLabel": "New item name",
"quantitySrLabel": "Quantity"
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
},
"modals": {
"ocr": {
"title": "Add Items via OCR",
"uploadLabel": "Upload Image"
},
"confirmation": {
"title": "Confirmation"
},
"editItem": {
"title": "Edit Item",
"nameLabel": "Item Name",
"quantityLabel": "Quantity"
},
"costSummary": {
"title": "List Cost Summary",
"totalCostLabel": "Total List Cost:",
"equalShareLabel": "Equal Share Per User:",
"participantsLabel": "Participating Users:",
"userBalancesHeader": "User Balances",
"tableHeaders": {
"user": "User",
"itemsAddedValue": "Items Added Value",
"amountDue": "Amount Due",
"balance": "Balance"
},
"emptyState": "No cost summary available."
},
"settleShare": {
"title": "Settle Share",
"settleAmountFor": "Settle amount for {userName}:",
"amountLabel": "Amount",
"errors": {
"enterAmount": "Please enter an amount.",
"positiveAmount": "Please enter a positive amount.",
"exceedsRemaining": "Amount cannot exceed remaining: {amount}.",
"noSplitSelected": "Error: No split selected."
}
}
},
"confirmations": {
"updateMessage": "Mark '{itemName}' as {status}?",
"statusComplete": "complete",
"statusIncomplete": "incomplete",
"deleteMessage": "Delete '{itemName}'? This cannot be undone."
},
"notifications": {
"itemAddedSuccess": "Item added successfully.",
"itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.",
"itemUpdatedSuccess": "Item updated successfully.",
"itemDeleteSuccess": "Item deleted successfully.",
"enterItemName": "Please enter an item name.",
"costSummaryLoadFailed": "Failed to load cost summary.",
"cannotSettleOthersShares": "You can only settle your own shares.",
"settlementDataMissing": "Cannot process settlement: missing data.",
"settleShareSuccess": "Share settled successfully!",
"settleShareFailed": "Failed to settle share."
},
"expensesSection": {
"title": "Expenses",
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
"paidAmount": "Paid:",
"activityLabel": "Activity:",
"byUser": "by",
"settleShareButton": "Settle My Share",
"retryButton": "Retry"
},
"status": {
"settled": "Settled",
"partiallySettled": "Partially Settled",
"unsettled": "Unsettled",
"paid": "Paid",
"partiallyPaid": "Partially Paid",
"unpaid": "Unpaid",
"unknown": "Unknown Status"
}
},
"myChoresPage": {
"title": "My Assigned Chores",
"showCompletedToggle": "Show Completed",
"timelineHeaders": {
"overdue": "Overdue",
"today": "Due Today",
"thisWeek": "This Week",
"later": "Later",
"completed": "Completed"
},
"choreCard": {
"personal": "Personal",
"group": "Group",
"duePrefix": "Due",
"completedPrefix": "Completed",
"dueToday": "Due Today",
"markCompleteButton": "Mark Complete"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"unknownDate": "Unknown Date"
},
"emptyState": {
"title": "No Assignments Yet!",
"noAssignmentsPending": "You have no pending chore assignments.",
"noAssignmentsAll": "You have no chore assignments (completed or pending).",
"viewAllChoresButton": "View All Chores"
},
"notifications": {
"loadFailed": "Failed to load assignments",
"markedComplete": "Marked \"{choreName}\" as complete!",
"markCompleteFailed": "Failed to mark assignment as complete"
}
},
"personalChoresPage": {
"title": "Personal Chores",
"newChoreButton": "New Chore",
"editButton": "Edit",
"deleteButton": "Delete",
"cancelButton": "Cancel",
"saveButton": "Save",
"modals": {
"editChoreTitle": "Edit Chore",
"newChoreTitle": "New Chore",
"deleteChoreTitle": "Delete Chore"
},
"form": {
"nameLabel": "Name",
"descriptionLabel": "Description",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"dueDateLabel": "Next Due Date"
},
"deleteDialog": {
"confirmationText": "Are you sure you want to delete this chore?"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"duePrefix": "Due"
},
"notifications": {
"loadFailed": "Failed to load personal chores",
"updateSuccess": "Personal chore updated successfully",
"createSuccess": "Personal chore created successfully",
"saveFailed": "Failed to save personal chore",
"deleteSuccess": "Personal chore deleted successfully",
"deleteFailed": "Failed to delete personal chore"
}
},
"indexPage": {
"welcomeMessage": "Welcome to Valerie UI App",
"mainPageInfo": "This is the main index page.",
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
}
}

561
fe/src/i18n/es.json Normal file
View File

@ -0,0 +1,561 @@
{
"message": {
"hello": "Hola"
},
"loginPage": {
"emailLabel": "ES: Email",
"passwordLabel": "ES: Password",
"togglePasswordVisibilityLabel": "ES: Toggle password visibility",
"loginButton": "ES: Login",
"signupLink": "ES: Don't have an account? Sign up",
"errors": {
"emailRequired": "ES: Email is required",
"emailInvalid": "ES: Invalid email format",
"passwordRequired": "ES: Password is required",
"loginFailed": "ES: Login failed. Please check your credentials."
},
"notifications": {
"loginSuccess": "ES: Login successful"
}
},
"listsPage": {
"retryButton": "ES: Retry",
"emptyState": {
"noListsForGroup": "ES: No lists found for this group.",
"noListsYet": "ES: You have no lists yet.",
"personalGlobalInfo": "ES: Create a personal list or join a group to see shared lists.",
"groupSpecificInfo": "ES: This group doesn't have any lists yet."
},
"createNewListButton": "ES: Create New List",
"loadingLists": "ES: Loading lists...",
"noDescription": "ES: No description",
"addItemPlaceholder": "ES: Add new item...",
"createCard": {
"title": "ES: + Create a new list"
},
"pageTitle": {
"forGroup": "ES: Lists for {groupName}",
"forGroupId": "ES: Lists for Group {groupId}",
"myLists": "ES: My Lists"
},
"errors": {
"fetchFailed": "ES: Failed to fetch lists."
}
},
"groupsPage": {
"retryButton": "ES: Retry",
"emptyState": {
"title": "ES: No Groups Yet!",
"description": "ES: You are not a member of any groups yet. Create one or join using an invite code.",
"createButton": "ES: Create New Group"
},
"groupCard": {
"newListButton": "ES: List"
},
"createCard": {
"title": "ES: + Group"
},
"joinGroup": {
"title": "ES: Join a Group with Invite Code",
"inputLabel": "ES: Enter Invite Code",
"inputPlaceholder": "ES: Enter Invite Code",
"joinButton": "ES: Join"
},
"createDialog": {
"title": "ES: Create New Group",
"closeButtonLabel": "ES: Close",
"groupNameLabel": "ES: Group Name",
"cancelButton": "ES: Cancel",
"createButton": "ES: Create"
},
"errors": {
"fetchFailed": "ES: Failed to load groups",
"groupNameRequired": "ES: Group name is required",
"createFailed": "ES: Failed to create group. Please try again.",
"inviteCodeRequired": "ES: Invite code is required",
"joinFailed": "ES: Failed to join group. Please check the invite code and try again."
},
"notifications": {
"groupCreatedSuccess": "ES: Group '{groupName}' created successfully.",
"joinSuccessNamed": "ES: Successfully joined group '{groupName}'.",
"joinSuccessGeneric": "ES: Successfully joined group.",
"listCreatedSuccess": "ES: List '{listName}' created successfully."
}
},
"authCallbackPage": {
"redirecting": "ES: Redirecting...",
"errors": {
"authenticationFailed": "ES: Authentication failed"
}
},
"choresPage": {
"title": "ES: Chores",
"tabs": {
"overdue": "ES: Overdue",
"today": "ES: Today",
"upcoming": "ES: Upcoming",
"allPending": "ES: All Pending",
"completed": "ES: Completed"
},
"viewToggle": {
"calendarLabel": "ES: Calendar View",
"calendarText": "ES: Calendar",
"listLabel": "ES: List View",
"listText": "ES: List"
},
"newChoreButtonLabel": "ES: New Chore",
"newChoreButtonText": "ES: New Chore",
"loadingState": {
"loadingChores": "ES: Loading chores..."
},
"calendar": {
"prevMonthLabel": "ES: Previous month",
"nextMonthLabel": "ES: Next month",
"weekdays": {
"sun": "ES: Sun",
"mon": "ES: Mon",
"tue": "ES: Tue",
"wed": "ES: Wed",
"thu": "ES: Thu",
"fri": "ES: Fri",
"sat": "ES: Sat"
},
"addChoreToDayLabel": "ES: Add chore to this day",
"emptyState": "ES: No chores to display for this period."
},
"listView": {
"choreTypePersonal": "ES: Personal",
"choreTypeGroupFallback": "ES: Group",
"completedDatePrefix": "ES: Completed:",
"actions": {
"doneTitle": "ES: Mark as Done",
"doneText": "ES: Done",
"undoTitle": "ES: Mark as Not Done",
"undoText": "ES: Undo",
"editTitle": "ES: Edit",
"editLabel": "ES: Edit chore",
"editText": "ES: Edit",
"deleteTitle": "ES: Delete",
"deleteLabel": "ES: Delete chore",
"deleteText": "ES: Delete"
},
"emptyState": {
"message": "ES: No chores in this view. Well done!",
"viewAllButton": "ES: View All Pending"
}
},
"choreModal": {
"editTitle": "ES: Edit Chore",
"newTitle": "ES: New Chore",
"closeButtonLabel": "ES: Close modal",
"nameLabel": "ES: Name",
"namePlaceholder": "ES: Enter chore name",
"typeLabel": "ES: Type",
"typePersonal": "ES: Personal",
"typeGroup": "ES: Group",
"groupLabel": "ES: Group",
"groupSelectDefault": "ES: Select a group",
"descriptionLabel": "ES: Description",
"descriptionPlaceholder": "ES: Add a description (optional)",
"frequencyLabel": "ES: Frequency",
"intervalLabel": "ES: Interval (days)",
"intervalPlaceholder": "ES: e.g. 3",
"dueDateLabel": "ES: Due Date",
"quickDueDateToday": "ES: Today",
"quickDueDateTomorrow": "ES: Tomorrow",
"quickDueDateNextWeek": "ES: Next Week",
"cancelButton": "ES: Cancel",
"saveButton": "ES: Save"
},
"deleteDialog": {
"title": "ES: Delete Chore",
"confirmationText": "ES: Are you sure you want to delete this chore? This action cannot be undone.",
"deleteButton": "ES: Delete"
},
"shortcutsModal": {
"title": "ES: Keyboard Shortcuts",
"descNewChore": "ES: New Chore",
"descToggleView": "ES: Toggle View (List/Calendar)",
"descToggleShortcuts": "ES: Show/Hide Shortcuts",
"descCloseModal": "ES: Close any open Modal/Dialog"
},
"frequencyOptions": {
"oneTime": "ES: One Time",
"daily": "ES: Daily",
"weekly": "ES: Weekly",
"monthly": "ES: Monthly",
"custom": "ES: Custom"
},
"formatters": {
"noDueDate": "ES: No due date",
"dueToday": "ES: Due Today",
"dueTomorrow": "ES: Due Tomorrow",
"overdueFull": "ES: Overdue: {date}",
"dueFull": "ES: Due {date}",
"invalidDate": "ES: Invalid Date"
},
"notifications": {
"loadFailed": "ES: Failed to load chores",
"updateSuccess": "ES: Chore '{name}' updated successfully",
"createSuccess": "ES: Chore '{name}' created successfully",
"updateFailed": "ES: Failed to update chore",
"createFailed": "ES: Failed to create chore",
"deleteSuccess": "ES: Chore '{name}' deleted successfully",
"deleteFailed": "ES: Failed to delete chore",
"markedDone": "ES: {name} marked as done.",
"markedNotDone": "ES: {name} marked as not done.",
"statusUpdateFailed": "ES: Failed to update chore status."
},
"validation": {
"nameRequired": "ES: Chore name is required.",
"groupRequired": "ES: Please select a group for group chores.",
"intervalRequired": "ES: Custom interval must be at least 1 day.",
"dueDateRequired": "ES: Due date is required.",
"invalidDueDate": "ES: Invalid due date format."
},
"unsavedChangesConfirmation": "ES: You have unsaved changes in the chore form. Are you sure you want to leave?"
},
"errorNotFoundPage": {
"errorCode": "ES: 404",
"errorMessage": "ES: Oops. Nothing here...",
"goHomeButton": "ES: Go Home"
},
"groupDetailPage": {
"loadingLabel": "ES: Loading group details...",
"retryButton": "ES: Retry",
"groupNotFound": "ES: Group not found or an error occurred.",
"members": {
"title": "ES: Group Members",
"defaultRole": "ES: Member",
"removeButton": "ES: Remove",
"emptyState": "ES: No members found."
},
"invites": {
"title": "ES: Invite Members",
"regenerateButton": "ES: Regenerate Invite Code",
"generateButton": "ES: Generate Invite Code",
"activeCodeLabel": "ES: Current Active Invite Code:",
"copyButtonLabel": "ES: Copy invite code",
"copySuccess": "ES: Invite code copied to clipboard!",
"emptyState": "ES: No active invite code. Click the button above to generate one.",
"errors": {
"newDataInvalid": "ES: New invite code data is invalid."
}
},
"chores": {
"title": "ES: Group Chores",
"manageButton": "ES: Manage Chores",
"duePrefix": "ES: Due:",
"emptyState": "ES: No chores scheduled. Click \"Manage Chores\" to create some!"
},
"expenses": {
"title": "ES: Group Expenses",
"manageButton": "ES: Manage Expenses",
"emptyState": "ES: No expenses recorded. Click \"Manage Expenses\" to add some!",
"splitTypes": {
"equal": "ES: Equal",
"exactAmounts": "ES: Exact Amounts",
"percentage": "ES: Percentage",
"shares": "ES: Shares",
"itemBased": "ES: Item Based"
}
},
"notifications": {
"fetchDetailsFailed": "ES: Failed to fetch group details.",
"fetchInviteFailed": "ES: Failed to fetch active invite code.",
"generateInviteSuccess": "ES: New invite code generated successfully!",
"generateInviteError": "ES: Failed to generate invite code.",
"clipboardNotSupported": "ES: Clipboard not supported or no code to copy.",
"copyInviteFailed": "ES: Failed to copy invite code.",
"removeMemberSuccess": "ES: Member removed successfully",
"removeMemberFailed": "ES: Failed to remove member"
}
},
"accountPage": {
"title": "Account Settings",
"loadingProfile": "Loading profile...",
"retryButton": "Retry",
"profileSection": {
"header": "Profile Information",
"nameLabel": "Name",
"emailLabel": "Email",
"saveButton": "Save Changes"
},
"passwordSection": {
"header": "Change Password",
"currentPasswordLabel": "Current Password",
"newPasswordLabel": "New Password",
"changeButton": "Change Password"
},
"notificationsSection": {
"header": "Notification Preferences",
"emailNotificationsLabel": "Email Notifications",
"emailNotificationsDescription": "Receive email notifications for important updates",
"listUpdatesLabel": "List Updates",
"listUpdatesDescription": "Get notified when lists are updated",
"groupActivitiesLabel": "Group Activities",
"groupActivitiesDescription": "Receive notifications for group activities"
},
"notifications": {
"profileLoadFailed": "Failed to load profile",
"profileUpdateSuccess": "Profile updated successfully",
"profileUpdateFailed": "Failed to update profile",
"passwordFieldsRequired": "Please fill in both current and new password fields.",
"passwordTooShort": "New password must be at least 8 characters long.",
"passwordChangeSuccess": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"preferencesUpdateSuccess": "Preferences updated successfully",
"preferencesUpdateFailed": "Failed to update preferences"
},
"saving": "Saving..."
},
"signupPage": {
"header": "Sign Up",
"fullNameLabel": "Full Name",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm Password",
"togglePasswordVisibility": "Toggle password visibility",
"submitButton": "Sign Up",
"loginLink": "Already have an account? Login",
"validation": {
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"passwordLength": "Password must be at least 8 characters",
"confirmPasswordRequired": "Please confirm your password",
"passwordsNoMatch": "Passwords do not match"
},
"notifications": {
"signupFailed": "Signup failed. Please try again.",
"signupSuccess": "Account created successfully. Please login."
}
},
"listDetailPage": {
"loading": {
"list": "Loading list...",
"items": "Loading items...",
"ocrProcessing": "Processing image...",
"addingOcrItems": "Adding OCR items...",
"costSummary": "Loading summary...",
"expenses": "Loading expenses...",
"settlement": "Processing settlement..."
},
"errors": {
"fetchFailed": "Failed to load list details.",
"genericLoadFailure": "Group not found or an error occurred.",
"ocrNoItems": "No items extracted from the image.",
"ocrFailed": "Failed to process image.",
"addItemFailed": "Failed to add item.",
"updateItemFailed": "Failed to update item.",
"updateItemPriceFailed": "Failed to update item price.",
"deleteItemFailed": "Failed to delete item.",
"addOcrItemsFailed": "Failed to add OCR items.",
"fetchItemsFailed": "Failed to load items: {errorMessage}",
"loadCostSummaryFailed": "Failed to load cost summary."
},
"retryButton": "Retry",
"buttons": {
"addViaOcr": "Add via OCR",
"addItem": "Add",
"addItems": "Add Items",
"cancel": "Cancel",
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
},
"badges": {
"groupList": "Group List",
"personalList": "Personal List"
},
"items": {
"emptyState": {
"title": "No Items Yet!",
"message": "Add some items using the form below."
},
"addItemForm": {
"placeholder": "Add a new item",
"quantityPlaceholder": "Qty",
"itemNameSrLabel": "New item name",
"quantitySrLabel": "Quantity"
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
},
"modals": {
"ocr": {
"title": "Add Items via OCR",
"uploadLabel": "Upload Image"
},
"confirmation": {
"title": "Confirmation"
},
"editItem": {
"title": "Edit Item",
"nameLabel": "Item Name",
"quantityLabel": "Quantity"
},
"costSummary": {
"title": "List Cost Summary",
"totalCostLabel": "Total List Cost:",
"equalShareLabel": "Equal Share Per User:",
"participantsLabel": "Participating Users:",
"userBalancesHeader": "User Balances",
"tableHeaders": {
"user": "User",
"itemsAddedValue": "Items Added Value",
"amountDue": "Amount Due",
"balance": "Balance"
},
"emptyState": "No cost summary available."
},
"settleShare": {
"title": "Settle Share",
"settleAmountFor": "Settle amount for {userName}:",
"amountLabel": "Amount",
"errors": {
"enterAmount": "Please enter an amount.",
"positiveAmount": "Please enter a positive amount.",
"exceedsRemaining": "Amount cannot exceed remaining: {amount}.",
"noSplitSelected": "Error: No split selected."
}
}
},
"confirmations": {
"updateMessage": "Mark '{itemName}' as {status}?",
"statusComplete": "complete",
"statusIncomplete": "incomplete",
"deleteMessage": "Delete '{itemName}'? This cannot be undone."
},
"notifications": {
"itemAddedSuccess": "Item added successfully.",
"itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.",
"itemUpdatedSuccess": "Item updated successfully.",
"itemDeleteSuccess": "Item deleted successfully.",
"enterItemName": "Please enter an item name.",
"costSummaryLoadFailed": "Failed to load cost summary.",
"cannotSettleOthersShares": "You can only settle your own shares.",
"settlementDataMissing": "Cannot process settlement: missing data.",
"settleShareSuccess": "Share settled successfully!",
"settleShareFailed": "Failed to settle share."
},
"expensesSection": {
"title": "Expenses",
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
"paidAmount": "Paid:",
"activityLabel": "Activity:",
"byUser": "by",
"settleShareButton": "Settle My Share",
"retryButton": "Retry"
},
"status": {
"settled": "Settled",
"partiallySettled": "Partially Settled",
"unsettled": "Unsettled",
"paid": "Paid",
"partiallyPaid": "Partially Paid",
"unpaid": "Unpaid",
"unknown": "Unknown Status"
}
},
"myChoresPage": {
"title": "My Assigned Chores",
"showCompletedToggle": "Show Completed",
"timelineHeaders": {
"overdue": "Overdue",
"today": "Due Today",
"thisWeek": "This Week",
"later": "Later",
"completed": "Completed"
},
"choreCard": {
"personal": "Personal",
"group": "Group",
"duePrefix": "Due",
"completedPrefix": "Completed",
"dueToday": "Due Today",
"markCompleteButton": "Mark Complete"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"unknownDate": "Unknown Date"
},
"emptyState": {
"title": "No Assignments Yet!",
"noAssignmentsPending": "You have no pending chore assignments.",
"noAssignmentsAll": "You have no chore assignments (completed or pending).",
"viewAllChoresButton": "View All Chores"
},
"notifications": {
"loadFailed": "Failed to load assignments",
"markedComplete": "Marked \"{choreName}\" as complete!",
"markCompleteFailed": "Failed to mark assignment as complete"
}
},
"personalChoresPage": {
"title": "Personal Chores",
"newChoreButton": "New Chore",
"editButton": "Edit",
"deleteButton": "Delete",
"cancelButton": "Cancel",
"saveButton": "Save",
"modals": {
"editChoreTitle": "Edit Chore",
"newChoreTitle": "New Chore",
"deleteChoreTitle": "Delete Chore"
},
"form": {
"nameLabel": "Name",
"descriptionLabel": "Description",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"dueDateLabel": "Next Due Date"
},
"deleteDialog": {
"confirmationText": "Are you sure you want to delete this chore?"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"duePrefix": "Due"
},
"notifications": {
"loadFailed": "Failed to load personal chores",
"updateSuccess": "Personal chore updated successfully",
"createSuccess": "Personal chore created successfully",
"saveFailed": "Failed to save personal chore",
"deleteSuccess": "Personal chore deleted successfully",
"deleteFailed": "Failed to delete personal chore"
}
},
"indexPage": {
"welcomeMessage": "Welcome to Valerie UI App",
"mainPageInfo": "This is the main index page.",
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
}
}

561
fe/src/i18n/fr.json Normal file
View File

@ -0,0 +1,561 @@
{
"message": {
"hello": "Bonjour"
},
"loginPage": {
"emailLabel": "FR: Email",
"passwordLabel": "FR: Password",
"togglePasswordVisibilityLabel": "FR: Toggle password visibility",
"loginButton": "FR: Login",
"signupLink": "FR: Don't have an account? Sign up",
"errors": {
"emailRequired": "FR: Email is required",
"emailInvalid": "FR: Invalid email format",
"passwordRequired": "FR: Password is required",
"loginFailed": "FR: Login failed. Please check your credentials."
},
"notifications": {
"loginSuccess": "FR: Login successful"
}
},
"listsPage": {
"retryButton": "FR: Retry",
"emptyState": {
"noListsForGroup": "FR: No lists found for this group.",
"noListsYet": "FR: You have no lists yet.",
"personalGlobalInfo": "FR: Create a personal list or join a group to see shared lists.",
"groupSpecificInfo": "FR: This group doesn't have any lists yet."
},
"createNewListButton": "FR: Create New List",
"loadingLists": "FR: Loading lists...",
"noDescription": "FR: No description",
"addItemPlaceholder": "FR: Add new item...",
"createCard": {
"title": "FR: + Create a new list"
},
"pageTitle": {
"forGroup": "FR: Lists for {groupName}",
"forGroupId": "FR: Lists for Group {groupId}",
"myLists": "FR: My Lists"
},
"errors": {
"fetchFailed": "FR: Failed to fetch lists."
}
},
"groupsPage": {
"retryButton": "FR: Retry",
"emptyState": {
"title": "FR: No Groups Yet!",
"description": "FR: You are not a member of any groups yet. Create one or join using an invite code.",
"createButton": "FR: Create New Group"
},
"groupCard": {
"newListButton": "FR: List"
},
"createCard": {
"title": "FR: + Group"
},
"joinGroup": {
"title": "FR: Join a Group with Invite Code",
"inputLabel": "FR: Enter Invite Code",
"inputPlaceholder": "FR: Enter Invite Code",
"joinButton": "FR: Join"
},
"createDialog": {
"title": "FR: Create New Group",
"closeButtonLabel": "FR: Close",
"groupNameLabel": "FR: Group Name",
"cancelButton": "FR: Cancel",
"createButton": "FR: Create"
},
"errors": {
"fetchFailed": "FR: Failed to load groups",
"groupNameRequired": "FR: Group name is required",
"createFailed": "FR: Failed to create group. Please try again.",
"inviteCodeRequired": "FR: Invite code is required",
"joinFailed": "FR: Failed to join group. Please check the invite code and try again."
},
"notifications": {
"groupCreatedSuccess": "FR: Group '{groupName}' created successfully.",
"joinSuccessNamed": "FR: Successfully joined group '{groupName}'.",
"joinSuccessGeneric": "FR: Successfully joined group.",
"listCreatedSuccess": "FR: List '{listName}' created successfully."
}
},
"authCallbackPage": {
"redirecting": "FR: Redirecting...",
"errors": {
"authenticationFailed": "FR: Authentication failed"
}
},
"choresPage": {
"title": "FR: Chores",
"tabs": {
"overdue": "FR: Overdue",
"today": "FR: Today",
"upcoming": "FR: Upcoming",
"allPending": "FR: All Pending",
"completed": "FR: Completed"
},
"viewToggle": {
"calendarLabel": "FR: Calendar View",
"calendarText": "FR: Calendar",
"listLabel": "FR: List View",
"listText": "FR: List"
},
"newChoreButtonLabel": "FR: New Chore",
"newChoreButtonText": "FR: New Chore",
"loadingState": {
"loadingChores": "FR: Loading chores..."
},
"calendar": {
"prevMonthLabel": "FR: Previous month",
"nextMonthLabel": "FR: Next month",
"weekdays": {
"sun": "FR: Sun",
"mon": "FR: Mon",
"tue": "FR: Tue",
"wed": "FR: Wed",
"thu": "FR: Thu",
"fri": "FR: Fri",
"sat": "FR: Sat"
},
"addChoreToDayLabel": "FR: Add chore to this day",
"emptyState": "FR: No chores to display for this period."
},
"listView": {
"choreTypePersonal": "FR: Personal",
"choreTypeGroupFallback": "FR: Group",
"completedDatePrefix": "FR: Completed:",
"actions": {
"doneTitle": "FR: Mark as Done",
"doneText": "FR: Done",
"undoTitle": "FR: Mark as Not Done",
"undoText": "FR: Undo",
"editTitle": "FR: Edit",
"editLabel": "FR: Edit chore",
"editText": "FR: Edit",
"deleteTitle": "FR: Delete",
"deleteLabel": "FR: Delete chore",
"deleteText": "FR: Delete"
},
"emptyState": {
"message": "FR: No chores in this view. Well done!",
"viewAllButton": "FR: View All Pending"
}
},
"choreModal": {
"editTitle": "FR: Edit Chore",
"newTitle": "FR: New Chore",
"closeButtonLabel": "FR: Close modal",
"nameLabel": "FR: Name",
"namePlaceholder": "FR: Enter chore name",
"typeLabel": "FR: Type",
"typePersonal": "FR: Personal",
"typeGroup": "FR: Group",
"groupLabel": "FR: Group",
"groupSelectDefault": "FR: Select a group",
"descriptionLabel": "FR: Description",
"descriptionPlaceholder": "FR: Add a description (optional)",
"frequencyLabel": "FR: Frequency",
"intervalLabel": "FR: Interval (days)",
"intervalPlaceholder": "FR: e.g. 3",
"dueDateLabel": "FR: Due Date",
"quickDueDateToday": "FR: Today",
"quickDueDateTomorrow": "FR: Tomorrow",
"quickDueDateNextWeek": "FR: Next Week",
"cancelButton": "FR: Cancel",
"saveButton": "FR: Save"
},
"deleteDialog": {
"title": "FR: Delete Chore",
"confirmationText": "FR: Are you sure you want to delete this chore? This action cannot be undone.",
"deleteButton": "FR: Delete"
},
"shortcutsModal": {
"title": "FR: Keyboard Shortcuts",
"descNewChore": "FR: New Chore",
"descToggleView": "FR: Toggle View (List/Calendar)",
"descToggleShortcuts": "FR: Show/Hide Shortcuts",
"descCloseModal": "FR: Close any open Modal/Dialog"
},
"frequencyOptions": {
"oneTime": "FR: One Time",
"daily": "FR: Daily",
"weekly": "FR: Weekly",
"monthly": "FR: Monthly",
"custom": "FR: Custom"
},
"formatters": {
"noDueDate": "FR: No due date",
"dueToday": "FR: Due Today",
"dueTomorrow": "FR: Due Tomorrow",
"overdueFull": "FR: Overdue: {date}",
"dueFull": "FR: Due {date}",
"invalidDate": "FR: Invalid Date"
},
"notifications": {
"loadFailed": "FR: Failed to load chores",
"updateSuccess": "FR: Chore '{name}' updated successfully",
"createSuccess": "FR: Chore '{name}' created successfully",
"updateFailed": "FR: Failed to update chore",
"createFailed": "FR: Failed to create chore",
"deleteSuccess": "FR: Chore '{name}' deleted successfully",
"deleteFailed": "FR: Failed to delete chore",
"markedDone": "FR: {name} marked as done.",
"markedNotDone": "FR: {name} marked as not done.",
"statusUpdateFailed": "FR: Failed to update chore status."
},
"validation": {
"nameRequired": "FR: Chore name is required.",
"groupRequired": "FR: Please select a group for group chores.",
"intervalRequired": "FR: Custom interval must be at least 1 day.",
"dueDateRequired": "FR: Due date is required.",
"invalidDueDate": "FR: Invalid due date format."
},
"unsavedChangesConfirmation": "FR: You have unsaved changes in the chore form. Are you sure you want to leave?"
},
"errorNotFoundPage": {
"errorCode": "FR: 404",
"errorMessage": "FR: Oops. Nothing here...",
"goHomeButton": "FR: Go Home"
},
"groupDetailPage": {
"loadingLabel": "FR: Loading group details...",
"retryButton": "FR: Retry",
"groupNotFound": "FR: Group not found or an error occurred.",
"members": {
"title": "FR: Group Members",
"defaultRole": "FR: Member",
"removeButton": "FR: Remove",
"emptyState": "FR: No members found."
},
"invites": {
"title": "FR: Invite Members",
"regenerateButton": "FR: Regenerate Invite Code",
"generateButton": "FR: Generate Invite Code",
"activeCodeLabel": "FR: Current Active Invite Code:",
"copyButtonLabel": "FR: Copy invite code",
"copySuccess": "FR: Invite code copied to clipboard!",
"emptyState": "FR: No active invite code. Click the button above to generate one.",
"errors": {
"newDataInvalid": "FR: New invite code data is invalid."
}
},
"chores": {
"title": "FR: Group Chores",
"manageButton": "FR: Manage Chores",
"duePrefix": "FR: Due:",
"emptyState": "FR: No chores scheduled. Click \"Manage Chores\" to create some!"
},
"expenses": {
"title": "FR: Group Expenses",
"manageButton": "FR: Manage Expenses",
"emptyState": "FR: No expenses recorded. Click \"Manage Expenses\" to add some!",
"splitTypes": {
"equal": "FR: Equal",
"exactAmounts": "FR: Exact Amounts",
"percentage": "FR: Percentage",
"shares": "FR: Shares",
"itemBased": "FR: Item Based"
}
},
"notifications": {
"fetchDetailsFailed": "FR: Failed to fetch group details.",
"fetchInviteFailed": "FR: Failed to fetch active invite code.",
"generateInviteSuccess": "FR: New invite code generated successfully!",
"generateInviteError": "FR: Failed to generate invite code.",
"clipboardNotSupported": "FR: Clipboard not supported or no code to copy.",
"copyInviteFailed": "FR: Failed to copy invite code.",
"removeMemberSuccess": "FR: Member removed successfully",
"removeMemberFailed": "FR: Failed to remove member"
}
},
"accountPage": {
"title": "Account Settings",
"loadingProfile": "Loading profile...",
"retryButton": "Retry",
"profileSection": {
"header": "Profile Information",
"nameLabel": "Name",
"emailLabel": "Email",
"saveButton": "Save Changes"
},
"passwordSection": {
"header": "Change Password",
"currentPasswordLabel": "Current Password",
"newPasswordLabel": "New Password",
"changeButton": "Change Password"
},
"notificationsSection": {
"header": "Notification Preferences",
"emailNotificationsLabel": "Email Notifications",
"emailNotificationsDescription": "Receive email notifications for important updates",
"listUpdatesLabel": "List Updates",
"listUpdatesDescription": "Get notified when lists are updated",
"groupActivitiesLabel": "Group Activities",
"groupActivitiesDescription": "Receive notifications for group activities"
},
"notifications": {
"profileLoadFailed": "Failed to load profile",
"profileUpdateSuccess": "Profile updated successfully",
"profileUpdateFailed": "Failed to update profile",
"passwordFieldsRequired": "Please fill in both current and new password fields.",
"passwordTooShort": "New password must be at least 8 characters long.",
"passwordChangeSuccess": "Password changed successfully",
"passwordChangeFailed": "Failed to change password",
"preferencesUpdateSuccess": "Preferences updated successfully",
"preferencesUpdateFailed": "Failed to update preferences"
},
"saving": "Saving..."
},
"signupPage": {
"header": "Sign Up",
"fullNameLabel": "Full Name",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm Password",
"togglePasswordVisibility": "Toggle password visibility",
"submitButton": "Sign Up",
"loginLink": "Already have an account? Login",
"validation": {
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"passwordRequired": "Password is required",
"passwordLength": "Password must be at least 8 characters",
"confirmPasswordRequired": "Please confirm your password",
"passwordsNoMatch": "Passwords do not match"
},
"notifications": {
"signupFailed": "Signup failed. Please try again.",
"signupSuccess": "Account created successfully. Please login."
}
},
"listDetailPage": {
"loading": {
"list": "Loading list...",
"items": "Loading items...",
"ocrProcessing": "Processing image...",
"addingOcrItems": "Adding OCR items...",
"costSummary": "Loading summary...",
"expenses": "Loading expenses...",
"settlement": "Processing settlement..."
},
"errors": {
"fetchFailed": "Failed to load list details.",
"genericLoadFailure": "Group not found or an error occurred.",
"ocrNoItems": "No items extracted from the image.",
"ocrFailed": "Failed to process image.",
"addItemFailed": "Failed to add item.",
"updateItemFailed": "Failed to update item.",
"updateItemPriceFailed": "Failed to update item price.",
"deleteItemFailed": "Failed to delete item.",
"addOcrItemsFailed": "Failed to add OCR items.",
"fetchItemsFailed": "Failed to load items: {errorMessage}",
"loadCostSummaryFailed": "Failed to load cost summary."
},
"retryButton": "Retry",
"buttons": {
"addViaOcr": "Add via OCR",
"addItem": "Add",
"addItems": "Add Items",
"cancel": "Cancel",
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
},
"badges": {
"groupList": "Group List",
"personalList": "Personal List"
},
"items": {
"emptyState": {
"title": "No Items Yet!",
"message": "Add some items using the form below."
},
"addItemForm": {
"placeholder": "Add a new item",
"quantityPlaceholder": "Qty",
"itemNameSrLabel": "New item name",
"quantitySrLabel": "Quantity"
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
},
"modals": {
"ocr": {
"title": "Add Items via OCR",
"uploadLabel": "Upload Image"
},
"confirmation": {
"title": "Confirmation"
},
"editItem": {
"title": "Edit Item",
"nameLabel": "Item Name",
"quantityLabel": "Quantity"
},
"costSummary": {
"title": "List Cost Summary",
"totalCostLabel": "Total List Cost:",
"equalShareLabel": "Equal Share Per User:",
"participantsLabel": "Participating Users:",
"userBalancesHeader": "User Balances",
"tableHeaders": {
"user": "User",
"itemsAddedValue": "Items Added Value",
"amountDue": "Amount Due",
"balance": "Balance"
},
"emptyState": "No cost summary available."
},
"settleShare": {
"title": "Settle Share",
"settleAmountFor": "Settle amount for {userName}:",
"amountLabel": "Amount",
"errors": {
"enterAmount": "Please enter an amount.",
"positiveAmount": "Please enter a positive amount.",
"exceedsRemaining": "Amount cannot exceed remaining: {amount}.",
"noSplitSelected": "Error: No split selected."
}
}
},
"confirmations": {
"updateMessage": "Mark '{itemName}' as {status}?",
"statusComplete": "complete",
"statusIncomplete": "incomplete",
"deleteMessage": "Delete '{itemName}'? This cannot be undone."
},
"notifications": {
"itemAddedSuccess": "Item added successfully.",
"itemsAddedSuccessOcr": "{count} item(s) added successfully from OCR.",
"itemUpdatedSuccess": "Item updated successfully.",
"itemDeleteSuccess": "Item deleted successfully.",
"enterItemName": "Please enter an item name.",
"costSummaryLoadFailed": "Failed to load cost summary.",
"cannotSettleOthersShares": "You can only settle your own shares.",
"settlementDataMissing": "Cannot process settlement: missing data.",
"settleShareSuccess": "Share settled successfully!",
"settleShareFailed": "Failed to settle share."
},
"expensesSection": {
"title": "Expenses",
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",
"paidAmount": "Paid:",
"activityLabel": "Activity:",
"byUser": "by",
"settleShareButton": "Settle My Share",
"retryButton": "Retry"
},
"status": {
"settled": "Settled",
"partiallySettled": "Partially Settled",
"unsettled": "Unsettled",
"paid": "Paid",
"partiallyPaid": "Partially Paid",
"unpaid": "Unpaid",
"unknown": "Unknown Status"
}
},
"myChoresPage": {
"title": "My Assigned Chores",
"showCompletedToggle": "Show Completed",
"timelineHeaders": {
"overdue": "Overdue",
"today": "Due Today",
"thisWeek": "This Week",
"later": "Later",
"completed": "Completed"
},
"choreCard": {
"personal": "Personal",
"group": "Group",
"duePrefix": "Due",
"completedPrefix": "Completed",
"dueToday": "Due Today",
"markCompleteButton": "Mark Complete"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"unknownDate": "Unknown Date"
},
"emptyState": {
"title": "No Assignments Yet!",
"noAssignmentsPending": "You have no pending chore assignments.",
"noAssignmentsAll": "You have no chore assignments (completed or pending).",
"viewAllChoresButton": "View All Chores"
},
"notifications": {
"loadFailed": "Failed to load assignments",
"markedComplete": "Marked \"{choreName}\" as complete!",
"markCompleteFailed": "Failed to mark assignment as complete"
}
},
"personalChoresPage": {
"title": "Personal Chores",
"newChoreButton": "New Chore",
"editButton": "Edit",
"deleteButton": "Delete",
"cancelButton": "Cancel",
"saveButton": "Save",
"modals": {
"editChoreTitle": "Edit Chore",
"newChoreTitle": "New Chore",
"deleteChoreTitle": "Delete Chore"
},
"form": {
"nameLabel": "Name",
"descriptionLabel": "Description",
"frequencyLabel": "Frequency",
"intervalLabel": "Interval (days)",
"dueDateLabel": "Next Due Date"
},
"deleteDialog": {
"confirmationText": "Are you sure you want to delete this chore?"
},
"frequencies": {
"one_time": "One Time",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"custom": "Custom",
"unknown": "Unknown Frequency"
},
"dates": {
"invalidDate": "Invalid Date",
"duePrefix": "Due"
},
"notifications": {
"loadFailed": "Failed to load personal chores",
"updateSuccess": "Personal chore updated successfully",
"createSuccess": "Personal chore created successfully",
"saveFailed": "Failed to save personal chore",
"deleteSuccess": "Personal chore deleted successfully",
"deleteFailed": "Failed to delete personal chore"
}
},
"indexPage": {
"welcomeMessage": "Welcome to Valerie UI App",
"mainPageInfo": "This is the main index page.",
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
"totalCountLabel": "Total count from meta:",
"noTodos": "No todos to display."
}
}

View File

@ -1,5 +1,11 @@
import enUS from './en-US';
import en from './en.json'; // Changed from enUS and path
import de from './de.json';
import fr from './fr.json';
import es from './es.json';
export default {
'en-US': enUS
'en': en, // Changed from 'en-US': enUS
'de': de,
'fr': fr,
'es': es
};

View File

@ -4,8 +4,8 @@ import * as Sentry from '@sentry/vue';
import { BrowserTracing } from '@sentry/tracing';
import App from './App.vue';
import router from './router';
// import { createI18n } from 'vue-i18n';
// import messages from '@/i18n'; // Import from absolute path
import { createI18n } from 'vue-i18n';
import messages from '@/i18n';
// Global styles
import './assets/main.scss';
@ -15,21 +15,22 @@ import { api, globalAxios } from '@/services/api'; // Renamed from boot/axios to
import { useAuthStore } from '@/stores/auth';
// Vue I18n setup (from your i18n boot file)
// export type MessageLanguages = keyof typeof messages;
// export type MessageSchema = (typeof messages)['en-US'];
// // export type MessageLanguages = keyof typeof messages;
// // export type MessageSchema = (typeof messages)['en-US'];
// declare module 'vue-i18n' {
// export interface DefineLocaleMessage extends MessageSchema {}
// // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// export interface DefineDateTimeFormat {}
// // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// export interface DefineNumberFormat {}
// }
// const i18n = createI18n<{ message: MessageSchema }>({
// locale: 'en-US',
// fallbackLocale: 'en-US',
// messages,
// });
// // declare module 'vue-i18n' {
// // export interface DefineLocaleMessage extends MessageSchema {}
// // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// // export interface DefineDateTimeFormat {}
// // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// // export interface DefineNumberFormat {}
// // }
const i18n = createI18n({
legacy: false, // Recommended for Vue 3
locale: 'en', // Default locale
fallbackLocale: 'en', // Fallback locale
messages,
});
const app = createApp(App);
const pinia = createPinia();
@ -62,7 +63,7 @@ if (authStore.accessToken) {
}
app.use(router);
// app.use(i18n);
app.use(i18n);
// Make API instance globally available (optional, prefer provide/inject or store)
app.config.globalProperties.$api = api;

View File

@ -1,32 +1,32 @@
<template>
<main class="container page-padding">
<VHeading level="1" text="Account Settings" class="mb-3" />
<VHeading level="1" :text="$t('accountPage.title')" class="mb-3" />
<div v-if="loading" class="text-center">
<VSpinner label="Loading profile..." />
<VSpinner :label="$t('accountPage.loadingProfile')" />
</div>
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchProfile">Retry</VButton>
<VButton variant="danger" size="sm" @click="fetchProfile">{{ $t('accountPage.retryButton') }}</VButton>
</template>
</VAlert>
<form v-else @submit.prevent="onSubmitProfile">
<!-- Profile Section -->
<VCard class="mb-3">
<template #header><VHeading level="3">Profile Information</VHeading></template>
<template #header><VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading></template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField label="Name" class="flex-grow">
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
<VInput id="profileName" v-model="profile.name" required />
</VFormField>
<VFormField label="Email" class="flex-grow">
<VFormField :label="$t('accountPage.profileSection.emailLabel')" class="flex-grow">
<VInput type="email" id="profileEmail" v-model="profile.email" required readonly />
</VFormField>
</div>
<template #footer>
<VButton type="submit" variant="primary" :disabled="saving">
<VSpinner v-if="saving" size="sm" /> Save Changes
<VSpinner v-if="saving" size="sm" /> {{ $t('accountPage.profileSection.saveButton') }}
</VButton>
</template>
</VCard>
@ -35,18 +35,18 @@
<!-- Password Section -->
<form @submit.prevent="onChangePassword">
<VCard class="mb-3">
<template #header><VHeading level="3">Change Password</VHeading></template>
<template #header><VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading></template>
<div class="flex flex-wrap" style="gap: 1rem;">
<VFormField label="Current Password" class="flex-grow">
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
<VInput type="password" id="currentPassword" v-model="password.current" required />
</VFormField>
<VFormField label="New Password" class="flex-grow">
<VFormField :label="$t('accountPage.passwordSection.newPasswordLabel')" class="flex-grow">
<VInput type="password" id="newPassword" v-model="password.newPassword" required />
</VFormField>
</div>
<template #footer>
<VButton type="submit" variant="primary" :disabled="changingPassword">
<VSpinner v-if="changingPassword" size="sm" /> Change Password
<VSpinner v-if="changingPassword" size="sm" /> {{ $t('accountPage.passwordSection.changeButton') }}
</VButton>
</template>
</VCard>
@ -54,28 +54,28 @@
<!-- Notifications Section -->
<VCard>
<template #header><VHeading level="3">Notification Preferences</VHeading></template>
<template #header><VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading></template>
<VList class="preference-list">
<VListItem class="preference-item">
<div class="preference-label">
<span>Email Notifications</span>
<small>Receive email notifications for important updates</small>
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange" label="Email Notifications" id="emailNotificationsToggle" />
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>List Updates</span>
<small>Get notified when lists are updated</small>
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange" label="List Updates" id="listUpdatesToggle"/>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle"/>
</VListItem>
<VListItem class="preference-item">
<div class="preference-label">
<span>Group Activities</span>
<small>Receive notifications for group activities</small>
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
</div>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange" label="Group Activities" id="groupActivitiesToggle"/>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle"/>
</VListItem>
</VList>
</VCard>
@ -84,6 +84,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
import VHeading from '@/components/valerie/VHeading.vue';
@ -97,6 +98,8 @@ import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
const { t } = useI18n();
interface Profile {
name: string;
email: string;
@ -136,10 +139,11 @@ const fetchProfile = async () => {
// Assume preferences are also fetched or part of profile
// preferences.value = response.data.preferences || preferences.value;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load profile';
error.value = message;
const apiMessage = err instanceof Error ? err.message : t('accountPage.notifications.profileLoadFailed');
error.value = apiMessage; // Show translated or API error message in the VAlert
console.error('Failed to fetch profile:', err);
notificationStore.addNotification({ message, type: 'error' });
// For the notification pop-up, always use the translated generic message
notificationStore.addNotification({ message: t('accountPage.notifications.profileLoadFailed'), type: 'error' });
} finally {
loading.value = false;
}
@ -149,11 +153,11 @@ const onSubmitProfile = async () => {
saving.value = true;
try {
await apiClient.put(API_ENDPOINTS.USERS.UPDATE_PROFILE, profile.value);
notificationStore.addNotification({ message: 'Profile updated successfully', type: 'success' });
notificationStore.addNotification({ message: t('accountPage.notifications.profileUpdateSuccess'), type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update profile';
const message = err instanceof Error ? err.message : t('accountPage.notifications.profileUpdateFailed');
console.error('Failed to update profile:', err);
notificationStore.addNotification({ message, type: 'error' });
notificationStore.addNotification({ message: t('accountPage.notifications.profileUpdateFailed'), type: 'error' });
} finally {
saving.value = false;
}
@ -161,11 +165,11 @@ const onSubmitProfile = async () => {
const onChangePassword = async () => {
if (!password.value.current || !password.value.newPassword) {
notificationStore.addNotification({ message: 'Please fill in both current and new password fields.', type: 'warning' });
notificationStore.addNotification({ message: t('accountPage.notifications.passwordFieldsRequired'), type: 'warning' });
return;
}
if (password.value.newPassword.length < 8) {
notificationStore.addNotification({ message: 'New password must be at least 8 characters long.', type: 'warning' });
notificationStore.addNotification({ message: t('accountPage.notifications.passwordTooShort'), type: 'warning' });
return;
}
@ -177,11 +181,11 @@ const onChangePassword = async () => {
new: password.value.newPassword
});
password.value = { current: '', newPassword: '' };
notificationStore.addNotification({ message: 'Password changed successfully', type: 'success' });
notificationStore.addNotification({ message: t('accountPage.notifications.passwordChangeSuccess'), type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to change password';
const message = err instanceof Error ? err.message : t('accountPage.notifications.passwordChangeFailed');
console.error('Failed to change password:', err);
notificationStore.addNotification({ message, type: 'error' });
notificationStore.addNotification({ message: t('accountPage.notifications.passwordChangeFailed'), type: 'error' });
} finally {
changingPassword.value = false;
}
@ -192,11 +196,11 @@ const onPreferenceChange = async () => {
// Consider debouncing or providing a "Save Preferences" button if API calls are expensive.
try {
await apiClient.put(API_ENDPOINTS.USERS.PREFERENCES, preferences.value);
notificationStore.addNotification({ message: 'Preferences updated successfully', type: 'success' });
notificationStore.addNotification({ message: t('accountPage.notifications.preferencesUpdateSuccess'), type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update preferences';
const message = err instanceof Error ? err.message : t('accountPage.notifications.preferencesUpdateFailed');
console.error('Failed to update preferences:', err);
notificationStore.addNotification({ message, type: 'error' });
notificationStore.addNotification({ message: t('accountPage.notifications.preferencesUpdateFailed'), type: 'error' });
// Optionally revert the toggle if the API call fails
// await fetchProfile(); // Or manage state more granularly
}

View File

@ -6,7 +6,7 @@
<span /><span /><span />
</div>
<p v-else-if="error" class="text-error">{{ error }}</p>
<p v-else>Redirecting...</p>
<p v-else>{{ t('authCallbackPage.redirecting') }}</p>
</div>
</div>
</main>
@ -14,6 +14,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { useNotificationStore } from '@/stores/notifications';
@ -23,6 +24,8 @@ const router = useRouter();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const { t } = useI18n();
const loading = ref(true);
const error = ref<string | null>(null);
@ -39,10 +42,10 @@ onMounted(async () => {
}
await authStore.setTokens({ access_token: tokenToUse, refresh_token: refreshToken });
notificationStore.addNotification({ message: 'Login successful', type: 'success' });
notificationStore.addNotification({ message: t('loginPage.notifications.loginSuccess'), type: 'success' });
router.push('/');
} catch (err) {
error.value = err instanceof Error ? err.message : 'Authentication failed';
error.value = err instanceof Error ? err.message : t('authCallbackPage.errors.authenticationFailed');
notificationStore.addNotification({ message: error.value, type: 'error' });
} finally {
loading.value = false;

View File

@ -2,54 +2,54 @@
<main class="neo-container page-padding">
<div class="neo-list-header">
<div class="header-left">
<h1 class="neo-title">Chores</h1>
<h1 class="neo-title">{{ t('choresPage.title') }}</h1>
<div class="view-tabs" role="tablist">
<button class="neo-tab-btn" :class="{ active: activeView === 'overdue' }" @click="activeView = 'overdue'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'overdue'">
<span class="material-icons">warning</span>
Overdue
{{ t('choresPage.tabs.overdue') }}
<span v-if="counts.overdue > 0" class="neo-tab-count">{{ counts.overdue }}</span>
</button>
<button class="neo-tab-btn" :class="{ active: activeView === 'today' }" @click="activeView = 'today'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'today'">
<span class="material-icons">today</span>
Today
{{ t('choresPage.tabs.today') }}
<span v-if="counts.today > 0" class="neo-tab-count">{{ counts.today }}</span>
</button>
<button class="neo-tab-btn" :class="{ active: activeView === 'upcoming' }" @click="activeView = 'upcoming'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'upcoming'">
<span class="material-icons">upcoming</span>
Upcoming
{{ t('choresPage.tabs.upcoming') }}
</button>
<button class="neo-tab-btn" :class="{ active: activeView === 'all' }" @click="activeView = 'all'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'all'">
<span class="material-icons">list</span>
All Pending
{{ t('choresPage.tabs.allPending') }}
</button>
<button class="neo-tab-btn" :class="{ active: activeView === 'completed' }" @click="activeView = 'completed'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'completed'">
<span class="material-icons">check_circle</span>
Completed
{{ t('choresPage.tabs.completed') }}
</button>
</div>
</div>
<div class="header-right">
<div class="neo-view-toggle">
<button class="neo-toggle-btn" :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'"
:disabled="isLoading" :aria-pressed="viewMode === 'calendar'" aria-label="Calendar View">
:disabled="isLoading" :aria-pressed="viewMode === 'calendar'" :aria-label="t('choresPage.viewToggle.calendarLabel')">
<span class="material-icons">calendar_month</span>
<span class="btn-text hide-text-on-mobile">Calendar</span>
<span class="btn-text hide-text-on-mobile">{{ t('choresPage.viewToggle.calendarText') }}</span>
</button>
<button class="neo-toggle-btn" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'"
:disabled="isLoading" :aria-pressed="viewMode === 'list'" aria-label="List View">
:disabled="isLoading" :aria-pressed="viewMode === 'list'" :aria-label="t('choresPage.viewToggle.listLabel')">
<span class="material-icons">view_list</span>
<span class="btn-text hide-text-on-mobile">List</span>
<span class="btn-text hide-text-on-mobile">{{ t('choresPage.viewToggle.listText') }}</span>
</button>
</div>
<button class="neo-action-button" @click="openCreateChoreModal(null)" :disabled="isLoading"
aria-label="New Chore">
:aria-label="t('choresPage.newChoreButtonLabel')">
<span class="material-icons">add</span>
<span class="btn-text hide-text-on-mobile-sm">New Chore</span>
<span class="btn-text hide-text-on-mobile-sm">{{ t('choresPage.newChoreButtonText') }}</span>
</button>
</div>
</div>
@ -57,24 +57,24 @@
<!-- Loading State -->
<div v-if="isLoading" class="neo-loading-state">
<div class="loading-spinner"></div>
<p>Loading chores...</p>
<p>{{ t('choresPage.loadingState.loadingChores') }}</p>
</div>
<!-- Calendar View -->
<div v-else-if="viewMode === 'calendar'" class="calendar-view">
<div class="calendar-header">
<button class="btn btn-icon" @click="previousMonth" aria-label="Previous month">
<button class="btn btn-icon" @click="previousMonth" :aria-label="t('choresPage.calendar.prevMonthLabel')">
<span class="material-icons">chevron_left</span>
</button>
<h2>{{ currentMonthYear }}</h2>
<button class="btn btn-icon" @click="nextMonth" aria-label="Next month">
<button class="btn btn-icon" @click="nextMonth" :aria-label="t('choresPage.calendar.nextMonthLabel')">
<span class="material-icons">chevron_right</span>
</button>
</div>
<div v-if="calendarDays.length > 0">
<div class="calendar-grid">
<div class="calendar-weekdays">
<div v-for="day in weekDays" :key="day" class="weekday">{{ day }}</div>
<div v-for="dayNameKey in weekDayKeys" :key="dayNameKey" class="weekday">{{ t(dayNameKey) }}</div>
</div>
<div class="calendar-days">
<div v-for="(day, index) in calendarDays" :key="index" class="calendar-day" :class="{
@ -86,7 +86,7 @@
<div class="day-header">
<span class="day-number">{{ day.date.getDate() }}</span>
<button v-if="!day.isOtherMonth" class="add-chore-indicator"
@click.stop="openCreateChoreModal(null, day.date)" aria-label="Add chore to this day">
@click.stop="openCreateChoreModal(null, day.date)" :aria-label="t('choresPage.calendar.addChoreToDayLabel')">
<span class="material-icons">add_circle_outline</span>
</button>
</div>
@ -107,7 +107,7 @@
</div>
<div v-else class="empty-state">
<span class="material-icons empty-icon">calendar_today</span>
<p>No chores to display for this period.</p>
<p>{{ t('choresPage.calendar.emptyState') }}</p>
</div>
</div>
@ -123,7 +123,7 @@
<h3>{{ chore.name }}</h3>
<div class="chore-tags">
<span class="chore-type-tag" :class="chore.type">
{{ chore.type === 'personal' ? 'Personal' : getGroupName(chore.group_id) || 'Group' }}
{{ chore.type === 'personal' ? t('choresPage.listView.choreTypePersonal') : getGroupName(chore.group_id) || t('choresPage.listView.choreTypeGroupFallback') }}
</span>
<span v-if="!chore.is_completed" class="chore-frequency-tag" :class="chore.frequency">
{{ formatFrequency(chore.frequency) }}
@ -137,7 +137,7 @@
</div>
<div v-else class="chore-completed-date">
<span class="material-icons">check_circle_outline</span>
Completed: {{ formatDate(chore.completed_at || chore.next_due_date) }}
{{ t('choresPage.listView.completedDatePrefix') }} {{ formatDate(chore.completed_at || chore.next_due_date) }}
</div>
<div v-if="chore.description" class="chore-description">
{{ chore.description }}
@ -146,21 +146,21 @@
</div>
<div class="chore-card-actions">
<button v-if="!chore.is_completed" class="btn btn-success btn-sm btn-complete"
@click="toggleChoreCompletion(chore)" title="Mark as Done">
<span class="material-icons">check_circle</span> Done
@click="toggleChoreCompletion(chore)" :title="t('choresPage.listView.actions.doneTitle')">
<span class="material-icons">check_circle</span> {{ t('choresPage.listView.actions.doneText') }}
</button>
<button v-else class="btn btn-warning btn-sm btn-undo" @click="toggleChoreCompletion(chore)"
title="Mark as Not Done">
<span class="material-icons">undo</span> Undo
:title="t('choresPage.listView.actions.undoTitle')">
<span class="material-icons">undo</span> {{ t('choresPage.listView.actions.undoText') }}
</button>
<button class="btn btn-icon" @click="openEditChoreModal(chore)" title="Edit" aria-label="Edit chore">
<button class="btn btn-icon" @click="openEditChoreModal(chore)" :title="t('choresPage.listView.actions.editTitle')" :aria-label="t('choresPage.listView.actions.editLabel')">
<span class="material-icons">edit</span>
<span class="btn-text hide-text-on-mobile">Edit</span>
<span class="btn-text hide-text-on-mobile">{{ t('choresPage.listView.actions.editText') }}</span>
</button>
<button class="btn btn-icon btn-danger-icon" @click="confirmDeleteChore(chore)" title="Delete"
aria-label="Delete chore">
<button class="btn btn-icon btn-danger-icon" @click="confirmDeleteChore(chore)" :title="t('choresPage.listView.actions.deleteTitle')"
:aria-label="t('choresPage.listView.actions.deleteLabel')">
<span class="material-icons">delete</span>
<span class="btn-text hide-text-on-mobile">Delete</span>
<span class="btn-text hide-text-on-mobile">{{ t('choresPage.listView.actions.deleteText') }}</span>
</button>
</div>
</div>
@ -168,9 +168,8 @@
</transition-group>
<div v-if="!isLoading && filteredChores.length === 0" class="empty-state">
<span class="material-icons empty-icon"> Rtask_alt</span>
<p>No chores in this view. Well done!</p>
<button v-if="activeView !== 'all'" class="btn btn-sm btn-outline" @click="activeView = 'all'">View All
Pending</button>
<p>{{ t('choresPage.listView.emptyState.message') }}</p>
<button v-if="activeView !== 'all'" class="btn btn-sm btn-outline" @click="activeView = 'all'">{{ t('choresPage.listView.emptyState.viewAllButton') }}</button>
</div>
</div>
@ -179,40 +178,40 @@
aria-modal="true" :aria-labelledby="isEditing ? 'editChoreModalTitle' : 'newChoreModalTitle'">
<div class="modal-container">
<div class="modal-header">
<h3 :id="isEditing ? 'editChoreModalTitle' : 'newChoreModalTitle'">{{ isEditing ? 'Edit Chore' : 'New Chore'
<h3 :id="isEditing ? 'editChoreModalTitle' : 'newChoreModalTitle'">{{ isEditing ? t('choresPage.choreModal.editTitle') : t('choresPage.choreModal.newTitle')
}}</h3>
<button class="btn btn-icon" @click="showChoreModal = false" aria-label="Close modal">
<button class="btn btn-icon" @click="showChoreModal = false" :aria-label="t('choresPage.choreModal.closeButtonLabel')">
<span class="material-icons">close</span>
</button>
</div>
<form @submit.prevent="onSubmit" class="modal-form">
<div class="form-group">
<label for="name">Name</label>
<input id="name" v-model="choreForm.name" type="text" class="form-input" placeholder="Enter chore name"
<label for="name">{{ t('choresPage.choreModal.nameLabel') }}</label>
<input id="name" v-model="choreForm.name" type="text" class="form-input" :placeholder="t('choresPage.choreModal.namePlaceholder')"
required />
</div>
<div class="form-group">
<label>Type</label>
<label>{{ t('choresPage.choreModal.typeLabel') }}</label>
<div class="type-selector">
<button type="button" class="type-btn" :class="{ active: choreForm.type === 'personal' }"
@click="choreForm.type = 'personal'; choreForm.group_id = undefined"
:aria-pressed="choreForm.type === 'personal' ? 'true' : 'false'">
<span class="material-icons">person</span>
Personal
{{ t('choresPage.choreModal.typePersonal') }}
</button>
<button type="button" class="type-btn" :class="{ active: choreForm.type === 'group' }"
@click="choreForm.type = 'group'" :aria-pressed="choreForm.type === 'group' ? 'true' : 'false'">
<span class="material-icons">group</span>
Group
{{ t('choresPage.choreModal.typeGroup') }}
</button>
</div>
</div>
<div v-if="choreForm.type === 'group'" class="form-group">
<label for="group">Group</label>
<label for="group">{{ t('choresPage.choreModal.groupLabel') }}</label>
<select id="group" v-model="choreForm.group_id" class="form-input" required>
<option :value="undefined" disabled>Select a group</option>
<option :value="undefined" disabled>{{ t('choresPage.choreModal.groupSelectDefault') }}</option>
<option v-for="group in groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
@ -220,14 +219,14 @@
</div>
<div class="form-group">
<label for="description">Description</label>
<label for="description">{{ t('choresPage.choreModal.descriptionLabel') }}</label>
<textarea id="description" v-model="choreForm.description" class="form-input" rows="3"
placeholder="Add a description (optional)"></textarea>
:placeholder="t('choresPage.choreModal.descriptionPlaceholder')"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="frequency">Frequency</label>
<label for="frequency">{{ t('choresPage.choreModal.frequencyLabel') }}</label>
<select id="frequency" v-model="choreForm.frequency" class="form-input" required>
<option v-for="option in frequencyOptions" :key="option.value" :value="option.value">
{{ option.label }}
@ -236,27 +235,26 @@
</div>
<div v-if="choreForm.frequency === 'custom'" class="form-group">
<label for="interval">Interval (days)</label>
<label for="interval">{{ t('choresPage.choreModal.intervalLabel') }}</label>
<input id="interval" v-model.number="choreForm.custom_interval_days" type="number" class="form-input"
min="1" placeholder="e.g. 3" required />
min="1" :placeholder="t('choresPage.choreModal.intervalPlaceholder')" required />
</div>
</div>
<div class="form-group">
<label for="dueDate">Due Date</label>
<label for="dueDate">{{ t('choresPage.choreModal.dueDateLabel') }}</label>
<div class="quick-due-dates">
<button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('today')">Today</button>
<button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('today')">{{ t('choresPage.choreModal.quickDueDateToday') }}</button>
<button type="button" class="btn btn-sm btn-outline"
@click="setQuickDueDate('tomorrow')">Tomorrow</button>
<button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('next_week')">Next
Week</button>
@click="setQuickDueDate('tomorrow')">{{ t('choresPage.choreModal.quickDueDateTomorrow') }}</button>
<button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('next_week')">{{ t('choresPage.choreModal.quickDueDateNextWeek') }}</button>
</div>
<input id="dueDate" v-model="choreForm.next_due_date" type="date" class="form-input" required />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{ t('choresPage.choreModal.cancelButton') }}</button>
<button type="submit" class="btn btn-primary">{{ t('choresPage.choreModal.saveButton') }}</button>
</div>
</form>
</div>
@ -267,17 +265,17 @@
aria-modal="true" aria-labelledby="deleteDialogTitle">
<div class="modal-container delete-confirm">
<div class="modal-header">
<h3 id="deleteDialogTitle">Delete Chore</h3>
<button class="btn btn-icon" @click="showDeleteDialog = false" aria-label="Close modal">
<h3 id="deleteDialogTitle">{{ t('choresPage.deleteDialog.title') }}</h3>
<button class="btn btn-icon" @click="showDeleteDialog = false" :aria-label="t('choresPage.choreModal.closeButtonLabel')">
<span class="material-icons">close</span>
</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this chore? This action cannot be undone.</p>
<p>{{ t('choresPage.deleteDialog.confirmationText') }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-neutral" @click="showDeleteDialog = false">Cancel</button>
<button class="btn btn-danger" @click="deleteChore">Delete</button>
<button class="btn btn-neutral" @click="showDeleteDialog = false">{{ t('choresPage.choreModal.cancelButton') }}</button>
<button class="btn btn-danger" @click="deleteChore">{{ t('choresPage.deleteDialog.deleteButton') }}</button>
</div>
</div>
</div>
@ -287,8 +285,8 @@
aria-modal="true" aria-labelledby="shortcutsModalTitle">
<div class="modal-container shortcuts-modal">
<div class="modal-header">
<h3 id="shortcutsModalTitle">Keyboard Shortcuts</h3>
<button class="btn btn-icon" @click="showShortcutsModal = false" aria-label="Close modal">
<h3 id="shortcutsModalTitle">{{ t('choresPage.shortcutsModal.title') }}</h3>
<button class="btn btn-icon" @click="showShortcutsModal = false" :aria-label="t('choresPage.choreModal.closeButtonLabel')">
<span class="material-icons">close</span>
</button>
</div>
@ -298,25 +296,25 @@
<div class="shortcut-keys">
<kbd>Ctrl/Cmd</kbd> + <kbd>N</kbd>
</div>
<div class="shortcut-description">New Chore</div>
<div class="shortcut-description">{{ t('choresPage.shortcutsModal.descNewChore') }}</div>
</div>
<div class="shortcut-item">
<div class="shortcut-keys">
<kbd>Ctrl/Cmd</kbd> + <kbd>/</kbd>
</div>
<div class="shortcut-description">Toggle View</div>
<div class="shortcut-description">{{ t('choresPage.shortcutsModal.descToggleView') }}</div>
</div>
<div class="shortcut-item">
<div class="shortcut-keys">
<kbd>Ctrl/Cmd</kbd> + <kbd>?</kbd>
</div>
<div class="shortcut-description">Show/Hide Shortcuts</div>
<div class="shortcut-description">{{ t('choresPage.shortcutsModal.descToggleShortcuts') }}</div>
</div>
<div class="shortcut-item">
<div class="shortcut-keys">
<kbd>Esc</kbd>
</div>
<div class="shortcut-description">Close Modal</div>
<div class="shortcut-description">{{ t('choresPage.shortcutsModal.descCloseModal') }}</div>
</div>
</div>
</div>
@ -327,6 +325,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { format, startOfDay, addDays, addWeeks, isBefore, isEqual, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday as isTodayDate } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
@ -335,6 +334,8 @@ import { useRoute } from 'vue-router'
import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core'
const { t } = useI18n()
// Types
interface ChoreWithCompletion extends Chore {
is_completed: boolean;
@ -386,13 +387,13 @@ const getGroupName = (groupId?: number | null): string | undefined => {
return groups.value.find(g => g.id === groupId)?.name;
};
const frequencyOptions: { label: string; value: ChoreFrequency }[] = [
{ label: 'One Time', value: 'one_time' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Custom', value: 'custom' }
];
const frequencyOptions = computed(() => [
{ label: t('choresPage.frequencyOptions.oneTime'), value: 'one_time' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.daily'), value: 'daily' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.weekly'), value: 'weekly' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.monthly'), value: 'monthly' as ChoreFrequency },
{ label: t('choresPage.frequencyOptions.custom'), value: 'custom' as ChoreFrequency }
]);
const isLoading = ref(true)
@ -409,7 +410,7 @@ const loadChores = async () => {
cachedTimestamp.value = Date.now()
} catch (error) {
console.error('Failed to load all chores:', error)
notificationStore.addNotification({ message: 'Failed to load chores', type: 'error' })
notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed'), type: 'error' })
if (!cachedChores.value || cachedChores.value.length === 0) chores.value = []
} finally {
isLoading.value = false
@ -419,8 +420,8 @@ const loadChores = async () => {
const viewMode = ref<'calendar' | 'list'>('calendar')
const currentDate = ref(new Date())
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
// For smaller screens, you might use: const weekDays = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const weekDayKeys = ['choresPage.calendar.weekdays.sun', 'choresPage.calendar.weekdays.mon', 'choresPage.calendar.weekdays.tue', 'choresPage.calendar.weekdays.wed', 'choresPage.calendar.weekdays.thu', 'choresPage.calendar.weekdays.fri', 'choresPage.calendar.weekdays.sat']
// For smaller screens, you might use: const weekDayKeys = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const currentMonthYear = computed(() => {
@ -523,10 +524,10 @@ const onSubmit = async () => {
if (isEditing.value && selectedChore.value) {
await choreService.updateChore(selectedChore.value.id, choreDataSubmit as ChoreUpdate)
notificationMessage = `Chore '${choreForm.value.name}' updated successfully`
notificationMessage = t('choresPage.notifications.updateSuccess', { name: choreForm.value.name })
} else {
await choreService.createChore(choreDataSubmit as ChoreCreate)
notificationMessage = `Chore '${choreForm.value.name}' created successfully`
notificationMessage = t('choresPage.notifications.createSuccess', { name: choreForm.value.name })
}
notificationStore.addNotification({ message: notificationMessage, type: 'success' })
@ -535,7 +536,7 @@ const onSubmit = async () => {
await loadChores()
} catch (error) {
console.error('Failed to save chore:', error)
notificationStore.addNotification({ message: `Failed to ${isEditing.value ? 'update' : 'create'} chore`, type: 'error' })
notificationStore.addNotification({ message: t(isEditing.value ? 'choresPage.notifications.updateFailed' : 'choresPage.notifications.createFailed'), type: 'error' })
} finally {
isLoading.value = false;
}
@ -556,11 +557,11 @@ const deleteChore = async () => {
selectedChore.value.group_id ?? undefined
);
showDeleteDialog.value = false;
notificationStore.addNotification({ message: `Chore '${selectedChore.value.name}' deleted successfully`, type: 'success' })
notificationStore.addNotification({ message: t('choresPage.notifications.deleteSuccess', { name: selectedChore.value.name }), type: 'success' })
await loadChores()
} catch (error) {
console.error('Failed to delete chore:', error);
notificationStore.addNotification({ message: 'Failed to delete chore', type: 'error' })
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed'), type: 'error' })
} finally {
isLoading.value = false;
selectedChore.value = null;
@ -686,19 +687,19 @@ const getDueDateClass = (chore: ChoreWithCompletion) => {
}
const formatDueDate = (dateString: string | null) => {
if (!dateString) return 'No due date';
if (!dateString) return t('choresPage.formatters.noDueDate');
try {
const dueDate = startOfDay(new Date(dateString.replace(/-/g, '/')));
if (isEqual(dueDate, today.value)) return 'Due Today';
if (isEqual(dueDate, today.value)) return t('choresPage.formatters.dueToday');
const tomorrow = addDays(today.value, 1);
if (isEqual(dueDate, tomorrow)) return 'Due Tomorrow';
if (isEqual(dueDate, tomorrow)) return t('choresPage.formatters.dueTomorrow');
if (isBefore(dueDate, today.value)) return `Overdue: ${formatDate(dateString)}`;
if (isBefore(dueDate, today.value)) return t('choresPage.formatters.overdueFull', { date: formatDate(dateString) });
return `Due ${formatDate(dateString)}`;
return t('choresPage.formatters.dueFull', { date: formatDate(dateString) });
} catch {
return 'Invalid date'
return t('choresPage.formatters.invalidDate')
}
}
@ -723,7 +724,7 @@ const toggleChoreCompletion = async (choreToToggle: ChoreWithCompletion) => {
} as ChoreUpdate);
notificationStore.addNotification({
message: `${choreToToggle.name} marked as ${choreToToggle.is_completed ? 'done' : 'not done'}.`,
message: t(choreToToggle.is_completed ? 'choresPage.notifications.markedDone' : 'choresPage.notifications.markedNotDone', { name: choreToToggle.name }),
type: choreToToggle.is_completed ? 'success' : 'info'
});
} catch (error) {
@ -734,7 +735,7 @@ const toggleChoreCompletion = async (choreToToggle: ChoreWithCompletion) => {
if (index !== -1) chores.value.splice(index, 1, { ...choreToToggle });
cachedChores.value = [...chores.value];
notificationStore.addNotification({ message: 'Failed to update chore status.', type: 'error' });
notificationStore.addNotification({ message: t('choresPage.notifications.statusUpdateFailed'), type: 'error' });
}
};
@ -783,21 +784,21 @@ onUnmounted(() => {
const validateForm = () => {
if (!choreForm.value.name.trim()) {
notificationStore.addNotification({ message: 'Chore name is required.', type: 'error' }); return false;
notificationStore.addNotification({ message: t('choresPage.validation.nameRequired'), type: 'error' }); return false;
}
if (choreForm.value.type === 'group' && !choreForm.value.group_id) {
notificationStore.addNotification({ message: 'Please select a group for group chores.', type: 'error' }); return false;
notificationStore.addNotification({ message: t('choresPage.validation.groupRequired'), type: 'error' }); return false;
}
if (choreForm.value.frequency === 'custom' && (!choreForm.value.custom_interval_days || choreForm.value.custom_interval_days < 1)) {
notificationStore.addNotification({ message: 'Custom interval must be at least 1 day.', type: 'error' }); return false;
notificationStore.addNotification({ message: t('choresPage.validation.intervalRequired'), type: 'error' }); return false;
}
if (!choreForm.value.next_due_date) {
notificationStore.addNotification({ message: 'Due date is required.', type: 'error' }); return false;
notificationStore.addNotification({ message: t('choresPage.validation.dueDateRequired'), type: 'error' }); return false;
}
try {
new Date(choreForm.value.next_due_date.replace(/-/g, '/')); // check if valid date
} catch {
notificationStore.addNotification({ message: 'Invalid due date format.', type: 'error' }); return false;
notificationStore.addNotification({ message: t('choresPage.validation.invalidDueDate'), type: 'error' }); return false;
}
return true;
}
@ -851,7 +852,7 @@ watch(showChoreModal, (isOpen) => {
onBeforeUnmount(() => {
if (hasUnsavedChanges.value && showChoreModal.value) { // Only prompt if modal is open with changes
const confirmLeave = window.confirm('You have unsaved changes in the chore form. Are you sure you want to leave?')
const confirmLeave = window.confirm(t('choresPage.unsavedChangesConfirmation'))
if (!confirmLeave) {
return false // This typically doesn't prevent component unmount in Vue 3 composition API directly
// but is a common pattern. For SPA, router guards are better.

View File

@ -1,14 +1,17 @@
<template>
<div class="fullscreen-error text-center">
<div>
<div class="error-code">404</div>
<div class="error-message">Oops. Nothing here...</div>
<router-link to="/" class="btn btn-primary mt-3">Go Home</router-link>
<div class="error-code">{{ t('errorNotFoundPage.errorCode') }}</div>
<div class="error-message">{{ t('errorNotFoundPage.errorMessage') }}</div>
<router-link to="/" class="btn btn-primary mt-3">{{ t('errorNotFoundPage.goHomeButton') }}</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// No script logic needed for this simple page
</script>

View File

@ -1,11 +1,11 @@
<template>
<main class="container page-padding">
<div v-if="loading" class="text-center">
<VSpinner label="Loading group details..." />
<VSpinner :label="t('groupDetailPage.loadingLabel')" />
</div>
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchGroupDetails">Retry</VButton>
<VButton variant="danger" size="sm" @click="fetchGroupDetails">{{ t('groupDetailPage.retryButton') }}</VButton>
</template>
</VAlert>
<div v-else-if="group">
@ -14,42 +14,42 @@
<div class="neo-grid">
<!-- Group Members Section -->
<VCard>
<template #header><VHeading level="3">Group Members</VHeading></template>
<template #header><VHeading level="3">{{ t('groupDetailPage.members.title') }}</VHeading></template>
<VList v-if="group.members && group.members.length > 0">
<VListItem v-for="member in group.members" :key="member.id" class="flex justify-between items-center">
<div class="neo-member-info">
<span class="neo-member-name">{{ member.email }}</span>
<VBadge :text="member.role || 'Member'" :variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
<VBadge :text="member.role || t('groupDetailPage.members.defaultRole')" :variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
</div>
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" @click="removeMember(member.id)" :disabled="removingMember === member.id">
<VSpinner v-if="removingMember === member.id" size="sm"/> Remove
<VSpinner v-if="removingMember === member.id" size="sm"/> {{ t('groupDetailPage.members.removeButton') }}
</VButton>
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="users" size="lg" class="opacity-50 mb-2" />
<p>No members found.</p>
<p>{{ t('groupDetailPage.members.emptyState') }}</p>
</div>
</VCard>
<!-- Invite Members Section -->
<VCard>
<template #header><VHeading level="3">Invite Members</VHeading></template>
<template #header><VHeading level="3">{{ t('groupDetailPage.invites.title') }}</VHeading></template>
<VButton variant="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
<VSpinner v-if="generatingInvite" size="sm"/> {{ inviteCode ? 'Regenerate Invite Code' : 'Generate Invite Code' }}
<VSpinner v-if="generatingInvite" size="sm"/> {{ inviteCode ? t('groupDetailPage.invites.regenerateButton') : t('groupDetailPage.invites.generateButton') }}
</VButton>
<div v-if="inviteCode" class="neo-invite-code mt-3">
<VFormField label="Current Active Invite Code:" :label-sr-only="false">
<VFormField :label="t('groupDetailPage.invites.activeCodeLabel')" :label-sr-only="false">
<div class="flex items-center gap-2">
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler" aria-label="Copy invite code" />
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler" :aria-label="t('groupDetailPage.invites.copyButtonLabel')" />
</div>
</VFormField>
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">Invite code copied to clipboard!</p>
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">{{ t('groupDetailPage.invites.copySuccess') }}</p>
</div>
<div v-else class="text-center py-4 mt-3">
<VIcon name="link" size="lg" class="opacity-50 mb-2" />
<p>No active invite code. Click the button above to generate one.</p>
<p>{{ t('groupDetailPage.invites.emptyState') }}</p>
</div>
</VCard>
</div>
@ -63,9 +63,9 @@
<VCard class="mt-4">
<template #header>
<div class="flex justify-between items-center w-full">
<VHeading level="3">Group Chores</VHeading>
<VHeading level="3">{{ t('groupDetailPage.chores.title') }}</VHeading>
<VButton :to="`/groups/${groupId}/chores`" variant="primary">
<span class="material-icons" style="margin-right: 0.25em;">cleaning_services</span> Manage Chores
<span class="material-icons" style="margin-right: 0.25em;">cleaning_services</span> {{ t('groupDetailPage.chores.manageButton') }}
</VButton>
</div>
</template>
@ -73,14 +73,14 @@
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
<div class="neo-chore-info">
<span class="neo-chore-name">{{ chore.name }}</span>
<span class="neo-chore-due">Due: {{ formatDate(chore.next_due_date) }}</span>
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{ formatDate(chore.next_due_date) }}</span>
</div>
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
</VListItem>
</VList>
<div v-else class="text-center py-4">
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" /> {/* Assuming cleaning_services is a valid VIcon name or will be added */}
<p>No chores scheduled. Click "Manage Chores" to create some!</p>
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
</div>
</VCard>
@ -88,9 +88,9 @@
<VCard class="mt-4">
<template #header>
<div class="flex justify-between items-center w-full">
<VHeading level="3">Group Expenses</VHeading>
<VHeading level="3">{{ t('groupDetailPage.expenses.title') }}</VHeading>
<VButton :to="`/groups/${groupId}/expenses`" variant="primary">
<span class="material-icons" style="margin-right: 0.25em;">payments</span> Manage Expenses
<span class="material-icons" style="margin-right: 0.25em;">payments</span> {{ t('groupDetailPage.expenses.manageButton') }}
</VButton>
</div>
</template>
@ -108,18 +108,19 @@
</VList>
<div v-else class="text-center py-4">
<VIcon name="payments" size="lg" class="opacity-50 mb-2" /> {/* Assuming payments is a valid VIcon name or will be added */}
<p>No expenses recorded. Click "Manage Expenses" to add some!</p>
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
</div>
</VCard>
</div>
<VAlert v-else type="info" message="Group not found or an error occurred." />
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
// import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useClipboard } from '@vueuse/core';
@ -141,6 +142,8 @@ import VInput from '@/components/valerie/VInput.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VIcon from '@/components/valerie/VIcon.vue';
const { t } = useI18n();
interface Group {
id: string | number;
name: string;
@ -238,13 +241,13 @@ const generateInviteCode = async () => {
if (response.data && response.data.code) {
inviteCode.value = response.data.code;
inviteExpiresAt.value = response.data.expires_at; // Update with new expiry
notificationStore.addNotification({ message: 'New invite code generated successfully!', type: 'success' });
notificationStore.addNotification({ message: t('groupDetailPage.notifications.generateInviteSuccess'), type: 'success' });
} else {
// Should not happen if POST is successful and returns the code
throw new Error('New invite code data is invalid.');
throw new Error(t('groupDetailPage.invites.errors.newDataInvalid'));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to generate invite code.';
const message = err instanceof Error ? err.message : t('groupDetailPage.notifications.generateInviteError');
console.error('Error generating invite code:', err);
notificationStore.addNotification({ message, type: 'error' });
} finally {
@ -254,7 +257,7 @@ const generateInviteCode = async () => {
const copyInviteCodeHandler = async () => {
if (!clipboardIsSupported.value || !inviteCode.value) {
notificationStore.addNotification({ message: 'Clipboard not supported or no code to copy.', type: 'warning' });
notificationStore.addNotification({ message: t('groupDetailPage.notifications.clipboardNotSupported'), type: 'warning' });
return;
}
await copy(inviteCode.value);
@ -264,7 +267,7 @@ const copyInviteCodeHandler = async () => {
// Optionally, notify success via store if preferred over inline message
// notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' });
} else {
notificationStore.addNotification({ message: 'Failed to copy invite code.', type: 'error' });
notificationStore.addNotification({ message: t('groupDetailPage.notifications.copyInviteFailed'), type: 'error' });
}
};
@ -283,11 +286,11 @@ const removeMember = async (memberId: number) => {
// Refresh group details to update the members list
await fetchGroupDetails();
notificationStore.addNotification({
message: 'Member removed successfully',
message: t('groupDetailPage.notifications.removeMemberSuccess'),
type: 'success'
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to remove member';
const message = err instanceof Error ? err.message : t('groupDetailPage.notifications.removeMemberFailed');
console.error('Error removing member:', err);
notificationStore.addNotification({ message, type: 'error' });
} finally {
@ -314,15 +317,15 @@ const formatDate = (date: string) => {
}
const formatFrequency = (frequency: ChoreFrequency) => {
const options = {
one_time: 'One Time',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
custom: 'Custom'
}
return options[frequency] || frequency
}
const options: Record<ChoreFrequency, string> = {
one_time: t('choresPage.frequencyOptions.oneTime'), // Reusing existing keys
daily: t('choresPage.frequencyOptions.daily'),
weekly: t('choresPage.frequencyOptions.weekly'),
monthly: t('choresPage.frequencyOptions.monthly'),
custom: t('choresPage.frequencyOptions.custom')
};
return options[frequency] || frequency;
};
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): string => {
const colorMap: Record<ChoreFrequency, string> = {
@ -351,10 +354,14 @@ const formatAmount = (amount: string) => {
}
const formatSplitType = (type: string) => {
return type.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ')
}
// Assuming 'type' is like 'exact_amounts' or 'item_based'
const key = `groupDetailPage.expenses.splitTypes.${type.toLowerCase().replace(/_([a-z])/g, g => g[1].toUpperCase())}`;
// This creates keys like 'groupDetailPage.expenses.splitTypes.exactAmounts'
// Check if translation exists, otherwise fallback to a simple formatted string
// For simplicity in this subtask, we'll assume keys will be added.
// A more robust solution would check i18n.global.te(key) or have a fallback.
return t(key);
};
const getSplitTypeBadgeVariant = (type: string): string => {
const colorMap: Record<string, string> = {

View File

@ -9,20 +9,20 @@
</svg>
{{ fetchError }}
</div>
<button type="button" class="btn btn-sm btn-danger" @click="fetchGroups">Retry</button>
<button type="button" class="btn btn-sm btn-danger" @click="fetchGroups">{{ t('groupsPage.retryButton') }}</button>
</div>
<div v-else-if="groups.length === 0" class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>No Groups Yet!</h3>
<p>You are not a member of any groups yet. Create one or join using an invite code.</p>
<h3>{{ t('groupsPage.emptyState.title') }}</h3>
<p>{{ t('groupsPage.emptyState.description') }}</p>
<button class="btn btn-primary mt-2" @click="openCreateGroupDialog">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-plus" />
</svg>
Create New Group
{{ t('groupsPage.emptyState.createButton') }}
</button>
</div>
@ -35,12 +35,12 @@
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-plus" />
</svg>
List
{{ t('groupsPage.groupCard.newListButton') }}
</button>
</div>
</div>
<div class="neo-create-group-card" @click="openCreateGroupDialog">
+ Group
{{ t('groupsPage.createCard.title') }}
</div>
</div>
@ -50,20 +50,20 @@
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-user" />
</svg>
Join a Group with Invite Code
{{ t('groupsPage.joinGroup.title') }}
</h3>
<span class="expand-icon" aria-hidden="true"></span>
</summary>
<div class="card-body">
<form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;">
<div class="form-group flex-grow" style="margin-bottom: 0;">
<label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
<label for="joinInviteCodeInput" class="sr-only">{{ t('groupsPage.joinGroup.inputLabel') }}</label>
<input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
placeholder="Enter Invite Code" required ref="joinInviteCodeInputRef" />
:placeholder="t('groupsPage.joinGroup.inputPlaceholder')" required ref="joinInviteCodeInputRef" />
</div>
<button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
<span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Join
{{ t('groupsPage.joinGroup.joinButton') }}
</button>
</form>
<p v-if="joinGroupFormError" class="form-error-text mt-1">{{ joinGroupFormError }}</p>
@ -76,8 +76,8 @@
<div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true"
aria-labelledby="createGroupTitle">
<div class="modal-header">
<h3 id="createGroupTitle">Create New Group</h3>
<button class="close-button" @click="closeCreateGroupDialog" aria-label="Close">
<h3 id="createGroupTitle">{{ t('groupsPage.createDialog.title') }}</h3>
<button class="close-button" @click="closeCreateGroupDialog" :aria-label="t('groupsPage.createDialog.closeButtonLabel')">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-close" />
</svg>
@ -86,17 +86,17 @@
<form @submit.prevent="handleCreateGroup">
<div class="modal-body">
<div class="form-group">
<label for="newGroupNameInput" class="form-label">Group Name</label>
<label for="newGroupNameInput" class="form-label">{{ t('groupsPage.createDialog.groupNameLabel') }}</label>
<input type="text" id="newGroupNameInput" v-model="newGroupName" class="form-input" required
ref="newGroupNameInputRef" />
<p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">Cancel</button>
<button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">{{ t('groupsPage.createDialog.cancelButton') }}</button>
<button type="submit" class="btn btn-primary ml-2" :disabled="creatingGroup">
<span v-if="creatingGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Create
{{ t('groupsPage.createDialog.createButton') }}
</button>
</div>
</form>
@ -110,6 +110,7 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { useStorage } from '@vueuse/core';
@ -119,6 +120,8 @@ import CreateListModal from '@/components/CreateListModal.vue';
import VButton from '@/components/valerie/VButton.vue';
import VIcon from '@/components/valerie/VIcon.vue';
const { t } = useI18n();
interface Group {
id: number;
name: string;
@ -172,7 +175,7 @@ const fetchGroups = async () => {
cachedGroups.value = response.data;
cachedTimestamp.value = Date.now();
} catch (err) {
fetchError.value = err instanceof Error ? err.message : 'Failed to load groups';
fetchError.value = err instanceof Error ? err.message : t('groupsPage.errors.fetchFailed');
// If we have cached data, keep showing it even if refresh failed
if (cachedGroups.value.length === 0) {
groups.value = [];
@ -197,7 +200,7 @@ onClickOutside(createGroupModalRef, closeCreateGroupDialog);
const handleCreateGroup = async () => {
if (!newGroupName.value.trim()) {
createGroupFormError.value = 'Group name is required';
createGroupFormError.value = t('groupsPage.errors.groupNameRequired');
newGroupNameInputRef.value?.focus();
return;
}
@ -211,7 +214,7 @@ const handleCreateGroup = async () => {
if (newGroup && newGroup.id && newGroup.name) {
groups.value.push(newGroup);
closeCreateGroupDialog();
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully.`, type: 'success' });
notificationStore.addNotification({ message: t('groupsPage.notifications.groupCreatedSuccess', { groupName: newGroup.name }), type: 'success' });
// Update cache
cachedGroups.value = groups.value;
cachedTimestamp.value = Date.now();
@ -219,7 +222,7 @@ const handleCreateGroup = async () => {
throw new Error('Invalid data received from server.');
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to create group. Please try again.';
const message = error instanceof Error ? error.message : t('groupsPage.errors.createFailed');
createGroupFormError.value = message;
console.error('Error creating group:', error);
notificationStore.addNotification({ message, type: 'error' });
@ -230,7 +233,7 @@ const handleCreateGroup = async () => {
const handleJoinGroup = async () => {
if (!inviteCodeToJoin.value.trim()) {
joinGroupFormError.value = 'Invite code is required';
joinGroupFormError.value = t('groupsPage.errors.inviteCodeRequired');
joinInviteCodeInputRef.value?.focus();
return;
}
@ -245,7 +248,7 @@ const handleJoinGroup = async () => {
groups.value.push(joinedGroup);
}
inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' });
notificationStore.addNotification({ message: t('groupsPage.notifications.joinSuccessNamed', { groupName: joinedGroup.name }), type: 'success' });
// Update cache
cachedGroups.value = groups.value;
cachedTimestamp.value = Date.now();
@ -253,10 +256,10 @@ const handleJoinGroup = async () => {
// If API returns only success message, re-fetch groups
await fetchGroups(); // Refresh the list of groups
inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: `Successfully joined group.`, type: 'success' });
notificationStore.addNotification({ message: t('groupsPage.notifications.joinSuccessGeneric'), type: 'success' });
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to join group. Please check the invite code and try again.';
const message = error instanceof Error ? error.message : t('groupsPage.errors.joinFailed');
joinGroupFormError.value = message;
console.error('Error joining group:', error);
notificationStore.addNotification({ message, type: 'error' });
@ -285,7 +288,7 @@ const openCreateListDialog = (group: Group) => {
const onListCreated = (newList: any) => {
notificationStore.addNotification({
message: `List '${newList.name}' created successfully.`,
message: t('groupsPage.notifications.listCreatedSuccess', { listName: newList.name }),
type: 'success'
});
// Optionally refresh the groups list to show the new list

View File

@ -1,12 +1,12 @@
<template>
<main class="container page-padding text-center">
<h1>Welcome to Valerie UI App</h1>
<p class="mb-3">This is the main index page.</p>
<h1>{{ $t('indexPage.welcomeMessage') }}</h1>
<p class="mb-3">{{ $t('indexPage.mainPageInfo') }}</p>
<!-- The ExampleComponent is not provided, so this section is a placeholder -->
<div v-if="todos.length" class="card">
<div class="card-header">
<h3>Sample Todos (from IndexPage data)</h3>
<h3>{{ $t('indexPage.sampleTodosHeader') }}</h3>
</div>
<div class="card-body">
<ul class="item-list">
@ -16,18 +16,21 @@
</div>
</li>
</ul>
<p class="mt-2">Total count from meta: {{ meta.totalCount }}</p>
<p class="mt-2">{{ $t('indexPage.totalCountLabel') }} {{ meta.totalCount }}</p>
</div>
</div>
<p v-else>No todos to display.</p>
<p v-else>{{ $t('indexPage.noTodos') }}</p>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Todo, Meta } from '@/components/models'; // Adjusted path if models.ts is in the same directory
// import ExampleComponent from 'components/ExampleComponent.vue'; // This component is not provided for conversion
const { t } = useI18n(); // Added for consistency, though not strictly needed if only $t in template
const todos = ref<Todo[]>([
{ id: 1, content: 'ct1' },
{ id: 2, content: 'ct2' },

View File

@ -1,12 +1,12 @@
<template>
<main class="neo-container page-padding">
<div v-if="pageInitialLoad && !list && !error" class="text-center py-10">
<VSpinner label="Loading list..." size="lg" />
<VSpinner :label="$t('listDetailPage.loading.list')" size="lg" />
</div>
<VAlert v-else-if="error && !list" type="error" :message="error" class="mb-4">
<template #actions>
<VButton @click="fetchListDetails">Retry</VButton>
<VButton @click="fetchListDetails">{{ $t('listDetailPage.retryButton') }}</VButton>
</template>
</VAlert>
@ -15,10 +15,10 @@
<div class="neo-list-header">
<VHeading :level="1" :text="list.name" class="mb-3 neo-title" />
<div class="neo-header-actions">
<VButton @click="showCostSummaryDialog = true" :disabled="!isOnline" icon-left="clipboard">Cost Summary
<VButton @click="showCostSummaryDialog = true" :disabled="!isOnline" icon-left="clipboard">{{ $t('listDetailPage.buttons.costSummary') }}
</VButton>
<VButton @click="openOcrDialog" :disabled="!isOnline" icon-left="plus">Add via OCR</VButton>
<VBadge :text="list.group_id ? 'Group List' : 'Personal List'" :variant="list.group_id ? 'accent' : 'settled'"
<VButton @click="openOcrDialog" :disabled="!isOnline" icon-left="plus">{{ $t('listDetailPage.buttons.addViaOcr') }}</VButton>
<VBadge :text="list.group_id ? $t('listDetailPage.badges.groupList') : $t('listDetailPage.badges.personalList')" :variant="list.group_id ? 'accent' : 'settled'"
class="neo-status" />
</div>
</div>
@ -26,10 +26,10 @@
<!-- Items List Section -->
<VCard v-if="itemsAreLoading" class="py-10 text-center mt-4">
<VSpinner label="Loading items..." size="lg" />
<VSpinner :label="$t('listDetailPage.loading.items')" size="lg" />
</VCard>
<VCard v-else-if="!itemsAreLoading && list.items.length === 0" variant="empty-state" empty-icon="clipboard"
empty-title="No Items Yet!" empty-message="Add some items using the form below." class="mt-4" />
:empty-title="$t('listDetailPage.items.emptyState.title')" :empty-message="$t('listDetailPage.items.emptyState.message')" class="mt-4" />
<div v-else class="neo-item-list-container mt-4">
<ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id" class="neo-list-item"
@ -42,18 +42,18 @@
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
</label>
<div class="neo-item-actions">
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" aria-label="Edit item">
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" :aria-label="$t('listDetailPage.items.editItemAriaLabel')">
<VIcon name="edit" />
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
:disabled="item.deleting" aria-label="Delete item">
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<VIcon name="trash" />
</button>
</div>
</div>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="item.priceInput = $event"
placeholder="Price" size="sm" class="w-24" step="0.01" @blur="updateItemPrice(item)"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24" step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</li>
@ -63,16 +63,16 @@
<!-- Add New Item Form -->
<form @submit.prevent="onAddItem" class="add-item-form mt-4 p-4 border rounded-lg shadow flex items-center gap-2">
<VIcon name="plus-circle" class="text-gray-400 shrink-0" />
<VFormField class="flex-grow" label="New item name" :label-sr-only="true">
<VInput v-model="newItem.name" placeholder="Add a new item" required ref="itemNameInputRef" />
<VFormField class="flex-grow" :label="$t('listDetailPage.items.addItemForm.itemNameSrLabel')" :label-sr-only="true">
<VInput v-model="newItem.name" :placeholder="$t('listDetailPage.items.addItemForm.placeholder')" required ref="itemNameInputRef" />
</VFormField>
<VFormField label="Quantity" :label-sr-only="true" class="w-24 shrink-0">
<VFormField :label="$t('listDetailPage.items.addItemForm.quantitySrLabel')" :label-sr-only="true" class="w-24 shrink-0">
<VInput type="number" :model-value="newItem.quantity || ''" @update:modelValue="newItem.quantity = $event"
placeholder="Qty" min="1" />
:placeholder="$t('listDetailPage.items.addItemForm.quantityPlaceholder')" min="1" />
</VFormField>
<VButton type="submit" :disabled="addingItem" class="shrink-0">
<VSpinner v-if="addingItem" size="sm" />
<span v-else>Add</span>
<span v-else>{{ $t('listDetailPage.buttons.addItem') }}</span>
</VButton>
</form>
</template>
@ -80,24 +80,24 @@
<!-- Expenses Section (Original Content - Part 3 will refactor this) -->
<section v-if="list && !itemsAreLoading" class="neo-expenses-section">
<div class="neo-expenses-header">
<h2 class="neo-expenses-title">Expenses</h2>
<h2 class="neo-expenses-title">{{ $t('listDetailPage.expensesSection.title') }}</h2>
<button class="neo-action-button" @click="showCreateExpenseForm = true">
<svg class="icon">
<use xlink:href="#icon-plus" />
</svg>
Add Expense
{{ $t('listDetailPage.expensesSection.addExpenseButton') }}
</button>
</div>
<div v-if="listDetailStore.isLoading && expenses.length === 0" class="neo-loading-state">
<div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading expenses...</p>
<p>{{ $t('listDetailPage.expensesSection.loading') }}</p>
</div>
<div v-else-if="listDetailStore.error" class="neo-error-state">
<p>{{ listDetailStore.error }}</p>
<button class="neo-button" @click="listDetailStore.fetchListWithExpenses(String(list?.id))">Retry</button>
<p>{{ listDetailStore.error }}</p> <!-- Assuming listDetailStore.error is a backend message or already translated if generic -->
<button class="neo-button" @click="listDetailStore.fetchListWithExpenses(String(list?.id))">{{ $t('listDetailPage.expensesSection.retryButton') }}</button>
</div>
<div v-else-if="!expenses || expenses.length === 0" class="neo-empty-state">
<p>No expenses recorded for this list yet.</p>
<p>{{ $t('listDetailPage.expensesSection.emptyState') }}</p>
</div>
<div v-else>
<div v-for="expense in expenses" :key="expense.id" class="neo-expense-card">
@ -108,34 +108,34 @@
</span>
</div>
<div class="neo-expense-details">
Paid by: <strong>{{ expense.paid_by_user?.name || expense.paid_by_user?.email || `User ID:
{{ $t('listDetailPage.expensesSection.paidBy') }} <strong>{{ expense.paid_by_user?.name || expense.paid_by_user?.email || `User ID:
${expense.paid_by_user_id}` }}</strong>
on {{ new Date(expense.expense_date).toLocaleDateString() }}
{{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(expense.expense_date).toLocaleDateString() }}
</div>
<div class="neo-splits-list">
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
<div class="neo-split-details">
<strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }}</strong> owes {{
<strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }}</strong> {{ $t('listDetailPage.expensesSection.owes') }} {{
formatCurrency(split.owed_amount) }}
<span class="neo-expense-status" :class="getStatusClass(split.status)">
{{ getSplitStatusText(split.status) }}
</span>
</div>
<div class="neo-split-details">
Paid: {{ getPaidAmountForSplitDisplay(split) }}
<span v-if="split.paid_at"> on {{ new Date(split.paid_at).toLocaleDateString() }}</span>
{{ $t('listDetailPage.expensesSection.paidAmount') }} {{ getPaidAmountForSplitDisplay(split) }}
<span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(split.paid_at).toLocaleDateString() }}</span>
</div>
<button v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
class="neo-button neo-button-primary" @click="openSettleShareModal(expense, split)"
:disabled="isSettlementLoading">
Settle My Share
{{ $t('listDetailPage.expensesSection.settleShareButton') }}
</button>
<ul v-if="split.settlement_activities && split.settlement_activities.length > 0"
class="neo-settlement-activities">
<li v-for="activity in split.settlement_activities" :key="activity.id">
Activity: {{ formatCurrency(activity.amount_paid) }} by {{ activity.payer?.name || `User
${activity.paid_by_user_id}` }} on {{ new Date(activity.paid_at).toLocaleDateString() }}
{{ $t('listDetailPage.expensesSection.activityLabel') }} {{ formatCurrency(activity.amount_paid) }} {{ $t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User
${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(activity.paid_at).toLocaleDateString() }}
</li>
</ul>
</div>
@ -149,10 +149,10 @@
@close="showCreateExpenseForm = false" @created="handleExpenseCreated" />
<!-- OCR Dialog -->
<VModal v-model="showOcrDialogState" title="Add Items via OCR" @update:modelValue="!$event && closeOcrDialog()">
<VModal v-model="showOcrDialogState" :title="$t('listDetailPage.modals.ocr.title')" @update:modelValue="!$event && closeOcrDialog()">
<template #default>
<div v-if="ocrLoading" class="text-center">
<VSpinner label="Processing image..." />
<VSpinner :label="$t('listDetailPage.loading.ocrProcessing')" />
</div>
<VList v-else-if="ocrItems.length > 0">
<VListItem v-for="(ocrItem, index) in ocrItems" :key="index">
@ -163,22 +163,22 @@
</div>
</VListItem>
</VList>
<VFormField v-else label="Upload Image" :error-message="ocrError || undefined">
<VFormField v-else :label="$t('listDetailPage.modals.ocr.uploadLabel')" :error-message="ocrError || undefined">
<VInput type="file" id="ocrFile" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"
:model-value="''" />
</VFormField>
</template>
<template #footer>
<VButton variant="neutral" @click="closeOcrDialog">Cancel</VButton>
<VButton variant="neutral" @click="closeOcrDialog">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton v-if="ocrItems.length > 0" type="button" variant="primary" @click="addOcrItems"
:disabled="addingOcrItems">
<VSpinner v-if="addingOcrItems" size="sm" /> Add Items
<VSpinner v-if="addingOcrItems" size="sm" :label="$t('listDetailPage.loading.addingOcrItems')" /> {{ $t('listDetailPage.buttons.addItems') }}
</VButton>
</template>
</VModal>
<!-- Confirmation Dialog -->
<VModal v-model="showConfirmDialogState" title="Confirmation" @update:modelValue="!$event && cancelConfirmation()"
<VModal v-model="showConfirmDialogState" :title="$t('listDetailPage.modals.confirmation.title')" @update:modelValue="!$event && cancelConfirmation()"
size="sm">
<template #default>
<div class="text-center">
@ -187,34 +187,34 @@
</div>
</template>
<template #footer>
<VButton variant="neutral" @click="cancelConfirmation">Cancel</VButton>
<VButton variant="primary" @click="handleConfirmedAction">Confirm</VButton>
<VButton variant="neutral" @click="cancelConfirmation">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton variant="primary" @click="handleConfirmedAction">{{ $t('listDetailPage.buttons.confirm') }}</VButton>
</template>
</VModal>
<!-- Cost Summary Dialog -->
<VModal v-model="showCostSummaryDialog" title="List Cost Summary" @update:modelValue="showCostSummaryDialog = false"
<VModal v-model="showCostSummaryDialog" :title="$t('listDetailPage.modals.costSummary.title')" @update:modelValue="showCostSummaryDialog = false"
size="lg">
<template #default>
<div v-if="costSummaryLoading" class="text-center">
<VSpinner label="Loading summary..." />
<VSpinner :label="$t('listDetailPage.loading.costSummary')" />
</div>
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
<div v-else-if="listCostSummary">
<div class="mb-3 cost-overview">
<p><strong>Total List Cost:</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>Equal Share Per User:</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>Participating Users:</strong> {{ listCostSummary.num_participating_users }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.totalCostLabel') }}</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.equalShareLabel') }}</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.participantsLabel') }}</strong> {{ listCostSummary.num_participating_users }}</p>
</div>
<h4>User Balances</h4>
<h4>{{ $t('listDetailPage.costSummaryModal.userBalancesHeader') }}</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>User</th>
<th class="text-right">Items Added Value</th>
<th class="text-right">Amount Due</th>
<th class="text-right">Balance</th>
<th>{{ $t('listDetailPage.costSummaryModal.tableHeaders.user') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.itemsAddedValue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.amountDue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.balance') }}</th>
</tr>
</thead>
<tbody>
@ -231,60 +231,60 @@
</table>
</div>
</div>
<p v-else>No cost summary available.</p>
<p v-else>{{ $t('listDetailPage.costSummaryModal.emptyState') }}</p>
</template>
<template #footer>
<VButton variant="primary" @click="showCostSummaryDialog = false">Close</VButton>
<VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}</VButton>
</template>
</VModal>
<!-- Settle Share Modal -->
<VModal v-model="showSettleModal" title="Settle Share" @update:modelValue="!$event && closeSettleShareModal()"
<VModal v-model="showSettleModal" :title="$t('listDetailPage.settleShareModal.title')" @update:modelValue="!$event && closeSettleShareModal()"
size="md">
<template #default>
<div v-if="isSettlementLoading" class="text-center">
<VSpinner label="Processing settlement..." />
<VSpinner :label="$t('listDetailPage.loading.settlement')" />
</div>
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
<div v-else>
<p>Settle amount for {{ selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email ||
`User ID: ${selectedSplitForSettlement?.user_id}` }}:</p>
<VFormField label="Amount" :error-message="settleAmountError || undefined">
<p>{{ $t('listDetailPage.settleShareModal.settleAmountFor', { userName: selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}` }) }}</p>
<VFormField :label="$t('listDetailPage.settleShareModal.amountLabel')" :error-message="settleAmountError || undefined">
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
</VFormField>
</div>
</template>
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">Cancel</VButton>
<VButton variant="primary" @click="handleConfirmSettle">Confirm</VButton>
<VButton variant="neutral" @click="closeSettleShareModal">{{ $t('listDetailPage.settleShareModal.cancelButton') }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton') }}</VButton>
</template>
</VModal>
<!-- Edit Item Dialog -->
<VModal v-model="showEditDialog" title="Edit Item" @update:modelValue="!$event && closeEditDialog()">
<VModal v-model="showEditDialog" :title="$t('listDetailPage.modals.editItem.title')" @update:modelValue="!$event && closeEditDialog()">
<template #default>
<VFormField v-if="editingItem" label="Item Name" class="mb-4">
<VFormField v-if="editingItem" :label="$t('listDetailPage.modals.editItem.nameLabel')" class="mb-4">
<VInput type="text" id="editItemName" v-model="editingItem.name" required />
</VFormField>
<VFormField v-if="editingItem" label="Quantity">
<VFormField v-if="editingItem" :label="$t('listDetailPage.modals.editItem.quantityLabel')">
<VInput type="number" id="editItemQuantity" :model-value="editingItem.quantity || ''"
@update:modelValue="editingItem.quantity = $event" min="1" />
</VFormField>
</template>
<template #footer>
<VButton variant="neutral" @click="closeEditDialog">Cancel</VButton>
<VButton variant="primary" @click="handleConfirmEdit" :disabled="!editingItem?.name.trim()">Save Changes
<VButton variant="neutral" @click="closeEditDialog">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton variant="primary" @click="handleConfirmEdit" :disabled="!editingItem?.name.trim()">{{ $t('listDetailPage.buttons.saveChanges') }}
</VButton>
</template>
</VModal>
<VAlert v-if="!list && !pageInitialLoad" type="info" message="Group not found or an error occurred." />
<VAlert v-if="!list && !pageInitialLoad" type="info" :message="$t('listDetailPage.errors.genericLoadFailure')" />
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Keep for item management
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core'; // onClickOutside removed
import { useNotificationStore } from '@/stores/notifications';
@ -313,6 +313,7 @@ import VListItem from '@/components/valerie/VListItem.vue';
import VCheckbox from '@/components/valerie/VCheckbox.vue';
// VTextarea and VSelect are not used in this part of the refactor for ListDetailPage
const { t } = useI18n();
// UI-specific properties that we add to items
interface ItemWithUI extends Item {
@ -433,31 +434,23 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
};
const fetchListDetails = async () => {
// If pageInitialLoad is still true here, it means no shell was loaded.
// The main spinner might be showing. We're about to fetch details, so turn off main spinner.
if (pageInitialLoad.value) {
pageInitialLoad.value = false;
}
itemsAreLoading.value = true;
// Check for pre-fetched full data first
const routeId = String(route.params.id);
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
try {
let response;
if (cachedFullData) {
// Use cached data
response = { data: JSON.parse(cachedFullData) };
// Clear the cache after using it
sessionStorage.removeItem(`listDetailFull_${routeId}`);
} else {
// Fetch fresh data
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
}
const rawList = response.data as ListWithExpenses;
// Map API response to local List type
const localList: List = {
id: rawList.id,
name: rawList.name,
@ -477,17 +470,15 @@ const fetchListDetails = async () => {
await fetchListCostSummary();
}
} catch (err: unknown) {
const errorMessage = (err instanceof Error ? err.message : String(err)) || 'Failed to load list details.';
if (!list.value) { // If there was no shell AND this fetch failed
error.value = errorMessage; // This error is for the whole page
const apiErrorMessage = err instanceof Error ? err.message : String(err);
const fallbackErrorMessage = t('listDetailPage.errors.fetchFailed');
if (!list.value) {
error.value = apiErrorMessage || fallbackErrorMessage;
} else {
// We have a shell, but items failed to load.
// Show a notification for item loading failure. list.items will remain as per shell (empty).
notificationStore.addNotification({ message: `Failed to load items: ${errorMessage}`, type: 'error' });
notificationStore.addNotification({ message: t('listDetailPage.errors.fetchItemsFailed', { errorMessage: apiErrorMessage }), type: 'error' });
}
} finally {
itemsAreLoading.value = false;
// If list is still null and no error was set (e.g. silent failure), ensure pageInitialLoad is false.
if (!list.value && !error.value) {
pageInitialLoad.value = false;
}
@ -532,7 +523,7 @@ const isItemPendingSync = (item: Item) => {
const onAddItem = async () => {
if (!list.value || !newItem.value.name.trim()) {
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
notificationStore.addNotification({ message: t('listDetailPage.notifications.enterItemName'), type: 'warning' });
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
@ -541,7 +532,7 @@ const onAddItem = async () => {
addingItem.value = true;
if (!isOnline.value) {
const offlinePayload: any = {
const offlinePayload: any = { // Define explicit type later if needed
name: newItem.value.name
};
if (typeof newItem.value.quantity !== 'undefined') {
@ -555,12 +546,12 @@ const onAddItem = async () => {
}
});
const optimisticItem: ItemWithUI = {
id: Date.now(),
id: Date.now(), // Temporary ID for offline
name: newItem.value.name,
quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null),
is_complete: false,
price: null,
version: 1,
version: 1, // Assuming initial version
updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
list_id: list.value.id,
@ -575,6 +566,7 @@ const onAddItem = async () => {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
addingItem.value = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' }); // Optimistic UI
return;
}
@ -592,8 +584,9 @@ const onAddItem = async () => {
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' });
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add item.', type: 'error' });
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.addItemFailed'), type: 'error' });
} finally {
addingItem.value = false;
}
@ -618,6 +611,7 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
}
});
item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
return;
}
@ -627,9 +621,10 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
{ completed: newCompleteStatus, version: item.version }
);
item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) {
item.is_complete = originalCompleteStatus;
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
item.is_complete = originalCompleteStatus; // Revert optimistic update
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'), type: 'error' });
} finally {
item.updating = false;
}
@ -638,11 +633,12 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
const updateItemPrice = async (item: ItemWithUI) => {
if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice?.toString()) return;
if (item.price === newPrice?.toString()) return; // No change
item.updating = true;
const originalPrice = item.price;
const originalPriceInput = item.priceInput;
item.price = newPrice?.toString() || null;
item.price = newPrice?.toString() || null; // Optimistic update
if (!isOnline.value) {
offlineStore.addAction({
type: 'update_list_item',
@ -650,13 +646,14 @@ const updateItemPrice = async (item: ItemWithUI) => {
listId: String(list.value.id),
itemId: String(item.id),
data: {
price: newPrice ?? null,
completed: item.is_complete
price: newPrice ?? null, // Ensure null is sent if cleared
completed: item.is_complete // Keep completion status
},
version: item.version
}
});
item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
return;
}
@ -666,10 +663,11 @@ const updateItemPrice = async (item: ItemWithUI) => {
{ price: newPrice?.toString(), completed: item.is_complete, version: item.version }
);
item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) {
item.price = originalPrice;
item.price = originalPrice; // Revert optimistic update
item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemPriceFailed'), type: 'error' });
} finally {
item.updating = false;
}
@ -678,6 +676,7 @@ const updateItemPrice = async (item: ItemWithUI) => {
const deleteItem = async (item: ItemWithUI) => {
if (!list.value) return;
item.deleting = true;
const originalItems = [...list.value.items]; // For potential revert
if (!isOnline.value) {
offlineStore.addAction({
@ -687,29 +686,35 @@ const deleteItem = async (item: ItemWithUI) => {
itemId: String(item.id)
}
});
list.value.items = list.value.items.filter(i => i.id !== item.id);
list.value.items = list.value.items.filter(i => i.id !== item.id); // Optimistic UI
item.deleting = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
return;
}
try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
list.value.items = list.value.items.filter(i => i.id !== item.id);
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
list.value.items = originalItems; // Revert optimistic UI
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.deleteItemFailed'), type: 'error' });
} finally {
item.deleting = false;
}
};
const confirmUpdateItem = (item: ItemWithUI, newCompleteStatus: boolean) => {
confirmDialogMessage.value = `Mark "${item.name}" as ${newCompleteStatus ? 'complete' : 'incomplete'}?`;
confirmDialogMessage.value = t('listDetailPage.confirmations.updateMessage', {
itemName: item.name,
status: newCompleteStatus ? t('listDetailPage.confirmations.statusComplete') : t('listDetailPage.confirmations.statusIncomplete')
});
pendingAction.value = () => updateItem(item, newCompleteStatus);
showConfirmDialogState.value = true;
};
const confirmDeleteItem = (item: ItemWithUI) => {
confirmDialogMessage.value = `Delete "${item.name}"? This cannot be undone.`;
confirmDialogMessage.value = t('listDetailPage.confirmations.deleteMessage', { itemName: item.name });
pendingAction.value = () => deleteItem(item);
showConfirmDialogState.value = true;
};
@ -723,20 +728,19 @@ const handleConfirmedAction = async () => {
const cancelConfirmation = () => {
showConfirmDialogState.value = false;
pendingAction.value = null;
confirmDialogMessage.value = ''; // Clear message
};
const openOcrDialog = () => {
ocrItems.value = [];
ocrError.value = null;
resetOcrFileDialog();
resetOcrFileDialog(); // From useFileDialog
showOcrDialogState.value = true;
nextTick(() => {
// For VInput type file, direct .value = '' might not work or be needed.
// VInput should handle its own reset if necessary, or this ref might target the native input inside.
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) { // Assuming VInput exposes $el
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) { // Fallback if ref is native input
} else if (ocrFileInputRef.value) { // Native input
(ocrFileInputRef.value as any).value = '';
}
});
@ -774,17 +778,18 @@ const handleOcrUpload = async (file: File) => {
});
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
if (ocrItems.value.length === 0) {
ocrError.value = "No items extracted from the image.";
ocrError.value = t('listDetailPage.errors.ocrNoItems');
}
} catch (err) {
ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.';
ocrError.value = (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.ocrFailed');
} finally {
ocrLoading.value = false;
// Reset file input
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) {
(ocrFileInputRef.value as any).value = '';
} else if (ocrFileInputRef.value) { // Native input
(ocrFileInputRef.value as any).value = '';
}
}
};
@ -798,16 +803,18 @@ const addOcrItems = async () => {
if (!item.name.trim()) continue;
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ name: item.name, quantity: "1" } // Assuming default quantity 1 for OCR items
{ name: item.name, quantity: "1" } // Default quantity 1
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
successCount++;
}
if (successCount > 0) notificationStore.addNotification({ message: `${successCount} item(s) added successfully from OCR.`, type: 'success' });
if (successCount > 0) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemsAddedSuccessOcr', { count: successCount }), type: 'success' });
}
closeOcrDialog();
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add OCR items.', type: 'error' });
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.addOcrItemsFailed'), type: 'error' });
} finally {
addingOcrItems.value = false;
}
@ -821,7 +828,7 @@ const fetchListCostSummary = async () => {
const response = await apiClient.get(API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id));
listCostSummary.value = response.data;
} catch (err) {
costSummaryError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load cost summary.';
costSummaryError.value = (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.loadCostSummaryFailed');
listCostSummary.value = null;
} finally {
costSummaryLoading.value = false;
@ -844,19 +851,19 @@ const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => {
switch (status) {
case ExpenseSplitStatusEnum.PAID: return 'Paid';
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return 'Partially Paid';
case ExpenseSplitStatusEnum.UNPAID: return 'Unpaid';
default: return status;
case ExpenseSplitStatusEnum.PAID: return t('listDetailPage.status.paid');
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallyPaid');
case ExpenseSplitStatusEnum.UNPAID: return t('listDetailPage.status.unpaid');
default: return t('listDetailPage.status.unknown');
}
};
const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => {
switch (status) {
case ExpenseOverallStatusEnum.PAID: return 'Settled';
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return 'Partially Settled';
case ExpenseOverallStatusEnum.UNPAID: return 'Unsettled';
default: return status;
case ExpenseOverallStatusEnum.PAID: return t('listDetailPage.status.settled');
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallySettled');
case ExpenseOverallStatusEnum.UNPAID: return t('listDetailPage.status.unsettled');
default: return t('listDetailPage.status.unknown');
}
};
@ -872,76 +879,74 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return;
return; // Don't interfere with typing
}
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) {
// Check if any modal is open, if so, don't trigger
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value || showEditDialog.value || showSettleModal.value || showCreateExpenseForm.value) {
return;
}
event.preventDefault();
if (itemNameInputRef.value?.$el) {
if (itemNameInputRef.value?.$el) { // Focus the add item input
(itemNameInputRef.value.$el as HTMLElement).focus();
}
}
});
let touchStartX = 0;
const SWIPE_THRESHOLD = 50;
const SWIPE_THRESHOLD = 50; // Pixels
const handleTouchStart = (event: TouchEvent) => {
touchStartX = event.changedTouches[0].clientX;
};
const handleTouchMove = () => {
const handleTouchMove = (event: TouchEvent, item: ItemWithUI) => {
// This function might be used for swipe-to-reveal actions in the future
// For now, it's a placeholder or can be removed if not used.
};
const handleTouchEnd = () => {
const handleTouchEnd = (event: TouchEvent, item: ItemWithUI) => {
// This function might be used for swipe-to-reveal actions in the future
// For now, it's a placeholder or can be removed if not used.
};
onMounted(() => {
pageInitialLoad.value = true;
itemsAreLoading.value = false;
error.value = null; // Clear stale errors on mount
error.value = null;
if (!route.params.id) {
error.value = 'No list ID provided';
pageInitialLoad.value = false; // Stop initial load phase, show error
listDetailStore.setError('No list ID provided for expenses.'); // Set error in expense store
error.value = t('listDetailPage.errors.fetchFailed'); // Generic error if no ID
pageInitialLoad.value = false;
listDetailStore.setError(t('listDetailPage.errors.fetchFailed'));
return;
}
// Attempt to load shell data from sessionStorage
const listShellJSON = sessionStorage.getItem('listDetailShell');
const routeId = String(route.params.id);
if (listShellJSON) {
const shellData = JSON.parse(listShellJSON);
// Ensure the shell data is for the current list
if (shellData.id === parseInt(routeId, 10)) {
list.value = {
id: shellData.id,
name: shellData.name,
description: shellData.description,
is_complete: false, // Assume not complete until full data loaded
items: [], // Start with no items, they will be fetched by fetchListDetails
version: 0, // Placeholder, will be updated
updated_at: new Date().toISOString(), // Placeholder
is_complete: false,
items: [],
version: 0,
updated_at: new Date().toISOString(),
group_id: shellData.group_id,
};
pageInitialLoad.value = false; // Shell loaded, main page spinner can go
// Optionally, clear the sessionStorage item after use
// sessionStorage.removeItem('listDetailShell');
pageInitialLoad.value = false;
} else {
// Shell data is for a different list, clear it
sessionStorage.removeItem('listDetailShell');
// pageInitialLoad remains true, will be set to false by fetchListDetails
}
}
fetchListDetails().then(() => { // Fetches items
fetchListDetails().then(() => {
startPolling();
});
// Fetch expenses using the store when component is mounted
const routeParamsId = route.params.id;
listDetailStore.fetchListWithExpenses(String(routeParamsId));
});
@ -951,7 +956,7 @@ onUnmounted(() => {
});
const editItem = (item: Item) => {
editingItem.value = { ...item };
editingItem.value = { ...item }; // Clone item for editing
showEditDialog.value = true;
};
@ -963,25 +968,22 @@ const closeEditDialog = () => {
const handleConfirmEdit = async () => {
if (!editingItem.value || !list.value) return;
const item = editingItem.value;
const originalItem = list.value.items.find(i => i.id === item.id);
if (!originalItem) return;
const itemToUpdate = editingItem.value; // Already a clone
try {
const response = await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(itemToUpdate.id)),
{
name: item.name,
quantity: item.quantity?.toString(),
version: item.version
name: itemToUpdate.name,
quantity: itemToUpdate.quantity?.toString(), // Ensure quantity is string or null
version: itemToUpdate.version
}
);
// Update the item in the list
const updatedItem = response.data as Item;
const index = list.value.items.findIndex(i => i.id === item.id);
const updatedItemFromApi = response.data as Item;
const index = list.value.items.findIndex(i => i.id === updatedItemFromApi.id);
if (index !== -1) {
list.value.items[index] = processListItems([updatedItem])[0];
list.value.items[index] = processListItems([updatedItemFromApi])[0];
}
notificationStore.addNotification({
@ -991,7 +993,7 @@ const handleConfirmEdit = async () => {
closeEditDialog();
} catch (err) {
notificationStore.addNotification({
message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item',
message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'),
type: 'error'
});
}
@ -999,7 +1001,7 @@ const handleConfirmEdit = async () => {
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
if (split.user_id !== authStore.user?.id) {
notificationStore.addNotification({ message: "You can only settle your own shares.", type: 'warning' });
notificationStore.addNotification({ message: t('listDetailPage.notifications.cannotSettleOthersShares'), type: 'warning' });
return;
}
selectedSplitForSettlement.value = split;
@ -1023,24 +1025,24 @@ const closeSettleShareModal = () => {
const validateSettleAmount = (): boolean => {
settleAmountError.value = null;
if (!settleAmount.value.trim()) {
settleAmountError.value = 'Please enter an amount.';
settleAmountError.value = t('listDetailPage.settleShareModal.errors.enterAmount');
return false;
}
const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = 'Please enter a positive amount.';
settleAmountError.value = t('listDetailPage.settleShareModal.errors.positiveAmount');
return false;
}
if (selectedSplitForSettlement.value) {
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(selectedSplitForSettlement.value.id));
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
const remaining = owed.minus(alreadyPaid);
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) { // Epsilon for float issues
settleAmountError.value = `Amount cannot exceed remaining: ${formatCurrency(remaining.toFixed(2))}.`;
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
return false;
}
} else {
settleAmountError.value = 'Error: No split selected.'; // Should not happen
settleAmountError.value = t('listDetailPage.settleShareModal.errors.noSplitSelected');
return false;
}
return true;
@ -1050,13 +1052,13 @@ const currentListIdForRefetch = computed(() => listDetailStore.currentList?.id |
const handleConfirmSettle = async () => {
if (!selectedSplitForSettlement.value || !authStore.user?.id || !currentListIdForRefetch.value) {
notificationStore.addNotification({ message: 'Cannot process settlement: missing data.', type: 'error' });
notificationStore.addNotification({ message: t('listDetailPage.notifications.settlementDataMissing'), type: 'error' });
return;
}
// Use settleAmount.value which is the confirmed amount (remaining amount for MVP)
const activityData: SettlementActivityCreate = {
expense_split_id: selectedSplitForSettlement.value.id,
paid_by_user_id: Number(authStore.user.id), // Convert to number
paid_by_user_id: Number(authStore.user.id),
amount_paid: new Decimal(settleAmount.value).toString(),
paid_at: new Date().toISOString(),
};
@ -1068,15 +1070,14 @@ const handleConfirmSettle = async () => {
});
if (success) {
notificationStore.addNotification({ message: 'Share settled successfully!', type: 'success' });
notificationStore.addNotification({ message: t('listDetailPage.notifications.settleShareSuccess'), type: 'success' });
closeSettleShareModal();
} else {
notificationStore.addNotification({ message: listDetailStore.error || 'Failed to settle share.', type: 'error' });
notificationStore.addNotification({ message: listDetailStore.error || t('listDetailPage.notifications.settleShareFailed'), type: 'error' });
}
};
const handleExpenseCreated = (expense: any) => {
// Refresh the expenses list
if (list.value?.id) {
listDetailStore.fetchListWithExpenses(String(list.value.id));
}

View File

@ -4,25 +4,25 @@
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">Retry</VButton>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
</template>
</VAlert>
<VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
:empty-title="noListsMessage">
:empty-title="t(noListsMessageKey.value)">
<template #default>
<p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p>
<p v-else>This group doesn't have any lists yet.</p>
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
<p v-else>{{ t('listsPage.emptyState.groupSpecificInfo') }}</p>
</template>
<template #empty-actions>
<VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus">
Create New List
{{ t('listsPage.createNewListButton') }}
</VButton>
</template>
</VCard>
<div v-else-if="loading && lists.length === 0" class="loading-placeholder">
Loading lists...
{{ t('listsPage.loadingLists') }}
</div>
<div v-else>
@ -32,7 +32,7 @@
@touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd"
:data-list-id="list.id">
<div class="neo-list-header">{{ list.name }}</div>
<div class="neo-list-desc">{{ list.description || 'No description' }}</div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id || item.tempId" class="neo-list-item" :data-item-id="item.id"
:data-item-temp-id="item.tempId" :class="{ 'is-updating': item.updating }">
@ -47,7 +47,7 @@
<li class="neo-list-item new-item-input-container">
<label class="neo-checkbox-label">
<input type="checkbox" disabled />
<input type="text" class="neo-new-item-input" placeholder="Add new item..." ref="newItemInputRefs"
<input type="text" class="neo-new-item-input" :placeholder="t('listsPage.addItemPlaceholder')" ref="newItemInputRefs"
:data-list-id="list.id" @keyup.enter="addNewItem(list, $event)"
@blur="handleNewItemBlur(list, $event)" @click.stop />
</label>
@ -55,7 +55,7 @@
</ul>
</div>
<div class="neo-create-list-card" @click="showCreateModal = true" ref="createListCardRef">
+ Create a new list
{{ t('listsPage.createCard.title') }}
</div>
</div>
</div>
@ -66,15 +66,18 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Adjust path as needed
import CreateListModal from '@/components/CreateListModal.vue'; // Adjust path as needed
import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue'; // Adjust path as needed
import VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed
import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
import VAlert from '@/components/valerie/VAlert.vue'; // Adjust path as needed
import VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed
import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
import { animate } from 'motion';
const { t } = useI18n();
interface List {
id: number;
name: string;
@ -155,17 +158,17 @@ const fetchCurrentViewGroupName = async () => {
const pageTitle = computed(() => {
if (currentGroupId.value) {
return currentViewedGroup.value
? `Lists for ${currentViewedGroup.value.name}`
: `Lists for Group ${currentGroupId.value}`;
? t('listsPage.pageTitle.forGroup', { groupName: currentViewedGroup.value.name })
: t('listsPage.pageTitle.forGroupId', { groupId: currentGroupId.value });
}
return 'My Lists';
return t('listsPage.pageTitle.myLists');
});
const noListsMessage = computed(() => {
const noListsMessageKey = computed(() => {
if (currentGroupId.value) {
return 'No lists found for this group.';
return 'listsPage.emptyState.noListsForGroup';
}
return 'You have no lists yet.';
return 'listsPage.emptyState.noListsYet';
});
const fetchAllAccessibleGroups = async () => {
@ -202,7 +205,7 @@ const fetchLists = async () => {
cachedLists.value = JSON.parse(JSON.stringify(response.data));
cachedTimestamp.value = Date.now();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch lists.';
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
console.error(error.value, err);
if (cachedLists.value.length === 0) lists.value = [];
} finally {

View File

@ -7,18 +7,18 @@
<div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2">
<label for="email" class="form-label">Email</label>
<label for="email" class="form-label">{{ t('loginPage.emailLabel') }}</label>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div>
<div class="form-group mb-3">
<label for="password" class="form-label">Password</label>
<label for="password" class="form-label">{{ t('loginPage.passwordLabel') }}</label>
<div class="input-with-icon-append">
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
required autocomplete="current-password" />
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
aria-label="Toggle password visibility">
:aria-label="t('loginPage.togglePasswordVisibilityLabel')">
<svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg> <!-- Placeholder for visibility icons -->
@ -31,11 +31,11 @@
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Login
{{ t('loginPage.loginButton') }}
</button>
<div class="text-center mt-2">
<router-link to="/auth/signup" class="link-styled">Don't have an account? Sign up</router-link>
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
</div>
<SocialLoginButtons />
@ -47,6 +47,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
@ -57,6 +58,8 @@ const route = useRoute();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const { t } = useI18n();
const email = ref('');
const password = ref('');
const isPwdVisible = ref(false);
@ -71,12 +74,12 @@ const isValidEmail = (val: string): boolean => {
const validateForm = (): boolean => {
formErrors.value = {};
if (!email.value.trim()) {
formErrors.value.email = 'Email is required';
formErrors.value.email = t('loginPage.errors.emailRequired');
} else if (!isValidEmail(email.value)) {
formErrors.value.email = 'Invalid email format';
formErrors.value.email = t('loginPage.errors.emailInvalid');
}
if (!password.value) {
formErrors.value.password = 'Password is required';
formErrors.value.password = t('loginPage.errors.passwordRequired');
}
return Object.keys(formErrors.value).length === 0;
};
@ -89,11 +92,11 @@ const onSubmit = async () => {
formErrors.value.general = undefined; // Clear previous general errors
try {
await authStore.login(email.value, password.value);
notificationStore.addNotification({ message: 'Login successful', type: 'success' });
notificationStore.addNotification({ message: t('loginPage.notifications.loginSuccess'), type: 'success' });
const redirectPath = (route.query.redirect as string) || '/';
router.push(redirectPath);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Login failed. Please check your credentials.';
const message = error instanceof Error ? error.message : t('loginPage.errors.loginFailed');
formErrors.value.general = message;
console.error(message, error);
notificationStore.addNotification({ message, type: 'error' });

View File

@ -1,12 +1,12 @@
<template>
<main class="container page-padding">
<div class="page-header">
<h1 class="mb-3">My Assigned Chores</h1>
<h1 class="mb-3">{{ $t('myChoresPage.title') }}</h1>
<div class="header-controls">
<label class="toggle-switch">
<input type="checkbox" v-model="showCompleted" @change="loadAssignments">
<span class="toggle-slider"></span>
Show Completed
{{ $t('myChoresPage.showCompletedToggle') }}
</label>
</div>
</div>
@ -17,7 +17,7 @@
<div v-if="assignmentsByTimeline.overdue.length > 0" class="timeline-section overdue">
<div class="timeline-header">
<div class="timeline-dot overdue"></div>
<h2 class="timeline-title">Overdue</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.overdue') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.overdue.length }}</span>
</div>
<div class="timeline-items">
@ -29,7 +29,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -39,7 +39,7 @@
<div class="assignment-meta">
<div class="assignment-due-date overdue">
<span class="material-icons">schedule</span>
Due {{ formatDate(assignment.due_date) }}
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -49,7 +49,7 @@
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
Mark Complete
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
@ -61,7 +61,7 @@
<div v-if="assignmentsByTimeline.today.length > 0" class="timeline-section today">
<div class="timeline-header">
<div class="timeline-dot today"></div>
<h2 class="timeline-title">Due Today</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.today') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.today.length }}</span>
</div>
<div class="timeline-items">
@ -73,7 +73,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -83,7 +83,7 @@
<div class="assignment-meta">
<div class="assignment-due-date today">
<span class="material-icons">today</span>
Due Today
{{ $t('myChoresPage.choreCard.dueToday') }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -93,7 +93,7 @@
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
Mark Complete
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
@ -105,7 +105,7 @@
<div v-if="assignmentsByTimeline.thisWeek.length > 0" class="timeline-section this-week">
<div class="timeline-header">
<div class="timeline-dot this-week"></div>
<h2 class="timeline-title">This Week</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.thisWeek') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.thisWeek.length }}</span>
</div>
<div class="timeline-items">
@ -117,7 +117,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -127,7 +127,7 @@
<div class="assignment-meta">
<div class="assignment-due-date this-week">
<span class="material-icons">date_range</span>
Due {{ formatDate(assignment.due_date) }}
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -137,7 +137,7 @@
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
Mark Complete
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
@ -149,7 +149,7 @@
<div v-if="assignmentsByTimeline.later.length > 0" class="timeline-section later">
<div class="timeline-header">
<div class="timeline-dot later"></div>
<h2 class="timeline-title">Later</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.later') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.later.length }}</span>
</div>
<div class="timeline-items">
@ -161,7 +161,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -171,7 +171,7 @@
<div class="assignment-meta">
<div class="assignment-due-date later">
<span class="material-icons">schedule</span>
Due {{ formatDate(assignment.due_date) }}
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -181,7 +181,7 @@
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
Mark Complete
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
@ -193,7 +193,7 @@
<div v-if="showCompleted && assignmentsByTimeline.completed.length > 0" class="timeline-section completed">
<div class="timeline-header">
<div class="timeline-dot completed"></div>
<h2 class="timeline-title">Completed</h2>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.completed') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.completed.length }}</span>
</div>
<div class="timeline-items">
@ -205,7 +205,7 @@
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? 'Personal' : 'Group' }}
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
@ -215,7 +215,7 @@
<div class="assignment-meta">
<div class="assignment-due-date completed">
<span class="material-icons">check_circle</span>
Completed {{ formatDate(assignment.completed_at || assignment.updated_at) }}
{{ $t('myChoresPage.choreCard.completedPrefix') }} {{ formatDate(assignment.completed_at || assignment.updated_at) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
@ -231,14 +231,14 @@
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>No Assignments Yet!</h3>
<p v-if="showCompleted">You have no chore assignments (completed or pending).</p>
<p v-else>You have no pending chore assignments.</p>
<h3>{{ $t('myChoresPage.emptyState.title') }}</h3>
<p v-if="showCompleted">{{ $t('myChoresPage.emptyState.noAssignmentsAll') }}</p>
<p v-else>{{ $t('myChoresPage.emptyState.noAssignmentsPending') }}</p>
<router-link to="/chores" class="btn btn-primary mt-2">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-eye" />
</svg>
View All Chores
{{ $t('myChoresPage.emptyState.viewAllChoresButton') }}
</router-link>
</div>
</main>
@ -246,11 +246,13 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { ChoreAssignment, ChoreFrequency } from '../types/chore'
const { t } = useI18n()
const notificationStore = useNotificationStore()
// State
@ -310,13 +312,14 @@ const assignmentsByTimeline = computed(() => {
return timeline
})
const frequencyOptions = [
{ label: 'One Time', value: 'one_time' as ChoreFrequency },
{ label: 'Daily', value: 'daily' as ChoreFrequency },
{ label: 'Weekly', value: 'weekly' as ChoreFrequency },
{ label: 'Monthly', value: 'monthly' as ChoreFrequency },
{ label: 'Custom', value: 'custom' as ChoreFrequency }
]
// frequencyOptions is not directly used for display labels anymore, but can be kept for logic if needed elsewhere.
// const frequencyOptions = [
// { label: 'One Time', value: 'one_time' as ChoreFrequency },
// { label: 'Daily', value: 'daily' as ChoreFrequency },
// { label: 'Weekly', value: 'weekly' as ChoreFrequency },
// { label: 'Monthly', value: 'monthly' as ChoreFrequency },
// { label: 'Custom', value: 'custom' as ChoreFrequency }
// ]
// Methods
const loadAssignments = async () => {
@ -325,7 +328,7 @@ const loadAssignments = async () => {
} catch (error) {
console.error('Failed to load assignments:', error)
notificationStore.addNotification({
message: 'Failed to load assignments',
message: t('myChoresPage.notifications.loadFailed'),
type: 'error'
})
}
@ -338,7 +341,7 @@ const completeAssignment = async (assignment: ChoreAssignment) => {
try {
await choreService.completeAssignment(assignment.id)
notificationStore.addNotification({
message: `Marked "${assignment.chore?.name}" as complete!`,
message: t('myChoresPage.notifications.markedComplete', { choreName: assignment.chore?.name || '' }),
type: 'success'
})
// Reload assignments to show updated state
@ -346,7 +349,7 @@ const completeAssignment = async (assignment: ChoreAssignment) => {
} catch (error) {
console.error('Failed to complete assignment:', error)
notificationStore.addNotification({
message: 'Failed to mark assignment as complete',
message: t('myChoresPage.notifications.markCompleteFailed'),
type: 'error'
})
} finally {
@ -355,23 +358,34 @@ const completeAssignment = async (assignment: ChoreAssignment) => {
}
const formatDate = (date: string | undefined) => {
if (!date) return 'Unknown'
if (!date) return t('myChoresPage.dates.unknownDate');
if (date.includes('T')) {
return format(new Date(date), 'MMM d, yyyy')
} else {
const parts = date.split('-')
if (parts.length === 3) {
return format(new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])), 'MMM d, yyyy')
// Attempt to parse and format; date-fns handles various ISO and other formats.
try {
const parsedDate = new Date(date);
// Check if parsedDate is valid
if (isNaN(parsedDate.getTime())) {
// Handle cases like "YYYY-MM-DD" which might be parsed as UTC midnight
// and then potentially displayed incorrectly depending on timezone.
// If the input is just a date string without time, ensure it's treated as local.
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
const [year, month, day] = date.split('-').map(Number);
return format(new Date(year, month - 1, day), 'MMM d, yyyy');
}
return t('myChoresPage.dates.invalidDate');
}
return format(parsedDate, 'MMM d, yyyy');
} catch (e) {
// Catch any error during parsing (though Date constructor is quite forgiving)
return t('myChoresPage.dates.invalidDate');
}
return 'Invalid Date'
}
const formatFrequency = (frequency: ChoreFrequency | undefined) => {
if (!frequency) return 'Unknown'
const option = frequencyOptions.find(opt => opt.value === frequency)
return option ? option.label : frequency
if (!frequency) return t('myChoresPage.frequencies.unknown');
// Assuming keys like myChoresPage.frequencies.one_time, myChoresPage.frequencies.daily
// The ChoreFrequency enum values ('one_time', 'daily', etc.) match the last part of the key.
return t(`myChoresPage.frequencies.${frequency}`);
}
// Lifecycle

View File

@ -1,10 +1,10 @@
<template>
<main class="container page-padding">
<div class="row q-mb-md items-center justify-between">
<h1 class="mb-3">Personal Chores</h1>
<h1 class="mb-3">{{ $t('personalChoresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal">
<span class="material-icons">add</span>
New Chore
{{ $t('personalChoresPage.newChoreButton') }}
</button>
</div>
@ -22,7 +22,7 @@
<div class="neo-card-body">
<div class="neo-chore-info">
<div class="neo-chore-due">
Due: {{ formatDate(chore.next_due_date) }}
{{ $t('personalChoresPage.dates.duePrefix') }}: {{ formatDate(chore.next_due_date) }}
</div>
<div v-if="chore.description" class="neo-chore-description">
{{ chore.description }}
@ -31,11 +31,11 @@
<div class="neo-card-actions">
<button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)">
<span class="material-icons">edit</span>
Edit
{{ $t('personalChoresPage.editButton') }}
</button>
<button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)">
<span class="material-icons">delete</span>
Delete
{{ $t('personalChoresPage.deleteButton') }}
</button>
</div>
</div>
@ -46,7 +46,7 @@
<div v-if="showChoreModal" class="neo-modal">
<div class="neo-modal-content">
<div class="neo-modal-header">
<h3>{{ isEditing ? 'Edit Chore' : 'New Chore' }}</h3>
<h3>{{ isEditing ? $t('personalChoresPage.modals.editChoreTitle') : $t('personalChoresPage.modals.newChoreTitle') }}</h3>
<button class="btn btn-neutral btn-icon-only" @click="showChoreModal = false">
<span class="material-icons">close</span>
</button>
@ -54,7 +54,7 @@
<div class="neo-modal-body">
<form @submit.prevent="onSubmit" class="neo-form">
<div class="neo-form-group">
<label for="name">Name</label>
<label for="name">{{ $t('personalChoresPage.form.nameLabel') }}</label>
<input
id="name"
v-model="choreForm.name"
@ -65,7 +65,7 @@
</div>
<div class="neo-form-group">
<label for="description">Description</label>
<label for="description">{{ $t('personalChoresPage.form.descriptionLabel') }}</label>
<textarea
id="description"
v-model="choreForm.description"
@ -75,7 +75,7 @@
</div>
<div class="neo-form-group">
<label for="frequency">Frequency</label>
<label for="frequency">{{ $t('personalChoresPage.form.frequencyLabel') }}</label>
<select
id="frequency"
v-model="choreForm.frequency"
@ -89,7 +89,7 @@
</div>
<div v-if="choreForm.frequency === 'custom'" class="neo-form-group">
<label for="interval">Interval (days)</label>
<label for="interval">{{ $t('personalChoresPage.form.intervalLabel') }}</label>
<input
id="interval"
v-model.number="choreForm.custom_interval_days"
@ -101,7 +101,7 @@
</div>
<div class="neo-form-group">
<label for="dueDate">Next Due Date</label>
<label for="dueDate">{{ $t('personalChoresPage.form.dueDateLabel') }}</label>
<input
id="dueDate"
v-model="choreForm.next_due_date"
@ -113,8 +113,8 @@
</form>
</div>
<div class="neo-modal-footer">
<button class="btn btn-neutral" @click="showChoreModal = false">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Save</button>
<button class="btn btn-neutral" @click="showChoreModal = false">{{ $t('personalChoresPage.cancelButton') }}</button>
<button class="btn btn-primary" @click="onSubmit">{{ $t('personalChoresPage.saveButton') }}</button>
</div>
</div>
</div>
@ -123,17 +123,17 @@
<div v-if="showDeleteDialog" class="neo-modal">
<div class="neo-modal-content">
<div class="neo-modal-header">
<h3>Delete Chore</h3>
<h3>{{ $t('personalChoresPage.modals.deleteChoreTitle') }}</h3>
<button class="btn btn-neutral btn-icon-only" @click="showDeleteDialog = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="neo-modal-body">
<p>Are you sure you want to delete this chore?</p>
<p>{{ $t('personalChoresPage.deleteDialog.confirmationText') }}</p>
</div>
<div class="neo-modal-footer">
<button class="btn btn-neutral" @click="showDeleteDialog = false">Cancel</button>
<button class="btn btn-danger" @click="deleteChore">Delete</button>
<button class="btn btn-neutral" @click="showDeleteDialog = false">{{ $t('personalChoresPage.cancelButton') }}</button>
<button class="btn btn-danger" @click="deleteChore">{{ $t('personalChoresPage.deleteButton') }}</button>
</div>
</div>
</div>
@ -141,12 +141,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency } from '../types/chore'
const { t } = useI18n()
const notificationStore = useNotificationStore()
// State
@ -165,13 +167,13 @@ const choreForm = ref<ChoreCreate>({
type: 'personal'
})
const frequencyOptions = [
{ label: 'One Time', value: 'one_time' as ChoreFrequency },
{ label: 'Daily', value: 'daily' as ChoreFrequency },
{ label: 'Weekly', value: 'weekly' as ChoreFrequency },
{ label: 'Monthly', value: 'monthly' as ChoreFrequency },
{ label: 'Custom', value: 'custom' as ChoreFrequency }
]
const frequencyOptions = computed(() => [
{ label: t('personalChoresPage.frequencies.one_time'), value: 'one_time' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.daily'), value: 'daily' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.weekly'), value: 'weekly' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.monthly'), value: 'monthly' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.custom'), value: 'custom' as ChoreFrequency }
])
// Methods
const loadChores = async () => {
@ -180,7 +182,7 @@ const loadChores = async () => {
} catch (error) {
console.error('Failed to load personal chores:', error)
notificationStore.addNotification({
message: 'Failed to load personal chores',
message: t('personalChoresPage.notifications.loadFailed'),
type: 'error'
})
}
@ -216,13 +218,13 @@ const onSubmit = async () => {
if (isEditing.value && selectedChore.value) {
await choreService.updatePersonalChore(selectedChore.value.id, payload as ChoreUpdate)
notificationStore.addNotification({
message: 'Personal chore updated successfully',
message: t('personalChoresPage.notifications.updateSuccess'),
type: 'success'
})
} else {
await choreService.createPersonalChore(payload as ChoreCreate)
notificationStore.addNotification({
message: 'Personal chore created successfully',
message: t('personalChoresPage.notifications.createSuccess'),
type: 'success'
})
}
@ -231,7 +233,7 @@ const onSubmit = async () => {
} catch (error) {
console.error('Failed to save personal chore:', error)
notificationStore.addNotification({
message: `Failed to ${isEditing.value ? 'update' : 'create'} personal chore`,
message: t('personalChoresPage.notifications.saveFailed'), // Generic message
type: 'error'
})
}
@ -249,34 +251,43 @@ const deleteChore = async () => {
await choreService.deletePersonalChore(selectedChore.value.id)
showDeleteDialog.value = false
notificationStore.addNotification({
message: 'Personal chore deleted successfully',
message: t('personalChoresPage.notifications.deleteSuccess'),
type: 'success'
})
loadChores()
} catch (error) {
console.error('Failed to delete personal chore:', error)
notificationStore.addNotification({
message: 'Failed to delete personal chore',
message: t('personalChoresPage.notifications.deleteFailed'),
type: 'error'
})
}
}
const formatDate = (date: string) => {
if (date && date.includes('T')) {
return format(new Date(date), 'MMM d, yyyy');
} else if (date) {
const parts = date.split('-');
if (parts.length === 3) {
return format(new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])), 'MMM d, yyyy');
const formatDate = (date: string | undefined) => {
if (!date) return ''; // Or perhaps a specific 'Unknown Date' string if desired: t('personalChoresPage.dates.unknownDate')
try {
// Handles both 'YYYY-MM-DD' and full ISO with 'T'
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
// Explicitly handle 'YYYY-MM-DD' if new Date() struggles with it directly as local time
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
const [year, month, day] = date.split('-').map(Number);
return format(new Date(year, month - 1, day), 'MMM d, yyyy');
}
return t('personalChoresPage.dates.invalidDate');
}
return format(parsedDate, 'MMM d, yyyy');
} catch (e) {
return t('personalChoresPage.dates.invalidDate');
}
}
return 'Invalid Date';
}
const formatFrequency = (frequency: ChoreFrequency) => {
const option = frequencyOptions.find(opt => opt.value === frequency)
return option ? option.label : frequency
const formatFrequency = (frequency: ChoreFrequency | undefined) => {
if (!frequency) return t('personalChoresPage.frequencies.unknown');
// Use the value from frequencyOptions which is now translated
const option = frequencyOptions.value.find(opt => opt.value === frequency);
return option ? option.label : t(`personalChoresPage.frequencies.${frequency}`); // Fallback if somehow not in options
}
// Lifecycle

View File

@ -2,29 +2,29 @@
<main class="flex items-center justify-center page-container">
<div class="card signup-card">
<div class="card-header">
<h3>Sign Up</h3>
<h3>{{ $t('signupPage.header') }}</h3>
</div>
<div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2">
<label for="name" class="form-label">Full Name</label>
<label for="name" class="form-label">{{ $t('signupPage.fullNameLabel') }}</label>
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name" />
<p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
</div>
<div class="form-group mb-2">
<label for="email" class="form-label">Email</label>
<label for="email" class="form-label">{{ $t('signupPage.emailLabel') }}</label>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div>
<div class="form-group mb-2">
<label for="password" class="form-label">Password</label>
<label for="password" class="form-label">{{ $t('signupPage.passwordLabel') }}</label>
<div class="input-with-icon-append">
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
required autocomplete="new-password" />
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
aria-label="Toggle password visibility">
:aria-label="$t('signupPage.togglePasswordVisibility')">
<svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg> <!-- Placeholder for visibility icons -->
@ -34,7 +34,7 @@
</div>
<div class="form-group mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<label for="confirmPassword" class="form-label">{{ $t('signupPage.confirmPasswordLabel') }}</label>
<input :type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" v-model="confirmPassword"
class="form-input" required autocomplete="new-password" />
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
@ -44,11 +44,11 @@
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Sign Up
{{ $t('signupPage.submitButton') }}
</button>
<div class="text-center mt-2">
<router-link to="auth/login" class="link-styled">Already have an account? Login</router-link>
<router-link to="auth/login" class="link-styled">{{ $t('signupPage.loginLink') }}</router-link>
</div>
</form>
</div>
@ -59,9 +59,11 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/auth'; // Assuming path is correct
import { useNotificationStore } from '@/stores/notifications';
const { t } = useI18n();
const router = useRouter();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
@ -82,22 +84,22 @@ const isValidEmail = (val: string): boolean => {
const validateForm = (): boolean => {
formErrors.value = {};
if (!name.value.trim()) {
formErrors.value.name = 'Name is required';
formErrors.value.name = t('signupPage.validation.nameRequired');
}
if (!email.value.trim()) {
formErrors.value.email = 'Email is required';
formErrors.value.email = t('signupPage.validation.emailRequired');
} else if (!isValidEmail(email.value)) {
formErrors.value.email = 'Invalid email format';
formErrors.value.email = t('signupPage.validation.emailInvalid');
}
if (!password.value) {
formErrors.value.password = 'Password is required';
formErrors.value.password = t('signupPage.validation.passwordRequired');
} else if (password.value.length < 8) {
formErrors.value.password = 'Password must be at least 8 characters';
formErrors.value.password = t('signupPage.validation.passwordLength');
}
if (!confirmPassword.value) {
formErrors.value.confirmPassword = 'Please confirm your password';
formErrors.value.confirmPassword = t('signupPage.validation.confirmPasswordRequired');
} else if (password.value !== confirmPassword.value) {
formErrors.value.confirmPassword = 'Passwords do not match';
formErrors.value.confirmPassword = t('signupPage.validation.passwordsNoMatch');
}
return Object.keys(formErrors.value).length === 0;
};
@ -114,13 +116,17 @@ const onSubmit = async () => {
email: email.value,
password: password.value,
});
notificationStore.addNotification({ message: 'Account created successfully. Please login.', type: 'success' });
notificationStore.addNotification({ message: t('signupPage.notifications.signupSuccess'), type: 'success' });
router.push('auth/login');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Signup failed. Please try again.';
formErrors.value.general = message;
console.error(message, error);
notificationStore.addNotification({ message, type: 'error' });
// Prefer API error message if available, otherwise use generic translated message for the form
const errorMessageForForm = error instanceof Error ? error.message : t('signupPage.notifications.signupFailed');
formErrors.value.general = errorMessageForForm;
// For the notification pop-up, always use the generic translated message if API message is not specific enough or not an Error
const notificationMessage = error instanceof Error && error.message ? error.message : t('signupPage.notifications.signupFailed');
console.error("Signup error:", error); // Keep detailed log for developers
notificationStore.addNotification({ message: notificationMessage, type: 'error' });
} finally {
loading.value = false;
}