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" AUTH_HEADER_PREFIX: str = "Bearer"
# OAuth Settings # 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_ID: str = ""
GOOGLE_CLIENT_SECRET: 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_CLIENT_ID: str = ""
APPLE_TEAM_ID: str = "" APPLE_TEAM_ID: str = ""
APPLE_KEY_ID: str = "" APPLE_KEY_ID: str = ""
APPLE_PRIVATE_KEY: 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 Settings
SESSION_SECRET_KEY: str = "your-session-secret-key" # Change this in production 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_SENTRY_DSN=your_frontend_sentry_dsn_here
VITE_ROUTER_MODE=history VITE_ROUTER_MODE=history
# OAuth Configuration (if using) # Google OAuth Configuration - Replace with your actual credentials
GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_ID="YOUR_GOOGLE_CLIENT_ID_HERE"
GOOGLE_CLIENT_SECRET=your_google_client_secret GOOGLE_CLIENT_SECRET="YOUR_GOOGLE_CLIENT_SECRET_HERE"
GOOGLE_REDIRECT_URI=https://yourdomain.com/auth/google/callback GOOGLE_REDIRECT_URI=https://yourdomain.com/auth/google/callback
APPLE_CLIENT_ID=your_apple_client_id APPLE_CLIENT_ID=your_apple_client_id

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <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="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="mitlist pwa"> <meta name="description" content="mitlist pwa">
<meta name="format-detection" content="telephone=no"> <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" "workbox-background-sync": "^7.3.0"
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8", "@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@storybook/addon-docs": "^9.0.2", "@storybook/addon-docs": "^9.0.2",
"@storybook/addon-onboarding": "^9.0.2", "@storybook/addon-onboarding": "^9.0.2",
@ -2558,14 +2558,13 @@
} }
}, },
"node_modules/@intlify/bundle-utils": { "node_modules/@intlify/bundle-utils": {
"version": "10.0.1", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-10.0.1.tgz", "resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-8.0.0.tgz",
"integrity": "sha512-WkaXfSevtpgtUR4t8K2M6lbR7g03mtOxFeh+vXp5KExvPqS12ppaRj1QxzwRuRI5VUto54A22BjKoBMLyHILWQ==", "integrity": "sha512-1B++zykRnMwQ+20SpsZI1JCnV/YJt9Oq7AGlEurzkWJOFtFAVqaGc/oV36PBRYeiKnTbY9VYfjBimr2Vt42wLQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@intlify/message-compiler": "^11.1.2", "@intlify/message-compiler": "^9.4.0",
"@intlify/shared": "^11.1.2", "@intlify/shared": "^9.4.0",
"acorn": "^8.8.2", "acorn": "^8.8.2",
"escodegen": "^2.1.0", "escodegen": "^2.1.0",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
@ -2575,7 +2574,7 @@
"yaml-eslint-parser": "^1.2.2" "yaml-eslint-parser": "^1.2.2"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 14.16"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"petite-vue-i18n": { "petite-vue-i18n": {
@ -2587,13 +2586,12 @@
} }
}, },
"node_modules/@intlify/bundle-utils/node_modules/@intlify/message-compiler": { "node_modules/@intlify/bundle-utils/node_modules/@intlify/message-compiler": {
"version": "11.1.3", "version": "9.14.4",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.3.tgz", "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.4.tgz",
"integrity": "sha512-7rbqqpo2f5+tIcwZTAG/Ooy9C8NDVwfDkvSeDPWUPQW+Dyzfw2o9H103N5lKBxO7wxX9dgCDjQ8Umz73uYw3hw==", "integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@intlify/shared": "11.1.3", "@intlify/shared": "9.14.4",
"source-map-js": "^1.0.2" "source-map-js": "^1.0.2"
}, },
"engines": { "engines": {
@ -2604,11 +2602,10 @@
} }
}, },
"node_modules/@intlify/bundle-utils/node_modules/@intlify/shared": { "node_modules/@intlify/bundle-utils/node_modules/@intlify/shared": {
"version": "11.1.3", "version": "9.14.4",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.3.tgz", "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.4.tgz",
"integrity": "sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==", "integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
}, },
@ -2661,19 +2658,15 @@
} }
}, },
"node_modules/@intlify/unplugin-vue-i18n": { "node_modules/@intlify/unplugin-vue-i18n": {
"version": "6.0.8", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-6.0.8.tgz", "resolved": "https://registry.npmjs.org/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-4.0.0.tgz",
"integrity": "sha512-Vvm3KhjE6TIBVUQAk37rBiaYy2M5OcWH0ZcI1XKEsOTeN1o0bErk+zeuXmcrcMc/73YggfI8RoxOUz9EB/69JQ==", "integrity": "sha512-q2Mhqa/mLi0tulfLFO4fMXXvEbkSZpI5yGhNNsLTNJJ41icEGUuyDe+j5zRZIKSkOJRgX6YbCyibTDJdRsukmw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@intlify/bundle-utils": "^8.0.0",
"@intlify/bundle-utils": "^10.0.1", "@intlify/shared": "^9.4.0",
"@intlify/shared": "^11.1.2",
"@intlify/vue-i18n-extensions": "^8.0.0",
"@rollup/pluginutils": "^5.1.0", "@rollup/pluginutils": "^5.1.0",
"@typescript-eslint/scope-manager": "^8.13.0", "@vue/compiler-sfc": "^3.2.47",
"@typescript-eslint/typescript-estree": "^8.13.0",
"debug": "^4.3.3", "debug": "^4.3.3",
"fast-glob": "^3.2.12", "fast-glob": "^3.2.12",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@ -2681,16 +2674,15 @@
"pathe": "^1.0.0", "pathe": "^1.0.0",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
"source-map-js": "^1.0.2", "source-map-js": "^1.0.2",
"unplugin": "^1.1.0", "unplugin": "^1.1.0"
"vue": "^3.4"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 14.16"
}, },
"peerDependencies": { "peerDependencies": {
"petite-vue-i18n": "*", "petite-vue-i18n": "*",
"vue": "^3.2.25", "vue-i18n": "*",
"vue-i18n": "*" "vue-i18n-bridge": "*"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"petite-vue-i18n": { "petite-vue-i18n": {
@ -2698,15 +2690,17 @@
}, },
"vue-i18n": { "vue-i18n": {
"optional": true "optional": true
},
"vue-i18n-bridge": {
"optional": true
} }
} }
}, },
"node_modules/@intlify/unplugin-vue-i18n/node_modules/@intlify/shared": { "node_modules/@intlify/unplugin-vue-i18n/node_modules/@intlify/shared": {
"version": "11.1.3", "version": "9.14.4",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.3.tgz", "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.4.tgz",
"integrity": "sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==", "integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
}, },
@ -2744,117 +2738,6 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT" "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": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -6040,8 +5923,7 @@
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/config-chain": { "node_modules/config-chain": {
"version": "1.1.13", "version": "1.1.13",
@ -6848,7 +6730,6 @@
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"dev": true, "dev": true,
"license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"esprima": "^4.0.1", "esprima": "^4.0.1",
"estraverse": "^5.2.0", "estraverse": "^5.2.0",
@ -9092,7 +8973,6 @@
"resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.0.tgz", "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.0.tgz",
"integrity": "sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==", "integrity": "sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"acorn": "^8.5.0", "acorn": "^8.5.0",
"eslint-visitor-keys": "^3.0.0", "eslint-visitor-keys": "^3.0.0",
@ -9111,7 +8991,6 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"acorn": "^8.9.0", "acorn": "^8.9.0",
"acorn-jsx": "^5.3.2", "acorn-jsx": "^5.3.2",
@ -9444,7 +9323,6 @@
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"acorn": "^8.14.0", "acorn": "^8.14.0",
"pathe": "^2.0.1", "pathe": "^2.0.1",
@ -10118,7 +9996,6 @@
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"confbox": "^0.1.8", "confbox": "^0.1.8",
"mlly": "^1.7.4", "mlly": "^1.7.4",
@ -12273,8 +12150,7 @@
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
@ -13868,7 +13744,6 @@
"resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz", "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz",
"integrity": "sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA==", "integrity": "sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"eslint-visitor-keys": "^3.0.0", "eslint-visitor-keys": "^3.0.0",
"yaml": "^2.0.0" "yaml": "^2.0.0"

View File

@ -34,7 +34,7 @@
"workbox-background-sync": "^7.3.0" "workbox-background-sync": "^7.3.0"
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8", "@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@storybook/addon-docs": "^9.0.2", "@storybook/addon-docs": "^9.0.2",
"@storybook/addon-onboarding": "^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"> <script setup lang="ts">
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { API_BASE_URL } from '@/config/api-config';
const router = useRouter(); const router = useRouter();
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
window.location.href = '/auth/google/login'; window.location.href = `${API_BASE_URL}/auth/google/login`;
}; };
const handleAppleLogin = () => { 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 { 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 { BrowserTracing } from '@sentry/tracing';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
// import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
// import messages from '@/i18n'; // Import from absolute path import messages from '@/i18n';
// Global styles // Global styles
import './assets/main.scss'; import './assets/main.scss';
@ -15,21 +15,22 @@ import { api, globalAxios } from '@/services/api'; // Renamed from boot/axios to
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
// Vue I18n setup (from your i18n boot file) // Vue I18n setup (from your i18n boot file)
// export type MessageLanguages = keyof typeof messages; // // export type MessageLanguages = keyof typeof messages;
// export type MessageSchema = (typeof messages)['en-US']; // // export type MessageSchema = (typeof messages)['en-US'];
// declare module 'vue-i18n' { // // declare module 'vue-i18n' {
// export interface DefineLocaleMessage extends MessageSchema {} // // export interface DefineLocaleMessage extends MessageSchema {}
// // eslint-disable-next-line @typescript-eslint/no-empty-object-type // // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// export interface DefineDateTimeFormat {} // // export interface DefineDateTimeFormat {}
// // eslint-disable-next-line @typescript-eslint/no-empty-object-type // // // eslint-disable-next-line @typescript-eslint/no-empty-object-type
// export interface DefineNumberFormat {} // // export interface DefineNumberFormat {}
// } // // }
// const i18n = createI18n<{ message: MessageSchema }>({ const i18n = createI18n({
// locale: 'en-US', legacy: false, // Recommended for Vue 3
// fallbackLocale: 'en-US', locale: 'en', // Default locale
// messages, fallbackLocale: 'en', // Fallback locale
// }); messages,
});
const app = createApp(App); const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
@ -62,7 +63,7 @@ if (authStore.accessToken) {
} }
app.use(router); app.use(router);
// app.use(i18n); app.use(i18n);
// Make API instance globally available (optional, prefer provide/inject or store) // Make API instance globally available (optional, prefer provide/inject or store)
app.config.globalProperties.$api = api; app.config.globalProperties.$api = api;

