Merge pull request 'ph4' (#49) from ph4 into prod

Reviewed-on: #49
This commit is contained in:
mo 2025-06-02 00:20:48 +02:00
commit 6a61bb8df4
41 changed files with 2882 additions and 695 deletions

View File

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

View File

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

View File

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

189
fe/package-lock.json generated
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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