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.
This commit is contained in:
google-labs-jules[bot] 2025-06-01 21:51:01 +00:00
parent e6c15210c1
commit 554814ad63
16 changed files with 1370 additions and 376 deletions

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",

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

@ -0,0 +1,274 @@
{
"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"
}
}
}

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'
};

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

@ -0,0 +1,274 @@
{
"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"
}
}
}

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

@ -0,0 +1,274 @@
{
"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"
}
}
}

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

@ -0,0 +1,274 @@
{
"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"
}
}
}

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

@ -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

@ -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' });