View File

@ -1,32 +1,32 @@
<template> <template>
<main class="container page-padding"> <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"> <div v-if="loading" class="text-center">
<VSpinner label="Loading profile..." /> <VSpinner :label="$t('accountPage.loadingProfile')" />
</div> </div>
<VAlert v-else-if="error" type="error" :message="error" class="mb-3"> <VAlert v-else-if="error" type="error" :message="error" class="mb-3">
<template #actions> <template #actions>
<VButton variant="danger" size="sm" @click="fetchProfile">Retry</VButton> <VButton variant="danger" size="sm" @click="fetchProfile">{{ $t('accountPage.retryButton') }}</VButton>
</template> </template>
</VAlert> </VAlert>
<form v-else @submit.prevent="onSubmitProfile"> <form v-else @submit.prevent="onSubmitProfile">
<!-- Profile Section --> <!-- Profile Section -->
<VCard class="mb-3"> <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;"> <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 /> <VInput id="profileName" v-model="profile.name" required />
</VFormField> </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 /> <VInput type="email" id="profileEmail" v-model="profile.email" required readonly />
</VFormField> </VFormField>
</div> </div>
<template #footer> <template #footer>
<VButton type="submit" variant="primary" :disabled="saving"> <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> </VButton>
</template> </template>
</VCard> </VCard>
@ -35,18 +35,18 @@
<!-- Password Section --> <!-- Password Section -->
<form @submit.prevent="onChangePassword"> <form @submit.prevent="onChangePassword">
<VCard class="mb-3"> <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;"> <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 /> <VInput type="password" id="currentPassword" v-model="password.current" required />
</VFormField> </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 /> <VInput type="password" id="newPassword" v-model="password.newPassword" required />
</VFormField> </VFormField>
</div> </div>
<template #footer> <template #footer>
<VButton type="submit" variant="primary" :disabled="changingPassword"> <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> </VButton>
</template> </template>
</VCard> </VCard>
@ -54,28 +54,28 @@
<!-- Notifications Section --> <!-- Notifications Section -->
<VCard> <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"> <VList class="preference-list">
<VListItem class="preference-item"> <VListItem class="preference-item">
<div class="preference-label"> <div class="preference-label">
<span>Email Notifications</span> <span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
<small>Receive email notifications for important updates</small> <small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
</div> </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>
<VListItem class="preference-item"> <VListItem class="preference-item">
<div class="preference-label"> <div class="preference-label">
<span>List Updates</span> <span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
<small>Get notified when lists are updated</small> <small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
</div> </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>
<VListItem class="preference-item"> <VListItem class="preference-item">
<div class="preference-label"> <div class="preference-label">
<span>Group Activities</span> <span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
<small>Receive notifications for group activities</small> <small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
</div> </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> </VListItem>
</VList> </VList>
</VCard> </VCard>
@ -84,6 +84,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import VHeading from '@/components/valerie/VHeading.vue'; 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 VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue'; import VListItem from '@/components/valerie/VListItem.vue';
const { t } = useI18n();
interface Profile { interface Profile {
name: string; name: string;
email: string; email: string;
@ -136,10 +139,11 @@ const fetchProfile = async () => {
// Assume preferences are also fetched or part of profile // Assume preferences are also fetched or part of profile
// preferences.value = response.data.preferences || preferences.value; // preferences.value = response.data.preferences || preferences.value;
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load profile'; const apiMessage = err instanceof Error ? err.message : t('accountPage.notifications.profileLoadFailed');
error.value = message; error.value = apiMessage; // Show translated or API error message in the VAlert
console.error('Failed to fetch profile:', err); 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 { } finally {
loading.value = false; loading.value = false;
} }
@ -149,11 +153,11 @@ const onSubmitProfile = async () => {
saving.value = true; saving.value = true;
try { try {
await apiClient.put(API_ENDPOINTS.USERS.UPDATE_PROFILE, profile.value); 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) { } 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); console.error('Failed to update profile:', err);
notificationStore.addNotification({ message, type: 'error' }); notificationStore.addNotification({ message: t('accountPage.notifications.profileUpdateFailed'), type: 'error' });
} finally { } finally {
saving.value = false; saving.value = false;
} }
@ -161,11 +165,11 @@ const onSubmitProfile = async () => {
const onChangePassword = async () => { const onChangePassword = async () => {
if (!password.value.current || !password.value.newPassword) { 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; return;
} }
if (password.value.newPassword.length < 8) { 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; return;
} }
@ -177,11 +181,11 @@ const onChangePassword = async () => {
new: password.value.newPassword new: password.value.newPassword
}); });
password.value = { current: '', 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) { } 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); console.error('Failed to change password:', err);
notificationStore.addNotification({ message, type: 'error' }); notificationStore.addNotification({ message: t('accountPage.notifications.passwordChangeFailed'), type: 'error' });
} finally { } finally {
changingPassword.value = false; changingPassword.value = false;
} }
@ -192,11 +196,11 @@ const onPreferenceChange = async () => {
// Consider debouncing or providing a "Save Preferences" button if API calls are expensive. // Consider debouncing or providing a "Save Preferences" button if API calls are expensive.
try { try {
await apiClient.put(API_ENDPOINTS.USERS.PREFERENCES, preferences.value); 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) { } 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); 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 // Optionally revert the toggle if the API call fails
// await fetchProfile(); // Or manage state more granularly // await fetchProfile(); // Or manage state more granularly
} }

View File

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

View File

