ph4 #49
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
@ -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"
|
||||||
|
@ -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",
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
BIN
fe/public/icons/icon-144x144.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
fe/public/icons/icon-152x152.png
Normal file
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 46 KiB |
BIN
fe/public/icons/icon-48x48.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 72 KiB |
BIN
fe/public/icons/icon-72x72.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
fe/public/icons/icon-96x96.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
@ -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
@ -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."
|
||||||
|
}
|
||||||
|
}
|
@ -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
@ -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
@ -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
@ -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."
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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.
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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> = {
|
||||||
|
@ -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
|
||||||
|
@ -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' },
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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' });
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|