@ -2,54 +2,54 @@
<main class="neo-container page-padding"> <main class="neo-container page-padding">
<div class="neo-list-header"> <div class="neo-list-header">
<div class="header-left"> <div class="header-left">
<h1 class="neo-title">Chores</h1> <h1 class="neo-title">{{ t('choresPage.title') }}</h1>
<div class="view-tabs" role="tablist"> <div class="view-tabs" role="tablist">
<button class="neo-tab-btn" :class="{ active: activeView === 'overdue' }" @click="activeView = 'overdue'" <button class="neo-tab-btn" :class="{ active: activeView === 'overdue' }" @click="activeView = 'overdue'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'overdue'"> :disabled="isLoading" role="tab" :aria-selected="activeView === 'overdue'">
<span class="material-icons">warning</span> <span class="material-icons">warning</span>
Overdue {{ t('choresPage.tabs.overdue') }}
<span v-if="counts.overdue > 0" class="neo-tab-count">{{ counts.overdue }}</span> <span v-if="counts.overdue > 0" class="neo-tab-count">{{ counts.overdue }}</span>
</button> </button>
<button class="neo-tab-btn" :class="{ active: activeView === 'today' }" @click="activeView = 'today'" <button class="neo-tab-btn" :class="{ active: activeView === 'today' }" @click="activeView = 'today'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'today'"> :disabled="isLoading" role="tab" :aria-selected="activeView === 'today'">
<span class="material-icons">today</span> <span class="material-icons">today</span>
Today {{ t('choresPage.tabs.today') }}
<span v-if="counts.today > 0" class="neo-tab-count">{{ counts.today }}</span> <span v-if="counts.today > 0" class="neo-tab-count">{{ counts.today }}</span>
</button> </button>
<button class="neo-tab-btn" :class="{ active: activeView === 'upcoming' }" @click="activeView = 'upcoming'" <button class="neo-tab-btn" :class="{ active: activeView === 'upcoming' }" @click="activeView = 'upcoming'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'upcoming'"> :disabled="isLoading" role="tab" :aria-selected="activeView === 'upcoming'">
<span class="material-icons">upcoming</span> <span class="material-icons">upcoming</span>
Upcoming {{ t('choresPage.tabs.upcoming') }}
</button> </button>
<button class="neo-tab-btn" :class="{ active: activeView === 'all' }" @click="activeView = 'all'" <button class="neo-tab-btn" :class="{ active: activeView === 'all' }" @click="activeView = 'all'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'all'"> :disabled="isLoading" role="tab" :aria-selected="activeView === 'all'">
<span class="material-icons">list</span> <span class="material-icons">list</span>
All Pending {{ t('choresPage.tabs.allPending') }}
</button> </button>
<button class="neo-tab-btn" :class="{ active: activeView === 'completed' }" @click="activeView = 'completed'" <button class="neo-tab-btn" :class="{ active: activeView === 'completed' }" @click="activeView = 'completed'"
:disabled="isLoading" role="tab" :aria-selected="activeView === 'completed'"> :disabled="isLoading" role="tab" :aria-selected="activeView === 'completed'">
<span class="material-icons">check_circle</span> <span class="material-icons">check_circle</span>
Completed {{ t('choresPage.tabs.completed') }}
</button> </button>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="neo-view-toggle"> <div class="neo-view-toggle">
<button class="neo-toggle-btn" :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'" <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="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>
<button class="neo-toggle-btn" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'" <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="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> </button>
</div> </div>
<button class="neo-action-button" @click="openCreateChoreModal(null)" :disabled="isLoading" <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="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> </button>
</div> </div>
</div> </div>
@ -57,24 +57,24 @@
<!-- Loading State --> <!-- Loading State -->
<div v-if="isLoading" class="neo-loading-state"> <div v-if="isLoading" class="neo-loading-state">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<p>Loading chores...</p> <p>{{ t('choresPage.loadingState.loadingChores') }}</p>
</div> </div>
<!-- Calendar View --> <!-- Calendar View -->
<div v-else-if="viewMode === 'calendar'" class="calendar-view"> <div v-else-if="viewMode === 'calendar'" class="calendar-view">
<div class="calendar-header"> <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> <span class="material-icons">chevron_left</span>
</button> </button>
<h2>{{ currentMonthYear }}</h2> <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> <span class="material-icons">chevron_right</span>
</button> </button>
</div> </div>
<div v-if="calendarDays.length > 0"> <div v-if="calendarDays.length > 0">
<div class="calendar-grid"> <div class="calendar-grid">
<div class="calendar-weekdays"> <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>
<div class="calendar-days"> <div class="calendar-days">
<div v-for="(day, index) in calendarDays" :key="index" class="calendar-day" :class="{ <div v-for="(day, index) in calendarDays" :key="index" class="calendar-day" :class="{
@ -86,7 +86,7 @@
<div class="day-header"> <div class="day-header">
<span class="day-number">{{ day.date.getDate() }}</span> <span class="day-number">{{ day.date.getDate() }}</span>
<button v-if="!day.isOtherMonth" class="add-chore-indicator" <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> <span class="material-icons">add_circle_outline</span>
</button> </button>
</div> </div>
@ -107,7 +107,7 @@
</div> </div>
<div v-else class="empty-state"> <div v-else class="empty-state">
<span class="material-icons empty-icon">calendar_today</span> <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>
</div> </div>
@ -123,7 +123,7 @@
<h3>{{ chore.name }}</h3> <h3>{{ chore.name }}</h3>
<div class="chore-tags"> <div class="chore-tags">
<span class="chore-type-tag" :class="chore.type"> <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>
<span v-if="!chore.is_completed" class="chore-frequency-tag" :class="chore.frequency"> <span v-if="!chore.is_completed" class="chore-frequency-tag" :class="chore.frequency">
{{ formatFrequency(chore.frequency) }} {{ formatFrequency(chore.frequency) }}
@ -137,7 +137,7 @@
</div> </div>
<div v-else class="chore-completed-date"> <div v-else class="chore-completed-date">
<span class="material-icons">check_circle_outline</span> <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>
<div v-if="chore.description" class="chore-description"> <div v-if="chore.description" class="chore-description">
{{ chore.description }} {{ chore.description }}
@ -146,21 +146,21 @@
</div> </div>
<div class="chore-card-actions"> <div class="chore-card-actions">
<button v-if="!chore.is_completed" class="btn btn-success btn-sm btn-complete" <button v-if="!chore.is_completed" class="btn btn-success btn-sm btn-complete"
@click="toggleChoreCompletion(chore)" title="Mark as Done"> @click="toggleChoreCompletion(chore)" :title="t('choresPage.listView.actions.doneTitle')">
<span class="material-icons">check_circle</span> Done <span class="material-icons">check_circle</span> {{ t('choresPage.listView.actions.doneText') }}
</button> </button>
<button v-else class="btn btn-warning btn-sm btn-undo" @click="toggleChoreCompletion(chore)" <button v-else class="btn btn-warning btn-sm btn-undo" @click="toggleChoreCompletion(chore)"
title="Mark as Not Done"> :title="t('choresPage.listView.actions.undoTitle')">
<span class="material-icons">undo</span> Undo <span class="material-icons">undo</span> {{ t('choresPage.listView.actions.undoText') }}
</button> </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="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>
<button class="btn btn-icon btn-danger-icon" @click="confirmDeleteChore(chore)" title="Delete" <button class="btn btn-icon btn-danger-icon" @click="confirmDeleteChore(chore)" :title="t('choresPage.listView.actions.deleteTitle')"
aria-label="Delete chore"> :aria-label="t('choresPage.listView.actions.deleteLabel')">
<span class="material-icons">delete</span> <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> </button>
</div> </div>
</div> </div>
@ -168,9 +168,8 @@
</transition-group> </transition-group>
<div v-if="!isLoading && filteredChores.length === 0" class="empty-state"> <div v-if="!isLoading && filteredChores.length === 0" class="empty-state">
<span class="material-icons empty-icon"> Rtask_alt</span> <span class="material-icons empty-icon"> Rtask_alt</span>
<p>No chores in this view. Well done!</p> <p>{{ t('choresPage.listView.emptyState.message') }}</p>
<button v-if="activeView !== 'all'" class="btn btn-sm btn-outline" @click="activeView = 'all'">View All <button v-if="activeView !== 'all'" class="btn btn-sm btn-outline" @click="activeView = 'all'">{{ t('choresPage.listView.emptyState.viewAllButton') }}</button>
Pending</button>
</div> </div>
</div> </div>
@ -179,40 +178,40 @@
aria-modal="true" :aria-labelledby="isEditing ? 'editChoreModalTitle' : 'newChoreModalTitle'"> aria-modal="true" :aria-labelledby="isEditing ? 'editChoreModalTitle' : 'newChoreModalTitle'">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <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> }}</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> <span class="material-icons">close</span>
</button> </button>
</div> </div>
<form @submit.prevent="onSubmit" class="modal-form"> <form @submit.prevent="onSubmit" class="modal-form">
<div class="form-group"> <div class="form-group">
<label for="name">Name</label> <label for="name">{{ t('choresPage.choreModal.nameLabel') }}</label>
<input id="name" v-model="choreForm.name" type="text" class="form-input" placeholder="Enter chore name" <input id="name" v-model="choreForm.name" type="text" class="form-input" :placeholder="t('choresPage.choreModal.namePlaceholder')"
required /> required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Type</label> <label>{{ t('choresPage.choreModal.typeLabel') }}</label>
<div class="type-selector"> <div class="type-selector">
<button type="button" class="type-btn" :class="{ active: choreForm.type === 'personal' }" <button type="button" class="type-btn" :class="{ active: choreForm.type === 'personal' }"
@click="choreForm.type = 'personal'; choreForm.group_id = undefined" @click="choreForm.type = 'personal'; choreForm.group_id = undefined"
:aria-pressed="choreForm.type === 'personal' ? 'true' : 'false'"> :aria-pressed="choreForm.type === 'personal' ? 'true' : 'false'">
<span class="material-icons">person</span> <span class="material-icons">person</span>
Personal {{ t('choresPage.choreModal.typePersonal') }}
</button> </button>
<button type="button" class="type-btn" :class="{ active: choreForm.type === 'group' }" <button type="button" class="type-btn" :class="{ active: choreForm.type === 'group' }"
@click="choreForm.type = 'group'" :aria-pressed="choreForm.type === 'group' ? 'true' : 'false'"> @click="choreForm.type = 'group'" :aria-pressed="choreForm.type === 'group' ? 'true' : 'false'">
<span class="material-icons">group</span> <span class="material-icons">group</span>
Group {{ t('choresPage.choreModal.typeGroup') }}
</button> </button>
</div> </div>
</div> </div>
<div v-if="choreForm.type === 'group'" class="form-group"> <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> <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"> <option v-for="group in groups" :key="group.id" :value="group.id">
{{ group.name }} {{ group.name }}
</option> </option>
@ -220,14 +219,14 @@
</div> </div>
<div class="form-group"> <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" <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>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <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> <select id="frequency" v-model="choreForm.frequency" class="form-input" required>
<option v-for="option in frequencyOptions" :key="option.value" :value="option.value"> <option v-for="option in frequencyOptions" :key="option.value" :value="option.value">
{{ option.label }} {{ option.label }}
@ -236,27 +235,26 @@
</div> </div>
<div v-if="choreForm.frequency === 'custom'" class="form-group"> <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" <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> </div>
<div class="form-group"> <div class="form-group">
<label for="dueDate">Due Date</label> <label for="dueDate">{{ t('choresPage.choreModal.dueDateLabel') }}</label>
<div class="quick-due-dates"> <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" <button type="button" class="btn btn-sm btn-outline"
@click="setQuickDueDate('tomorrow')">Tomorrow</button> @click="setQuickDueDate('tomorrow')">{{ t('choresPage.choreModal.quickDueDateTomorrow') }}</button>
<button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('next_week')">Next <button type="button" class="btn btn-sm btn-outline" @click="setQuickDueDate('next_week')">{{ t('choresPage.choreModal.quickDueDateNextWeek') }}</button>
Week</button>
</div> </div>
<input id="dueDate" v-model="choreForm.next_due_date" type="date" class="form-input" required /> <input id="dueDate" v-model="choreForm.next_due_date" type="date" class="form-input" required />
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">Cancel</button> <button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{ t('choresPage.choreModal.cancelButton') }}</button>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">{{ t('choresPage.choreModal.saveButton') }}</button>
</div> </div>
</form> </form>
</div> </div>
@ -267,17 +265,17 @@
aria-modal="true" aria-labelledby="deleteDialogTitle"> aria-modal="true" aria-labelledby="deleteDialogTitle">
<div class="modal-container delete-confirm"> <div class="modal-container delete-confirm">
<div class="modal-header"> <div class="modal-header">
<h3 id="deleteDialogTitle">Delete Chore</h3> <h3 id="deleteDialogTitle">{{ t('choresPage.deleteDialog.title') }}</h3>
<button class="btn btn-icon" @click="showDeleteDialog = false" aria-label="Close modal"> <button class="btn btn-icon" @click="showDeleteDialog = false" :aria-label="t('choresPage.choreModal.closeButtonLabel')">
<span class="material-icons">close</span> <span class="material-icons">close</span>
</button> </button>
</div> </div>
<div class="modal-body"> <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>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-neutral" @click="showDeleteDialog = false">Cancel</button> <button class="btn btn-neutral" @click="showDeleteDialog = false">{{ t('choresPage.choreModal.cancelButton') }}</button>
<button class="btn btn-danger" @click="deleteChore">Delete</button> <button class="btn btn-danger" @click="deleteChore">{{ t('choresPage.deleteDialog.deleteButton') }}</button>
</div> </div>
</div> </div>
</div> </div>
@ -287,8 +285,8 @@
aria-modal="true" aria-labelledby="shortcutsModalTitle"> aria-modal="true" aria-labelledby="shortcutsModalTitle">
<div class="modal-container shortcuts-modal"> <div class="modal-container shortcuts-modal">
<div class="modal-header"> <div class="modal-header">
<h3 id="shortcutsModalTitle">Keyboard Shortcuts</h3> <h3 id="shortcutsModalTitle">{{ t('choresPage.shortcutsModal.title') }}</h3>
<button class="btn btn-icon" @click="showShortcutsModal = false" aria-label="Close modal"> <button class="btn btn-icon" @click="showShortcutsModal = false" :aria-label="t('choresPage.choreModal.closeButtonLabel')">
<span class="material-icons">close</span> <span class="material-icons">close</span>
</button> </button>
</div> </div>
@ -298,25 +296,25 @@
<div class="shortcut-keys"> <div class="shortcut-keys">
<kbd>Ctrl/Cmd</kbd> + <kbd>N</kbd> <kbd>Ctrl/Cmd</kbd> + <kbd>N</kbd>
</div> </div>
<div class="shortcut-description">New Chore</div> <div class="shortcut-description">{{ t('choresPage.shortcutsModal.descNewChore') }}</div>
</div> </div>
<div class="shortcut-item"> <div class="shortcut-item">
<div class="shortcut-keys"> <div class="shortcut-keys">
<kbd>Ctrl/Cmd</kbd> + <kbd>/</kbd> <kbd>Ctrl/Cmd</kbd> + <kbd>/</kbd>
</div> </div>
<div class="shortcut-description">Toggle View</div> <div class="shortcut-description">{{ t('choresPage.shortcutsModal.descToggleView') }}</div>
</div> </div>
<div class="shortcut-item"> <div class="shortcut-item">
<div class="shortcut-keys"> <div class="shortcut-keys">
<kbd>Ctrl/Cmd</kbd> + <kbd>?</kbd> <kbd>Ctrl/Cmd</kbd> + <kbd>?</kbd>
</div> </div>
<div class="shortcut-description">Show/Hide Shortcuts</div> <div class="shortcut-description">{{ t('choresPage.shortcutsModal.descToggleShortcuts') }}</div>
</div> </div>
<div class="shortcut-item"> <div class="shortcut-item">
<div class="shortcut-keys"> <div class="shortcut-keys">
<kbd>Esc</kbd> <kbd>Esc</kbd>
</div> </div>
<div class="shortcut-description">Close Modal</div> <div class="shortcut-description">{{ t('choresPage.shortcutsModal.descCloseModal') }}</div>
</div> </div>
</div> </div>
</div> </div>
@ -327,6 +325,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, watch, onBeforeUnmount } from 'vue' 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 { format, startOfDay, addDays, addWeeks, isBefore, isEqual, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday as isTodayDate } from 'date-fns'
import { choreService } from '../services/choreService' import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications' import { useNotificationStore } from '../stores/notifications'
@ -335,6 +334,8 @@ import { useRoute } from 'vue-router'
import { groupService } from '../services/groupService' import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const { t } = useI18n()
// Types // Types
interface ChoreWithCompletion extends Chore { interface ChoreWithCompletion extends Chore {
is_completed: boolean; is_completed: boolean;
@ -386,13 +387,13 @@ const getGroupName = (groupId?: number | null): string | undefined => {
return groups.value.find(g => g.id === groupId)?.name; return groups.value.find(g => g.id === groupId)?.name;
}; };
const frequencyOptions: { label: string; value: ChoreFrequency }[] = [ const frequencyOptions = computed(() => [
{ label: 'One Time', value: 'one_time' }, { label: t('choresPage.frequencyOptions.oneTime'), value: 'one_time' as ChoreFrequency },
{ label: 'Daily', value: 'daily' }, { label: t('choresPage.frequencyOptions.daily'), value: 'daily' as ChoreFrequency },
{ label: 'Weekly', value: 'weekly' }, { label: t('choresPage.frequencyOptions.weekly'), value: 'weekly' as ChoreFrequency },
{ label: 'Monthly', value: 'monthly' }, { label: t('choresPage.frequencyOptions.monthly'), value: 'monthly' as ChoreFrequency },
{ label: 'Custom', value: 'custom' } { label: t('choresPage.frequencyOptions.custom'), value: 'custom' as ChoreFrequency }
]; ]);
const isLoading = ref(true) const isLoading = ref(true)
@ -409,7 +410,7 @@ const loadChores = async () => {
cachedTimestamp.value = Date.now() cachedTimestamp.value = Date.now()
} catch (error) { } catch (error) {
console.error('Failed to load all chores:', 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 = [] if (!cachedChores.value || cachedChores.value.length === 0) chores.value = []
} finally { } finally {
isLoading.value = false isLoading.value = false
@ -419,8 +420,8 @@ const loadChores = async () => {
const viewMode = ref<'calendar' | 'list'>('calendar') const viewMode = ref<'calendar' | 'list'>('calendar')
const currentDate = ref(new Date()) const currentDate = ref(new Date())
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] 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 weekDays = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; // For smaller screens, you might use: const weekDayKeys = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const currentMonthYear = computed(() => { const currentMonthYear = computed(() => {
@ -523,10 +524,10 @@ const onSubmit = async () => {
if (isEditing.value && selectedChore.value) { if (isEditing.value && selectedChore.value) {
await choreService.updateChore(selectedChore.value.id, choreDataSubmit as ChoreUpdate) 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 { } else {
await choreService.createChore(choreDataSubmit as ChoreCreate) 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' }) notificationStore.addNotification({ message: notificationMessage, type: 'success' })
@ -535,7 +536,7 @@ const onSubmit = async () => {
await loadChores() await loadChores()
} catch (error) { } catch (error) {
console.error('Failed to save chore:', 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 { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -556,11 +557,11 @@ const deleteChore = async () => {
selectedChore.value.group_id ?? undefined selectedChore.value.group_id ?? undefined
); );
showDeleteDialog.value = false; 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() await loadChores()
} catch (error) { } catch (error) {
console.error('Failed to delete chore:', 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 { } finally {
isLoading.value = false; isLoading.value = false;
selectedChore.value = null; selectedChore.value = null;
@ -686,19 +687,19 @@ const getDueDateClass = (chore: ChoreWithCompletion) => {
} }
const formatDueDate = (dateString: string | null) => { const formatDueDate = (dateString: string | null) => {
if (!dateString) return 'No due date'; if (!dateString) return t('choresPage.formatters.noDueDate');
try { try {
const dueDate = startOfDay(new Date(dateString.replace(/-/g, '/'))); 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); 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 { } catch {
return 'Invalid date' return t('choresPage.formatters.invalidDate')
} }
} }
@ -723,7 +724,7 @@ const toggleChoreCompletion = async (choreToToggle: ChoreWithCompletion) => {
} as ChoreUpdate); } as ChoreUpdate);
notificationStore.addNotification({ 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' type: choreToToggle.is_completed ? 'success' : 'info'
}); });
} catch (error) { } catch (error) {
@ -734,7 +735,7 @@ const toggleChoreCompletion = async (choreToToggle: ChoreWithCompletion) => {
if (index !== -1) chores.value.splice(index, 1, { ...choreToToggle }); if (index !== -1) chores.value.splice(index, 1, { ...choreToToggle });
cachedChores.value = [...chores.value]; 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 = () => { const validateForm = () => {
if (!choreForm.value.name.trim()) { 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) { 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)) { 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) { 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 { try {
new Date(choreForm.value.next_due_date.replace(/-/g, '/')); // check if valid date new Date(choreForm.value.next_due_date.replace(/-/g, '/')); // check if valid date
} catch { } 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; return true;
} }
@ -851,7 +852,7 @@ watch(showChoreModal, (isOpen) => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (hasUnsavedChanges.value && showChoreModal.value) { // Only prompt if modal is open with changes 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) { if (!confirmLeave) {
return false // This typically doesn't prevent component unmount in Vue 3 composition API directly 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. // but is a common pattern. For SPA, router guards are better.

View File

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

View File

@ -1,11 +1,11 @@
<template> <template>
<main class="container page-padding"> <main class="container page-padding">
<div v-if="loading" class="text-center"> <div v-if="loading" class="text-center">
<VSpinner label="Loading group details..." /> <VSpinner :label="t('groupDetailPage.loadingLabel')" />
</div> </div>
<VAlert v-else-if="error" type="error" :message="error" class="mb-3"> <VAlert v-else-if="error" type="error" :message="error" class="mb-3">
<template #actions> <template #actions>
<VButton variant="danger" size="sm" @click="fetchGroupDetails">Retry</VButton> <VButton variant="danger" size="sm" @click="fetchGroupDetails">{{ t('groupDetailPage.retryButton') }}</VButton>
</template> </template>
</VAlert> </VAlert>
<div v-else-if="group"> <div v-else-if="group">
@ -14,42 +14,42 @@
<div class="neo-grid"> <div class="neo-grid">
<!-- Group Members Section --> <!-- Group Members Section -->
<VCard> <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"> <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"> <VListItem v-for="member in group.members" :key="member.id" class="flex justify-between items-center">
<div class="neo-member-info"> <div class="neo-member-info">
<span class="neo-member-name">{{ member.email }}</span> <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> </div>
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" @click="removeMember(member.id)" :disabled="removingMember === member.id"> <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> </VButton>
</VListItem> </VListItem>
</VList> </VList>
<div v-else class="text-center py-4"> <div v-else class="text-center py-4">
<VIcon name="users" size="lg" class="opacity-50 mb-2" /> <VIcon name="users" size="lg" class="opacity-50 mb-2" />
<p>No members found.</p> <p>{{ t('groupDetailPage.members.emptyState') }}</p>
</div> </div>
</VCard> </VCard>
<!-- Invite Members Section --> <!-- Invite Members Section -->
<VCard> <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"> <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> </VButton>
<div v-if="inviteCode" class="neo-invite-code mt-3"> <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"> <div class="flex items-center gap-2">
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" /> <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> </div>
</VFormField> </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>
<div v-else class="text-center py-4 mt-3"> <div v-else class="text-center py-4 mt-3">
<VIcon name="link" size="lg" class="opacity-50 mb-2" /> <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> </div>
</VCard> </VCard>
</div> </div>
@ -63,9 +63,9 @@
<VCard class="mt-4"> <VCard class="mt-4">
<template #header> <template #header>
<div class="flex justify-between items-center w-full"> <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"> <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> </VButton>
</div> </div>
</template> </template>
@ -73,14 +73,14 @@
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center"> <VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
<div class="neo-chore-info"> <div class="neo-chore-info">
<span class="neo-chore-name">{{ chore.name }}</span> <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> </div>
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" /> <VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
</VListItem> </VListItem>
</VList> </VList>
<div v-else class="text-center py-4"> <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 */} <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> </div>
</VCard> </VCard>
@ -88,9 +88,9 @@
<VCard class="mt-4"> <VCard class="mt-4">
<template #header> <template #header>
<div class="flex justify-between items-center w-full"> <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"> <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> </VButton>
</div> </div>
</template> </template>
@ -108,18 +108,19 @@
</VList> </VList>
<div v-else class="text-center py-4"> <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 */} <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> </div>
</VCard> </VCard>
</div> </div>
<VAlert v-else type="info" message="Group not found or an error occurred." /> <VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
// import { useRoute } from 'vue-router'; // import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useClipboard } from '@vueuse/core'; import { useClipboard } from '@vueuse/core';
@ -141,6 +142,8 @@ import VInput from '@/components/valerie/VInput.vue';
import VFormField from '@/components/valerie/VFormField.vue'; import VFormField from '@/components/valerie/VFormField.vue';
import VIcon from '@/components/valerie/VIcon.vue'; import VIcon from '@/components/valerie/VIcon.vue';
const { t } = useI18n();
interface Group { interface Group {
id: string | number; id: string | number;
name: string; name: string;
@ -238,13 +241,13 @@ const generateInviteCode = async () => {
if (response.data && response.data.code) { if (response.data && response.data.code) {
inviteCode.value = response.data.code; inviteCode.value = response.data.code;
inviteExpiresAt.value = response.data.expires_at; // Update with new expiry 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 { } else {
// Should not happen if POST is successful and returns the code // 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) { } 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); console.error('Error generating invite code:', err);
notificationStore.addNotification({ message, type: 'error' }); notificationStore.addNotification({ message, type: 'error' });
} finally { } finally {
@ -254,7 +257,7 @@ const generateInviteCode = async () => {
const copyInviteCodeHandler = async () => { const copyInviteCodeHandler = async () => {
if (!clipboardIsSupported.value || !inviteCode.value) { 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; return;
} }
await copy(inviteCode.value); await copy(inviteCode.value);
@ -264,7 +267,7 @@ const copyInviteCodeHandler = async () => {
// Optionally, notify success via store if preferred over inline message // Optionally, notify success via store if preferred over inline message
// notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' }); // notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' });
} else { } 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 // Refresh group details to update the members list
await fetchGroupDetails(); await fetchGroupDetails();
notificationStore.addNotification({ notificationStore.addNotification({
message: 'Member removed successfully', message: t('groupDetailPage.notifications.removeMemberSuccess'),
type: 'success' type: 'success'
}); });
} catch (err: unknown) { } 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); console.error('Error removing member:', err);
notificationStore.addNotification({ message, type: 'error' }); notificationStore.addNotification({ message, type: 'error' });
} finally { } finally {
@ -314,15 +317,15 @@ const formatDate = (date: string) => {
} }
const formatFrequency = (frequency: ChoreFrequency) => { const formatFrequency = (frequency: ChoreFrequency) => {
const options = { const options: Record<ChoreFrequency, string> = {
one_time: 'One Time', one_time: t('choresPage.frequencyOptions.oneTime'), // Reusing existing keys
daily: 'Daily', daily: t('choresPage.frequencyOptions.daily'),
weekly: 'Weekly', weekly: t('choresPage.frequencyOptions.weekly'),
monthly: 'Monthly', monthly: t('choresPage.frequencyOptions.monthly'),
custom: 'Custom' custom: t('choresPage.frequencyOptions.custom')
} };
return options[frequency] || frequency return options[frequency] || frequency;
} };
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): string => { const getFrequencyBadgeVariant = (frequency: ChoreFrequency): string => {
const colorMap: Record<ChoreFrequency, string> = { const colorMap: Record<ChoreFrequency, string> = {
@ -351,10 +354,14 @@ const formatAmount = (amount: string) => {
} }
const formatSplitType = (type: string) => { const formatSplitType = (type: string) => {
return type.split('_').map(word => // Assuming 'type' is like 'exact_amounts' or 'item_based'
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() const key = `groupDetailPage.expenses.splitTypes.${type.toLowerCase().replace(/_([a-z])/g, g => g[1].toUpperCase())}`;
).join(' ') // 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 getSplitTypeBadgeVariant = (type: string): string => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {

View File

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

View File

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

View File

@ -1,12 +1,12 @@
<template> <template>
<main class="neo-container page-padding"> <main class="neo-container page-padding">
<div v-if="pageInitialLoad && !list && !error" class="text-center py-10"> <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> </div>
<VAlert v-else-if="error && !list" type="error" :message="error" class="mb-4"> <VAlert v-else-if="error && !list" type="error" :message="error" class="mb-4">
<template #actions> <template #actions>
<VButton @click="fetchListDetails">Retry</VButton> <VButton @click="fetchListDetails">{{ $t('listDetailPage.retryButton') }}</VButton>
</template> </template>
</VAlert> </VAlert>
@ -15,10 +15,10 @@
<div class="neo-list-header"> <div class="neo-list-header">
<VHeading :level="1" :text="list.name" class="mb-3 neo-title" /> <VHeading :level="1" :text="list.name" class="mb-3 neo-title" />
<div class="neo-header-actions"> <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>
<VButton @click="openOcrDialog" :disabled="!isOnline" icon-left="plus">Add via OCR</VButton> <VButton @click="openOcrDialog" :disabled="!isOnline" icon-left="plus">{{ $t('listDetailPage.buttons.addViaOcr') }}</VButton>
<VBadge :text="list.group_id ? 'Group List' : 'Personal List'" :variant="list.group_id ? 'accent' : 'settled'" <VBadge :text="list.group_id ? $t('listDetailPage.badges.groupList') : $t('listDetailPage.badges.personalList')" :variant="list.group_id ? 'accent' : 'settled'"
class="neo-status" /> class="neo-status" />
</div> </div>
</div> </div>
@ -26,10 +26,10 @@
<!-- Items List Section --> <!-- Items List Section -->
<VCard v-if="itemsAreLoading" class="py-10 text-center mt-4"> <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>
<VCard v-else-if="!itemsAreLoading && list.items.length === 0" variant="empty-state" empty-icon="clipboard" <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"> <div v-else class="neo-item-list-container mt-4">
<ul class="neo-item-list"> <ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id" class="neo-list-item" <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> <span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
</label> </label>
<div class="neo-item-actions"> <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" /> <VIcon name="edit" />
</button> </button>
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)" <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" /> <VIcon name="trash" />
</button> </button>
</div> </div>
</div> </div>
<div v-if="item.is_complete" class="neo-price-input"> <div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="item.priceInput = $event" <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()" /> @keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div> </div>
</li> </li>
@ -63,16 +63,16 @@
<!-- Add New Item Form --> <!-- 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"> <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" /> <VIcon name="plus-circle" class="text-gray-400 shrink-0" />
<VFormField class="flex-grow" label="New item name" :label-sr-only="true"> <VFormField class="flex-grow" :label="$t('listDetailPage.items.addItemForm.itemNameSrLabel')" :label-sr-only="true">
<VInput v-model="newItem.name" placeholder="Add a new item" required ref="itemNameInputRef" /> <VInput v-model="newItem.name" :placeholder="$t('listDetailPage.items.addItemForm.placeholder')" required ref="itemNameInputRef" />
</VFormField> </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" <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> </VFormField>
<VButton type="submit" :disabled="addingItem" class="shrink-0"> <VButton type="submit" :disabled="addingItem" class="shrink-0">
<VSpinner v-if="addingItem" size="sm" /> <VSpinner v-if="addingItem" size="sm" />
<span v-else>Add</span> <span v-else>{{ $t('listDetailPage.buttons.addItem') }}</span>
</VButton> </VButton>
</form> </form>
</template> </template>
@ -80,24 +80,24 @@
<!-- Expenses Section (Original Content - Part 3 will refactor this) --> <!-- Expenses Section (Original Content - Part 3 will refactor this) -->
<section v-if="list && !itemsAreLoading" class="neo-expenses-section"> <section v-if="list && !itemsAreLoading" class="neo-expenses-section">
<div class="neo-expenses-header"> <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"> <button class="neo-action-button" @click="showCreateExpenseForm = true">
<svg class="icon"> <svg class="icon">
<use xlink:href="#icon-plus" /> <use xlink:href="#icon-plus" />
</svg> </svg>
Add Expense {{ $t('listDetailPage.expensesSection.addExpenseButton') }}
</button> </button>
</div> </div>
<div v-if="listDetailStore.isLoading && expenses.length === 0" class="neo-loading-state"> <div v-if="listDetailStore.isLoading && expenses.length === 0" class="neo-loading-state">
<div class="spinner-dots" role="status"><span /><span /><span /></div> <div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading expenses...</p> <p>{{ $t('listDetailPage.expensesSection.loading') }}</p>
</div> </div>
<div v-else-if="listDetailStore.error" class="neo-error-state"> <div v-else-if="listDetailStore.error" class="neo-error-state">
<p>{{ listDetailStore.error }}</p> <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))">Retry</button> <button class="neo-button" @click="listDetailStore.fetchListWithExpenses(String(list?.id))">{{ $t('listDetailPage.expensesSection.retryButton') }}</button>
</div> </div>
<div v-else-if="!expenses || expenses.length === 0" class="neo-empty-state"> <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>
<div v-else> <div v-else>
<div v-for="expense in expenses" :key="expense.id" class="neo-expense-card"> <div v-for="expense in expenses" :key="expense.id" class="neo-expense-card">
@ -108,34 +108,34 @@
</span> </span>
</div> </div>
<div class="neo-expense-details"> <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> ${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>
<div class="neo-splits-list"> <div class="neo-splits-list">
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item"> <div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
<div class="neo-split-details"> <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) }} formatCurrency(split.owed_amount) }}
<span class="neo-expense-status" :class="getStatusClass(split.status)"> <span class="neo-expense-status" :class="getStatusClass(split.status)">
{{ getSplitStatusText(split.status) }} {{ getSplitStatusText(split.status) }}
</span> </span>
</div> </div>
<div class="neo-split-details"> <div class="neo-split-details">
Paid: {{ getPaidAmountForSplitDisplay(split) }} {{ $t('listDetailPage.expensesSection.paidAmount') }} {{ getPaidAmountForSplitDisplay(split) }}
<span v-if="split.paid_at"> on {{ new Date(split.paid_at).toLocaleDateString() }}</span> <span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(split.paid_at).toLocaleDateString() }}</span>
</div> </div>
<button v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID" <button v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
class="neo-button neo-button-primary" @click="openSettleShareModal(expense, split)" class="neo-button neo-button-primary" @click="openSettleShareModal(expense, split)"
:disabled="isSettlementLoading"> :disabled="isSettlementLoading">
Settle My Share {{ $t('listDetailPage.expensesSection.settleShareButton') }}
</button> </button>
<ul v-if="split.settlement_activities && split.settlement_activities.length > 0" <ul v-if="split.settlement_activities && split.settlement_activities.length > 0"
class="neo-settlement-activities"> class="neo-settlement-activities">
<li v-for="activity in split.settlement_activities" :key="activity.id"> <li v-for="activity in split.settlement_activities" :key="activity.id">
Activity: {{ formatCurrency(activity.amount_paid) }} by {{ activity.payer?.name || `User {{ $t('listDetailPage.expensesSection.activityLabel') }} {{ formatCurrency(activity.amount_paid) }} {{ $t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User
${activity.paid_by_user_id}` }} on {{ new Date(activity.paid_at).toLocaleDateString() }} ${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(activity.paid_at).toLocaleDateString() }}
</li> </li>
</ul> </ul>
</div> </div>
@ -149,10 +149,10 @@
@close="showCreateExpenseForm = false" @created="handleExpenseCreated" /> @close="showCreateExpenseForm = false" @created="handleExpenseCreated" />
<!-- OCR Dialog --> <!-- 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> <template #default>
<div v-if="ocrLoading" class="text-center"> <div v-if="ocrLoading" class="text-center">
<VSpinner label="Processing image..." /> <VSpinner :label="$t('listDetailPage.loading.ocrProcessing')" />
</div> </div>
<VList v-else-if="ocrItems.length > 0"> <VList v-else-if="ocrItems.length > 0">
<VListItem v-for="(ocrItem, index) in ocrItems" :key="index"> <VListItem v-for="(ocrItem, index) in ocrItems" :key="index">
@ -163,22 +163,22 @@
</div> </div>
</VListItem> </VListItem>
</VList> </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" <VInput type="file" id="ocrFile" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"
:model-value="''" /> :model-value="''" />
</VFormField> </VFormField>
</template> </template>
<template #footer> <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" <VButton v-if="ocrItems.length > 0" type="button" variant="primary" @click="addOcrItems"
:disabled="addingOcrItems"> :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> </VButton>
</template> </template>
</VModal> </VModal>
<!-- Confirmation Dialog --> <!-- 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"> size="sm">
<template #default> <template #default>
<div class="text-center"> <div class="text-center">
@ -187,34 +187,34 @@
</div> </div>
</template> </template>
<template #footer> <template #footer>
<VButton variant="neutral" @click="cancelConfirmation">Cancel</VButton> <VButton variant="neutral" @click="cancelConfirmation">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton variant="primary" @click="handleConfirmedAction">Confirm</VButton> <VButton variant="primary" @click="handleConfirmedAction">{{ $t('listDetailPage.buttons.confirm') }}</VButton>
</template> </template>
</VModal> </VModal>
<!-- Cost Summary Dialog --> <!-- 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"> size="lg">
<template #default> <template #default>
<div v-if="costSummaryLoading" class="text-center"> <div v-if="costSummaryLoading" class="text-center">
<VSpinner label="Loading summary..." /> <VSpinner :label="$t('listDetailPage.loading.costSummary')" />
</div> </div>
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" /> <VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
<div v-else-if="listCostSummary"> <div v-else-if="listCostSummary">
<div class="mb-3 cost-overview"> <div class="mb-3 cost-overview">
<p><strong>Total List Cost:</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p> <p><strong>{{ $t('listDetailPage.costSummaryModal.totalCostLabel') }}</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>Equal Share Per User:</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p> <p><strong>{{ $t('listDetailPage.costSummaryModal.equalShareLabel') }}</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>Participating Users:</strong> {{ listCostSummary.num_participating_users }}</p> <p><strong>{{ $t('listDetailPage.costSummaryModal.participantsLabel') }}</strong> {{ listCostSummary.num_participating_users }}</p>
</div> </div>
<h4>User Balances</h4> <h4>{{ $t('listDetailPage.costSummaryModal.userBalancesHeader') }}</h4>
<div class="table-container mt-2"> <div class="table-container mt-2">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>User</th> <th>{{ $t('listDetailPage.costSummaryModal.tableHeaders.user') }}</th>
<th class="text-right">Items Added Value</th> <th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.itemsAddedValue') }}</th>
<th class="text-right">Amount Due</th> <th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.amountDue') }}</th>
<th class="text-right">Balance</th> <th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.balance') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -231,60 +231,60 @@
</table> </table>
</div> </div>
</div> </div>
<p v-else>No cost summary available.</p> <p v-else>{{ $t('listDetailPage.costSummaryModal.emptyState') }}</p>
</template> </template>
<template #footer> <template #footer>
<VButton variant="primary" @click="showCostSummaryDialog = false">Close</VButton> <VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}</VButton>
</template> </template>
</VModal> </VModal>
<!-- Settle Share Modal --> <!-- 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"> size="md">
<template #default> <template #default>
<div v-if="isSettlementLoading" class="text-center"> <div v-if="isSettlementLoading" class="text-center">
<VSpinner label="Processing settlement..." /> <VSpinner :label="$t('listDetailPage.loading.settlement')" />
</div> </div>
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" /> <VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
<div v-else> <div v-else>
<p>Settle amount for {{ selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email || <p>{{ $t('listDetailPage.settleShareModal.settleAmountFor', { userName: selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}` }) }}</p>
`User ID: ${selectedSplitForSettlement?.user_id}` }}:</p> <VFormField :label="$t('listDetailPage.settleShareModal.amountLabel')" :error-message="settleAmountError || undefined">
<VFormField label="Amount" :error-message="settleAmountError || undefined">
<VInput type="number" v-model="settleAmount" id="settleAmount" required /> <VInput type="number" v-model="settleAmount" id="settleAmount" required />
</VFormField> </VFormField>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">Cancel</VButton> <VButton variant="neutral" @click="closeSettleShareModal">{{ $t('listDetailPage.settleShareModal.cancelButton') }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">Confirm</VButton> <VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton') }}</VButton>
</template> </template>
</VModal> </VModal>
<!-- Edit Item Dialog --> <!-- 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> <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 /> <VInput type="text" id="editItemName" v-model="editingItem.name" required />
</VFormField> </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 || ''" <VInput type="number" id="editItemQuantity" :model-value="editingItem.quantity || ''"
@update:modelValue="editingItem.quantity = $event" min="1" /> @update:modelValue="editingItem.quantity = $event" min="1" />
</VFormField> </VFormField>
</template> </template>
<template #footer> <template #footer>
<VButton variant="neutral" @click="closeEditDialog">Cancel</VButton> <VButton variant="neutral" @click="closeEditDialog">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton variant="primary" @click="handleConfirmEdit" :disabled="!editingItem?.name.trim()">Save Changes <VButton variant="primary" @click="handleConfirmEdit" :disabled="!editingItem?.name.trim()">{{ $t('listDetailPage.buttons.saveChanges') }}
</VButton> </VButton>
</template> </template>
</VModal> </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> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'; import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Keep for item management import { apiClient, API_ENDPOINTS } from '@/config/api'; // Keep for item management
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core'; // onClickOutside removed import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core'; // onClickOutside removed
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
@ -313,6 +313,7 @@ import VListItem from '@/components/valerie/VListItem.vue';
import VCheckbox from '@/components/valerie/VCheckbox.vue'; import VCheckbox from '@/components/valerie/VCheckbox.vue';
// VTextarea and VSelect are not used in this part of the refactor for ListDetailPage // 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 // UI-specific properties that we add to items
interface ItemWithUI extends Item { interface ItemWithUI extends Item {
@ -433,31 +434,23 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
}; };
const fetchListDetails = async () => { 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) { if (pageInitialLoad.value) {
pageInitialLoad.value = false; pageInitialLoad.value = false;
} }
itemsAreLoading.value = true; itemsAreLoading.value = true;
// Check for pre-fetched full data first
const routeId = String(route.params.id); const routeId = String(route.params.id);
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`); const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
try { try {
let response; let response;
if (cachedFullData) { if (cachedFullData) {
// Use cached data
response = { data: JSON.parse(cachedFullData) }; response = { data: JSON.parse(cachedFullData) };
// Clear the cache after using it
sessionStorage.removeItem(`listDetailFull_${routeId}`); sessionStorage.removeItem(`listDetailFull_${routeId}`);
} else { } else {
// Fetch fresh data
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId)); response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
} }
const rawList = response.data as ListWithExpenses; const rawList = response.data as ListWithExpenses;
// Map API response to local List type
const localList: List = { const localList: List = {
id: rawList.id, id: rawList.id,
name: rawList.name, name: rawList.name,
@ -477,17 +470,15 @@ const fetchListDetails = async () => {
await fetchListCostSummary(); await fetchListCostSummary();
} }
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = (err instanceof Error ? err.message : String(err)) || 'Failed to load list details.'; const apiErrorMessage = err instanceof Error ? err.message : String(err);
if (!list.value) { // If there was no shell AND this fetch failed const fallbackErrorMessage = t('listDetailPage.errors.fetchFailed');
error.value = errorMessage; // This error is for the whole page if (!list.value) {
error.value = apiErrorMessage || fallbackErrorMessage;
} else { } else {
// We have a shell, but items failed to load. notificationStore.addNotification({ message: t('listDetailPage.errors.fetchItemsFailed', { errorMessage: apiErrorMessage }), type: 'error' });
// 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' });
} }
} finally { } finally {
itemsAreLoading.value = false; 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) { if (!list.value && !error.value) {
pageInitialLoad.value = false; pageInitialLoad.value = false;
} }
@ -532,7 +523,7 @@ const isItemPendingSync = (item: Item) => {
const onAddItem = async () => { const onAddItem = async () => {
if (!list.value || !newItem.value.name.trim()) { 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) { if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus(); (itemNameInputRef.value.$el as HTMLElement).focus();
} }
@ -541,7 +532,7 @@ const onAddItem = async () => {
addingItem.value = true; addingItem.value = true;
if (!isOnline.value) { if (!isOnline.value) {
const offlinePayload: any = { const offlinePayload: any = { // Define explicit type later if needed
name: newItem.value.name name: newItem.value.name
}; };
if (typeof newItem.value.quantity !== 'undefined') { if (typeof newItem.value.quantity !== 'undefined') {
@ -555,12 +546,12 @@ const onAddItem = async () => {
} }
}); });
const optimisticItem: ItemWithUI = { const optimisticItem: ItemWithUI = {
id: Date.now(), id: Date.now(), // Temporary ID for offline
name: newItem.value.name, name: newItem.value.name,
quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null), quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null),
is_complete: false, is_complete: false,
price: null, price: null,
version: 1, version: 1, // Assuming initial version
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
list_id: list.value.id, list_id: list.value.id,
@ -575,6 +566,7 @@ const onAddItem = async () => {
(itemNameInputRef.value.$el as HTMLElement).focus(); (itemNameInputRef.value.$el as HTMLElement).focus();
} }
addingItem.value = false; addingItem.value = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' }); // Optimistic UI
return; return;
} }
@ -592,8 +584,9 @@ const onAddItem = async () => {
if (itemNameInputRef.value?.$el) { if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus(); (itemNameInputRef.value.$el as HTMLElement).focus();
} }
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' });
} catch (err) { } 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 { } finally {
addingItem.value = false; addingItem.value = false;
} }
@ -618,6 +611,7 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
} }
}); });
item.updating = false; item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
return; return;
} }
@ -627,9 +621,10 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
{ completed: newCompleteStatus, version: item.version } { completed: newCompleteStatus, version: item.version }
); );
item.version++; item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) { } catch (err) {
item.is_complete = originalCompleteStatus; item.is_complete = originalCompleteStatus; // Revert optimistic update
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' }); notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'), type: 'error' });
} finally { } finally {
item.updating = false; item.updating = false;
} }
@ -638,11 +633,12 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
const updateItemPrice = async (item: ItemWithUI) => { const updateItemPrice = async (item: ItemWithUI) => {
if (!list.value || !item.is_complete) return; if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null; 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; item.updating = true;
const originalPrice = item.price; const originalPrice = item.price;
const originalPriceInput = item.priceInput; const originalPriceInput = item.priceInput;
item.price = newPrice?.toString() || null; item.price = newPrice?.toString() || null; // Optimistic update
if (!isOnline.value) { if (!isOnline.value) {
offlineStore.addAction({ offlineStore.addAction({
type: 'update_list_item', type: 'update_list_item',
@ -650,13 +646,14 @@ const updateItemPrice = async (item: ItemWithUI) => {
listId: String(list.value.id), listId: String(list.value.id),
itemId: String(item.id), itemId: String(item.id),
data: { data: {
price: newPrice ?? null, price: newPrice ?? null, // Ensure null is sent if cleared
completed: item.is_complete completed: item.is_complete // Keep completion status
}, },
version: item.version version: item.version
} }
}); });
item.updating = false; item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
return; return;
} }
@ -666,10 +663,11 @@ const updateItemPrice = async (item: ItemWithUI) => {
{ price: newPrice?.toString(), completed: item.is_complete, version: item.version } { price: newPrice?.toString(), completed: item.is_complete, version: item.version }
); );
item.version++; item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) { } catch (err) {
item.price = originalPrice; item.price = originalPrice; // Revert optimistic update
item.priceInput = originalPriceInput; 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 { } finally {
item.updating = false; item.updating = false;
} }
@ -678,6 +676,7 @@ const updateItemPrice = async (item: ItemWithUI) => {
const deleteItem = async (item: ItemWithUI) => { const deleteItem = async (item: ItemWithUI) => {
if (!list.value) return; if (!list.value) return;
item.deleting = true; item.deleting = true;
const originalItems = [...list.value.items]; // For potential revert
if (!isOnline.value) { if (!isOnline.value) {
offlineStore.addAction({ offlineStore.addAction({
@ -687,29 +686,35 @@ const deleteItem = async (item: ItemWithUI) => {
itemId: String(item.id) 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; item.deleting = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
return; return;
} }
try { try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id))); 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); list.value.items = list.value.items.filter(i => i.id !== item.id);
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
} catch (err) { } 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 { } finally {
item.deleting = false; item.deleting = false;
} }
}; };
const confirmUpdateItem = (item: ItemWithUI, newCompleteStatus: boolean) => { 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); pendingAction.value = () => updateItem(item, newCompleteStatus);
showConfirmDialogState.value = true; showConfirmDialogState.value = true;
}; };
const confirmDeleteItem = (item: ItemWithUI) => { 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); pendingAction.value = () => deleteItem(item);
showConfirmDialogState.value = true; showConfirmDialogState.value = true;
}; };
@ -723,20 +728,19 @@ const handleConfirmedAction = async () => {
const cancelConfirmation = () => { const cancelConfirmation = () => {
showConfirmDialogState.value = false; showConfirmDialogState.value = false;
pendingAction.value = null; pendingAction.value = null;
confirmDialogMessage.value = ''; // Clear message
}; };
const openOcrDialog = () => { const openOcrDialog = () => {
ocrItems.value = []; ocrItems.value = [];
ocrError.value = null; ocrError.value = null;
resetOcrFileDialog(); resetOcrFileDialog(); // From useFileDialog
showOcrDialogState.value = true; showOcrDialogState.value = true;
nextTick(() => { nextTick(() => {
// For VInput type file, direct .value = '' might not work or be needed. if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
// 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
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el; const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = ''; 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 = ''; (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); ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
if (ocrItems.value.length === 0) { if (ocrItems.value.length === 0) {
ocrError.value = "No items extracted from the image."; ocrError.value = t('listDetailPage.errors.ocrNoItems');
} }
} catch (err) { } 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 { } finally {
ocrLoading.value = false; ocrLoading.value = false;
// Reset file input
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) { if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el; const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = ''; if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) { } else if (ocrFileInputRef.value) { // Native input
(ocrFileInputRef.value as any).value = ''; (ocrFileInputRef.value as any).value = '';
} }
} }
}; };
@ -798,16 +803,18 @@ const addOcrItems = async () => {
if (!item.name.trim()) continue; if (!item.name.trim()) continue;
const response = await apiClient.post( const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)), 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; const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]); list.value.items.push(processListItems([addedItem])[0]);
successCount++; 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(); closeOcrDialog();
} catch (err) { } 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 { } finally {
addingOcrItems.value = false; addingOcrItems.value = false;
} }
@ -821,7 +828,7 @@ const fetchListCostSummary = async () => {
const response = await apiClient.get(API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id)); const response = await apiClient.get(API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id));
listCostSummary.value = response.data; listCostSummary.value = response.data;
} catch (err) { } 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; listCostSummary.value = null;
} finally { } finally {
costSummaryLoading.value = false; costSummaryLoading.value = false;
@ -844,19 +851,19 @@ const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => { const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => {
switch (status) { switch (status) {
case ExpenseSplitStatusEnum.PAID: return 'Paid'; case ExpenseSplitStatusEnum.PAID: return t('listDetailPage.status.paid');
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return 'Partially Paid'; case ExpenseSplitStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallyPaid');
case ExpenseSplitStatusEnum.UNPAID: return 'Unpaid'; case ExpenseSplitStatusEnum.UNPAID: return t('listDetailPage.status.unpaid');
default: return status; default: return t('listDetailPage.status.unknown');
} }
}; };
const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => { const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => {
switch (status) { switch (status) {
case ExpenseOverallStatusEnum.PAID: return 'Settled'; case ExpenseOverallStatusEnum.PAID: return t('listDetailPage.status.settled');
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return 'Partially Settled'; case ExpenseOverallStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallySettled');
case ExpenseOverallStatusEnum.UNPAID: return 'Unsettled'; case ExpenseOverallStatusEnum.UNPAID: return t('listDetailPage.status.unsettled');
default: return status; 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) { if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
const activeElement = document.activeElement; const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) { 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; return;
} }
event.preventDefault(); event.preventDefault();
if (itemNameInputRef.value?.$el) { if (itemNameInputRef.value?.$el) { // Focus the add item input
(itemNameInputRef.value.$el as HTMLElement).focus(); (itemNameInputRef.value.$el as HTMLElement).focus();
} }
} }
}); });
let touchStartX = 0; let touchStartX = 0;
const SWIPE_THRESHOLD = 50; const SWIPE_THRESHOLD = 50; // Pixels
const handleTouchStart = (event: TouchEvent) => { const handleTouchStart = (event: TouchEvent) => {
touchStartX = event.changedTouches[0].clientX; 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(() => { onMounted(() => {
pageInitialLoad.value = true; pageInitialLoad.value = true;
itemsAreLoading.value = false; itemsAreLoading.value = false;
error.value = null; // Clear stale errors on mount error.value = null;
if (!route.params.id) { if (!route.params.id) {
error.value = 'No list ID provided'; error.value = t('listDetailPage.errors.fetchFailed'); // Generic error if no ID
pageInitialLoad.value = false; // Stop initial load phase, show error pageInitialLoad.value = false;
listDetailStore.setError('No list ID provided for expenses.'); // Set error in expense store listDetailStore.setError(t('listDetailPage.errors.fetchFailed'));
return; return;
} }
// Attempt to load shell data from sessionStorage
const listShellJSON = sessionStorage.getItem('listDetailShell'); const listShellJSON = sessionStorage.getItem('listDetailShell');
const routeId = String(route.params.id); const routeId = String(route.params.id);
if (listShellJSON) { if (listShellJSON) {
const shellData = JSON.parse(listShellJSON); const shellData = JSON.parse(listShellJSON);
// Ensure the shell data is for the current list
if (shellData.id === parseInt(routeId, 10)) { if (shellData.id === parseInt(routeId, 10)) {
list.value = { list.value = {
id: shellData.id, id: shellData.id,
name: shellData.name, name: shellData.name,
description: shellData.description, description: shellData.description,
is_complete: false, // Assume not complete until full data loaded is_complete: false,
items: [], // Start with no items, they will be fetched by fetchListDetails items: [],
version: 0, // Placeholder, will be updated version: 0,
updated_at: new Date().toISOString(), // Placeholder updated_at: new Date().toISOString(),
group_id: shellData.group_id, group_id: shellData.group_id,
}; };
pageInitialLoad.value = false; // Shell loaded, main page spinner can go pageInitialLoad.value = false;
// Optionally, clear the sessionStorage item after use
// sessionStorage.removeItem('listDetailShell');
} else { } else {
// Shell data is for a different list, clear it
sessionStorage.removeItem('listDetailShell'); sessionStorage.removeItem('listDetailShell');
// pageInitialLoad remains true, will be set to false by fetchListDetails
} }
} }
fetchListDetails().then(() => { // Fetches items fetchListDetails().then(() => {
startPolling(); startPolling();
}); });
// Fetch expenses using the store when component is mounted
const routeParamsId = route.params.id; const routeParamsId = route.params.id;
listDetailStore.fetchListWithExpenses(String(routeParamsId)); listDetailStore.fetchListWithExpenses(String(routeParamsId));
}); });
@ -951,7 +956,7 @@ onUnmounted(() => {
}); });
const editItem = (item: Item) => { const editItem = (item: Item) => {
editingItem.value = { ...item }; editingItem.value = { ...item }; // Clone item for editing
showEditDialog.value = true; showEditDialog.value = true;
}; };
@ -963,25 +968,22 @@ const closeEditDialog = () => {
const handleConfirmEdit = async () => { const handleConfirmEdit = async () => {
if (!editingItem.value || !list.value) return; if (!editingItem.value || !list.value) return;
const item = editingItem.value; const itemToUpdate = editingItem.value; // Already a clone
const originalItem = list.value.items.find(i => i.id === item.id);
if (!originalItem) return;
try { try {
const response = await apiClient.put( 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, name: itemToUpdate.name,
quantity: item.quantity?.toString(), quantity: itemToUpdate.quantity?.toString(), // Ensure quantity is string or null
version: item.version version: itemToUpdate.version
} }
); );
// Update the item in the list const updatedItemFromApi = response.data as Item;
const updatedItem = response.data as Item; const index = list.value.items.findIndex(i => i.id === updatedItemFromApi.id);
const index = list.value.items.findIndex(i => i.id === item.id);
if (index !== -1) { if (index !== -1) {
list.value.items[index] = processListItems([updatedItem])[0]; list.value.items[index] = processListItems([updatedItemFromApi])[0];
} }
notificationStore.addNotification({ notificationStore.addNotification({
@ -991,7 +993,7 @@ const handleConfirmEdit = async () => {
closeEditDialog(); closeEditDialog();
} catch (err) { } catch (err) {
notificationStore.addNotification({ 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' type: 'error'
}); });
} }
@ -999,7 +1001,7 @@ const handleConfirmEdit = async () => {
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => { const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
if (split.user_id !== authStore.user?.id) { 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; return;
} }
selectedSplitForSettlement.value = split; selectedSplitForSettlement.value = split;
@ -1023,24 +1025,24 @@ const closeSettleShareModal = () => {
const validateSettleAmount = (): boolean => { const validateSettleAmount = (): boolean => {
settleAmountError.value = null; settleAmountError.value = null;
if (!settleAmount.value.trim()) { if (!settleAmount.value.trim()) {
settleAmountError.value = 'Please enter an amount.'; settleAmountError.value = t('listDetailPage.settleShareModal.errors.enterAmount');
return false; return false;
} }
const amount = new Decimal(settleAmount.value); const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) { if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = 'Please enter a positive amount.'; settleAmountError.value = t('listDetailPage.settleShareModal.errors.positiveAmount');
return false; return false;
} }
if (selectedSplitForSettlement.value) { if (selectedSplitForSettlement.value) {
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(selectedSplitForSettlement.value.id)); const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(selectedSplitForSettlement.value.id));
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount); const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
const remaining = owed.minus(alreadyPaid); const remaining = owed.minus(alreadyPaid);
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) { // Epsilon for float issues if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
settleAmountError.value = `Amount cannot exceed remaining: ${formatCurrency(remaining.toFixed(2))}.`; settleAmountError.value = t('listDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
return false; return false;
} }
} else { } else {
settleAmountError.value = 'Error: No split selected.'; // Should not happen settleAmountError.value = t('listDetailPage.settleShareModal.errors.noSplitSelected');
return false; return false;
} }
return true; return true;
@ -1050,13 +1052,13 @@ const currentListIdForRefetch = computed(() => listDetailStore.currentList?.id |
const handleConfirmSettle = async () => { const handleConfirmSettle = async () => {
if (!selectedSplitForSettlement.value || !authStore.user?.id || !currentListIdForRefetch.value) { 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; return;
} }
// Use settleAmount.value which is the confirmed amount (remaining amount for MVP)
const activityData: SettlementActivityCreate = { const activityData: SettlementActivityCreate = {
expense_split_id: selectedSplitForSettlement.value.id, 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(), amount_paid: new Decimal(settleAmount.value).toString(),
paid_at: new Date().toISOString(), paid_at: new Date().toISOString(),
}; };
@ -1068,15 +1070,14 @@ const handleConfirmSettle = async () => {
}); });
if (success) { if (success) {
notificationStore.addNotification({ message: 'Share settled successfully!', type: 'success' }); notificationStore.addNotification({ message: t('listDetailPage.notifications.settleShareSuccess'), type: 'success' });
closeSettleShareModal(); closeSettleShareModal();
} else { } 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) => { const handleExpenseCreated = (expense: any) => {
// Refresh the expenses list
if (list.value?.id) { if (list.value?.id) {
listDetailStore.fetchListWithExpenses(String(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"> <VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions> <template #actions>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">Retry</VButton> <VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
</template> </template>
</VAlert> </VAlert>
<VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard" <VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
:empty-title="noListsMessage"> :empty-title="t(noListsMessageKey.value)">
<template #default> <template #default>
<p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p> <p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
<p v-else>This group doesn't have any lists yet.</p> <p v-else>{{ t('listsPage.emptyState.groupSpecificInfo') }}</p>
</template> </template>
<template #empty-actions> <template #empty-actions>
<VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus"> <VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus">
Create New List {{ t('listsPage.createNewListButton') }}
</VButton> </VButton>
</template> </template>
</VCard> </VCard>
<div v-else-if="loading && lists.length === 0" class="loading-placeholder"> <div v-else-if="loading && lists.length === 0" class="loading-placeholder">
Loading lists... {{ t('listsPage.loadingLists') }}
</div> </div>
<div v-else> <div v-else>
@ -32,7 +32,7 @@
@touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd" @touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd"
:data-list-id="list.id"> :data-list-id="list.id">
<div class="neo-list-header">{{ list.name }}</div> <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"> <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" <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 }"> :data-item-temp-id="item.tempId" :class="{ 'is-updating': item.updating }">
@ -47,7 +47,7 @@
<li class="neo-list-item new-item-input-container"> <li class="neo-list-item new-item-input-container">
<label class="neo-checkbox-label"> <label class="neo-checkbox-label">
<input type="checkbox" disabled /> <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)" :data-list-id="list.id" @keyup.enter="addNewItem(list, $event)"
@blur="handleNewItemBlur(list, $event)" @click.stop /> @blur="handleNewItemBlur(list, $event)" @click.stop />
</label> </label>
@ -55,7 +55,7 @@
</ul> </ul>
</div> </div>
<div class="neo-create-list-card" @click="showCreateModal = true" ref="createListCardRef"> <div class="neo-create-list-card" @click="showCreateModal = true" ref="createListCardRef">
+ Create a new list {{ t('listsPage.createCard.title') }}
</div> </div>
</div> </div>
</div> </div>
@ -66,15 +66,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue'; import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Adjust path as needed import { apiClient, API_ENDPOINTS } from '@/config/api'; // Adjust path as needed
import CreateListModal from '@/components/CreateListModal.vue'; // Adjust path as needed import CreateListModal from '@/components/CreateListModal.vue'; // Adjust path as needed
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.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 VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed
import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
import { animate } from 'motion'; import { animate } from 'motion';
const { t } = useI18n();
interface List { interface List {
id: number; id: number;
name: string; name: string;
@ -155,17 +158,17 @@ const fetchCurrentViewGroupName = async () => {
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (currentGroupId.value) { if (currentGroupId.value) {
return currentViewedGroup.value return currentViewedGroup.value
? `Lists for ${currentViewedGroup.value.name}` ? t('listsPage.pageTitle.forGroup', { groupName: currentViewedGroup.value.name })
: `Lists for Group ${currentGroupId.value}`; : t('listsPage.pageTitle.forGroupId', { groupId: currentGroupId.value });
} }
return 'My Lists'; return t('listsPage.pageTitle.myLists');
}); });
const noListsMessage = computed(() => { const noListsMessageKey = computed(() => {
if (currentGroupId.value) { 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 () => { const fetchAllAccessibleGroups = async () => {
@ -202,7 +205,7 @@ const fetchLists = async () => {
cachedLists.value = JSON.parse(JSON.stringify(response.data)); cachedLists.value = JSON.parse(JSON.stringify(response.data));
cachedTimestamp.value = Date.now(); cachedTimestamp.value = Date.now();
} catch (err: unknown) { } 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); console.error(error.value, err);
if (cachedLists.value.length === 0) lists.value = []; if (cachedLists.value.length === 0) lists.value = [];
} finally { } finally {

View File

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

View File

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

View File

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

View File

@ -2,29 +2,29 @@
<main class="flex items-center justify-center page-container"> <main class="flex items-center justify-center page-container">
<div class="card signup-card"> <div class="card signup-card">
<div class="card-header"> <div class="card-header">
<h3>Sign Up</h3> <h3>{{ $t('signupPage.header') }}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout"> <form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2"> <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" /> <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> <p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
</div> </div>
<div class="form-group mb-2"> <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" /> <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> <p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div> </div>
<div class="form-group mb-2"> <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"> <div class="input-with-icon-append">
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input" <input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
required autocomplete="new-password" /> required autocomplete="new-password" />
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" <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"> <svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use> <use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg> <!-- Placeholder for visibility icons --> </svg> <!-- Placeholder for visibility icons -->
@ -34,7 +34,7 @@
</div> </div>
<div class="form-group mb-3"> <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" <input :type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" v-model="confirmPassword"
class="form-input" required autocomplete="new-password" /> class="form-input" required autocomplete="new-password" />
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p> <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"> <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> <span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Sign Up {{ $t('signupPage.submitButton') }}
</button> </button>
<div class="text-center mt-2"> <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> </div>
</form> </form>
</div> </div>
@ -59,9 +59,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores/auth'; // Assuming path is correct import { useAuthStore } from '@/stores/auth'; // Assuming path is correct
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
@ -82,22 +84,22 @@ const isValidEmail = (val: string): boolean => {
const validateForm = (): boolean => { const validateForm = (): boolean => {
formErrors.value = {}; formErrors.value = {};
if (!name.value.trim()) { if (!name.value.trim()) {
formErrors.value.name = 'Name is required'; formErrors.value.name = t('signupPage.validation.nameRequired');
} }
if (!email.value.trim()) { if (!email.value.trim()) {
formErrors.value.email = 'Email is required'; formErrors.value.email = t('signupPage.validation.emailRequired');
} else if (!isValidEmail(email.value)) { } else if (!isValidEmail(email.value)) {
formErrors.value.email = 'Invalid email format'; formErrors.value.email = t('signupPage.validation.emailInvalid');
} }
if (!password.value) { if (!password.value) {
formErrors.value.password = 'Password is required'; formErrors.value.password = t('signupPage.validation.passwordRequired');
} else if (password.value.length < 8) { } 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) { if (!confirmPassword.value) {
formErrors.value.confirmPassword = 'Please confirm your password'; formErrors.value.confirmPassword = t('signupPage.validation.confirmPasswordRequired');
} else if (password.value !== confirmPassword.value) { } 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; return Object.keys(formErrors.value).length === 0;
}; };
@ -114,13 +116,17 @@ const onSubmit = async () => {
email: email.value, email: email.value,
password: password.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'); router.push('auth/login');
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Signup failed. Please try again.'; // Prefer API error message if available, otherwise use generic translated message for the form
formErrors.value.general = message; const errorMessageForForm = error instanceof Error ? error.message : t('signupPage.notifications.signupFailed');
console.error(message, error); formErrors.value.general = errorMessageForForm;
notificationStore.addNotification({ message, type: 'error' });
// 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 { } finally {
loading.value = false; loading.value = false;
} }