diff --git a/.github/workflows/test-and-deploy.yml b/.github/workflows/test-and-deploy.yml index 06eb4f5..4660e68 100644 --- a/.github/workflows/test-and-deploy.yml +++ b/.github/workflows/test-and-deploy.yml @@ -1,13 +1,33 @@ -name: Deploy to Staging +name: Test & Deploy on: - push: - branches: [staging, main] pull_request: branches: [staging, main] + push: + branches: [staging, main] jobs: + test: + runs-on: ubuntu-latest + name: test + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci + + - name: Run tests and build + run: npm run build + deploy: + needs: test if: github.ref == 'refs/heads/staging' runs-on: ubuntu-latest name: deploy diff --git a/config/jest.config.cjs b/config/jest.config.cjs index d489535..f22ba58 100644 --- a/config/jest.config.cjs +++ b/config/jest.config.cjs @@ -7,8 +7,10 @@ const createJestConfig = nextJest({ // Add any custom config to be passed to Jest const customJestConfig = { + // Ensure is the project root (one level up from /config) + rootDir: '../', setupFilesAfterEnv: ['/config/jest.setup.js'], - moduleNameMapping: { + moduleNameMapper: { // Handle module aliases (this will be automatically configured for you based on your tsconfig.json paths) '^@/(.*)$': '/src/$1', }, diff --git a/config/jest.setup.js b/config/jest.setup.js index a56251e..2c49fc1 100644 --- a/config/jest.setup.js +++ b/config/jest.setup.js @@ -23,13 +23,3 @@ jest.mock('next/router', () => ({ }; }, })); - -// Mock Auth0 -jest.mock('@auth0/nextjs-auth0/client', () => ({ - useUser: () => ({ - user: null, - error: null, - isLoading: false, - }), - UserProvider: ({ children }) => children, -})); diff --git a/next.config.ts b/next.config.ts index 6ce7f43..0c3fb99 100644 --- a/next.config.ts +++ b/next.config.ts @@ -18,14 +18,15 @@ const nextConfig = { formats: ['image/avif', 'image/webp'], // Allowed remote image sources (domains is deprecated) - remotePatterns: [ - { - protocol: 'https', - hostname: 'streetsupportstoragestag.blob.core.windows.net', - port: '', - pathname: '/**', - }, - ], + remotePatterns: process.env.BLOB_STORAGE_HOSTNAME + ? [ + { + protocol: 'https', + hostname: process.env.BLOB_STORAGE_HOSTNAME, + pathname: '/**', + }, + ] + : [], // Device sizes for responsive images deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048], @@ -51,10 +52,6 @@ const nextConfig = { }, }, - eslint: { - ignoreDuringBuilds: true, - }, - // Production optimizations ...(process.env.NODE_ENV === 'production' && { output: 'standalone', // Optimize for deployment diff --git a/package-lock.json b/package-lock.json index 9cc55d9..8a4137b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,17 @@ "name": "streetsupport-platform-admin", "version": "0.1.0", "dependencies": { + "@lexical/link": "^0.38.2", + "@lexical/list": "^0.38.2", + "@lexical/react": "^0.38.2", + "@lexical/rich-text": "^0.38.2", + "@lexical/selection": "^0.38.2", + "@lexical/utils": "^0.38.2", "@sentry/nextjs": "^10.15.0", + "@types/dompurify": "^3.0.5", "clsx": "^2.1.1", + "dompurify": "^3.3.0", + "lexical": "^0.38.2", "lucide-react": "^0.511.0", "next": "15.3.2", "next-auth": "^4.24.11", @@ -2304,6 +2313,59 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3594,6 +3656,281 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lexical/clipboard": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.38.2.tgz", + "integrity": "sha512-dDShUplCu8/o6BB9ousr3uFZ9bltR+HtleF/Tl8FXFNPpZ4AXhbLKUoJuucRuIr+zqT7RxEv/3M6pk/HEoE6NQ==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.38.2", + "@lexical/list": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/code": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.38.2.tgz", + "integrity": "sha512-wpqgbmPsfi/+8SYP0zI2kml09fGPRhzO5litR9DIbbSGvcbawMbRNcKLO81DaTbsJRnBJiQvbBBBJAwZKRqgBw==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.38.2", + "lexical": "0.38.2", + "prismjs": "^1.30.0" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.38.2.tgz", + "integrity": "sha512-hlN0q7taHNzG47xKynQLCAFEPOL8l6IP79C2M18/FE1+htqNP35q4rWhYhsptGlKo4me4PtiME7mskvr7T4yqA==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.38.2", + "@lexical/link": "0.38.2", + "@lexical/mark": "0.38.2", + "@lexical/table": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.38.2.tgz", + "integrity": "sha512-riOhgo+l4oN50RnLGhcqeUokVlMZRc+NDrxRNs2lyKSUdC4vAhAmAVUHDqYPyb4K4ZSw4ebZ3j8hI2zO4O3BbA==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/extension": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.38.2.tgz", + "integrity": "sha512-qbUNxEVjAC0kxp7hEMTzktj0/51SyJoIJWK6Gm790b4yNBq82fEPkksfuLkRg9VQUteD0RT1Nkjy8pho8nNamw==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.38.2", + "@preact/signals-core": "^1.11.0", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.38.2.tgz", + "integrity": "sha512-jNI4Pv+plth39bjOeeQegMypkjDmoMWBMZtV0lCynBpkkPFlfMnyL9uzW/IxkZnX8LXWSw5mbWk07nqOUNTCrA==", + "license": "MIT", + "dependencies": { + "@lexical/text": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/history": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.38.2.tgz", + "integrity": "sha512-QWPwoVDMe/oJ0+TFhy78TDi7TWU/8bcDRFUNk1nWgbq7+2m+5MMoj90LmOFwakQHnCVovgba2qj+atZrab1dsQ==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/html": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.38.2.tgz", + "integrity": "sha512-pC5AV+07bmHistRwgG3NJzBMlIzSdxYO6rJU4eBNzyR4becdiLsI4iuv+aY7PhfSv+SCs7QJ9oc4i5caq48Pkg==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/link": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.38.2.tgz", + "integrity": "sha512-UOKTyYqrdCR9+7GmH6ZVqJTmqYefKGMUHMGljyGks+OjOGZAQs78S1QgcPEqltDy+SSdPSYK7wAo6gjxZfEq9g==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/list": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.38.2.tgz", + "integrity": "sha512-OQm9TzatlMrDZGxMxbozZEHzMJhKxAbH1TOnOGyFfzpfjbnFK2y8oLeVsfQZfZRmiqQS4Qc/rpFnRP2Ax5dsbA==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/mark": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.38.2.tgz", + "integrity": "sha512-U+8KGwc3cP5DxSs15HfkP2YZJDs5wMbWQAwpGqep9bKphgxUgjPViKhdi+PxIt2QEzk7WcoZWUsK1d2ty/vSmg==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.38.2.tgz", + "integrity": "sha512-ykQJ9KUpCs1+Ak6ZhQMP6Slai4/CxfLEGg/rSHNVGbcd7OaH/ICtZN5jOmIe9ExfXMWy1o8PyMu+oAM3+AWFgA==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.38.2", + "@lexical/link": "0.38.2", + "@lexical/list": "0.38.2", + "@lexical/rich-text": "0.38.2", + "@lexical/text": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/offset": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.38.2.tgz", + "integrity": "sha512-uDky2palcY+gE6WTv6q2umm2ioTUnVqcaWlEcchP6A310rI08n6rbpmkaLSIh3mT2GJQN2QcN2x0ct5BQmKIpA==", + "license": "MIT", + "dependencies": { + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.38.2.tgz", + "integrity": "sha512-f6vkTf+YZF0EuKvUK3goh4jrnF+Z0koiNMO+7rhSMLooc5IlD/4XXix4ZLiIktUWq4BhO84b82qtrO+6oPUxtw==", + "license": "MIT", + "dependencies": { + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.38.2.tgz", + "integrity": "sha512-xRYNHJJFCbaQgr0uErW8Im2Phv1nWHIT4VSoAlBYqLuVGZBD4p61dqheBwqXWlGGJFk+MY5C5URLiMicgpol7A==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.38.2", + "@lexical/dragon": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/react": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.38.2.tgz", + "integrity": "sha512-M3z3MkWyw3Msg4Hojr5TnO4TzL71NVPVNGoavESjdgJbTdv1ezcQqjE4feq+qs7H9jytZeuK8wsEOJfSPmNd8w==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@lexical/devtools-core": "0.38.2", + "@lexical/dragon": "0.38.2", + "@lexical/extension": "0.38.2", + "@lexical/hashtag": "0.38.2", + "@lexical/history": "0.38.2", + "@lexical/link": "0.38.2", + "@lexical/list": "0.38.2", + "@lexical/mark": "0.38.2", + "@lexical/markdown": "0.38.2", + "@lexical/overflow": "0.38.2", + "@lexical/plain-text": "0.38.2", + "@lexical/rich-text": "0.38.2", + "@lexical/table": "0.38.2", + "@lexical/text": "0.38.2", + "@lexical/utils": "0.38.2", + "@lexical/yjs": "0.38.2", + "lexical": "0.38.2", + "react-error-boundary": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.38.2.tgz", + "integrity": "sha512-eFjeOT7YnDZYpty7Zlwlct0UxUSaYu53uLYG+Prs3NoKzsfEK7e7nYsy/BbQFfk5HoM1pYuYxFR2iIX62+YHGw==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.38.2", + "@lexical/dragon": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/selection": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.38.2.tgz", + "integrity": "sha512-eMFiWlBH6bEX9U9sMJ6PXPxVXTrihQfFeiIlWLuTpEIDF2HRz7Uo1KFRC/yN6q0DQaj7d9NZYA6Mei5DoQuz5w==", + "license": "MIT", + "dependencies": { + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/table": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.38.2.tgz", + "integrity": "sha512-uu0i7yz0nbClmHOO5ZFsinRJE6vQnFz2YPblYHAlNigiBedhqMwSv5bedrzDq8nTTHwych3mC63tcyKIrM+I1g==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.38.2", + "@lexical/extension": "0.38.2", + "@lexical/utils": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/text": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.38.2.tgz", + "integrity": "sha512-+juZxUugtC4T37aE3P0l4I9tsWbogDUnTI/mgYk4Ht9g+gLJnhQkzSA8chIyfTxbj5i0A8yWrUUSw+/xA7lKUQ==", + "license": "MIT", + "dependencies": { + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/utils": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.38.2.tgz", + "integrity": "sha512-y+3rw15r4oAWIEXicUdNjfk8018dbKl7dWHqGHVEtqzAYefnEYdfD2FJ5KOTXfeoYfxi8yOW7FvzS4NZDi8Bfw==", + "license": "MIT", + "dependencies": { + "@lexical/list": "0.38.2", + "@lexical/selection": "0.38.2", + "@lexical/table": "0.38.2", + "lexical": "0.38.2" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.38.2.tgz", + "integrity": "sha512-fg6ZHNrVQmy1AAxaTs8HrFbeNTJCaCoEDPi6pqypHQU3QVfqr4nq0L0EcHU/TRlR1CeduEPvZZIjUUxWTZ0u8g==", + "license": "MIT", + "dependencies": { + "@lexical/offset": "0.38.2", + "@lexical/selection": "0.38.2", + "lexical": "0.38.2" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4334,6 +4671,16 @@ "node": ">=18" } }, + "node_modules/@preact/signals-core": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.1.tgz", + "integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@prisma/instrumentation": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.15.0.tgz", @@ -5871,6 +6218,15 @@ "@types/node": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -6104,6 +6460,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -7491,6 +7853,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", + "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -7527,9 +7898,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", - "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", "funding": [ { "type": "opencollective", @@ -7546,10 +7917,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001737", - "electron-to-chromium": "^1.5.211", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" @@ -7656,9 +8028,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001739", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz", - "integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==", + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", "funding": [ { "type": "opencollective", @@ -8227,6 +8599,15 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -8255,9 +8636,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.213", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.213.tgz", - "integrity": "sha512-xr9eRzSLNa4neDO0xVFrkXu3vyIzG4Ay08dApecw42Z1NbmCt+keEpXdvlYGVe0wtvY5dhW0Ay0lY0IOfsCg0Q==", + "version": "1.5.249", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz", + "integrity": "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==", "license": "ISC" }, "node_modules/emittery": { @@ -10429,6 +10810,17 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -12014,6 +12406,34 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.38.2.tgz", + "integrity": "sha512-JJmfsG3c4gwBHzUGffbV7ifMNkKAWMCnYE3xJl87gty7hjyV5f3xq7eqTjP5HFYvO4XpjJvvWO2/djHp5S10tw==", + "license": "MIT" + }, + "node_modules/lib0": { + "version": "0.2.114", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", + "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -12261,13 +12681,17 @@ "license": "MIT" }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "license": "MIT", "peer": true, "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -12781,9 +13205,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, "node_modules/normalize-path": { @@ -13678,6 +14102,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -13791,6 +14224,18 @@ "react": "^19.1.1" } }, + "node_modules/react-error-boundary": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz", + "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-hot-toast": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", @@ -14270,9 +14715,9 @@ "license": "MIT" }, "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", "peer": true, "dependencies": { @@ -14989,6 +15434,12 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", @@ -14997,9 +15448,9 @@ "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "license": "MIT", "engines": { "node": ">=6" @@ -15048,9 +15499,9 @@ } }, "node_modules/terser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", - "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "version": "5.44.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", + "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -15615,9 +16066,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", "funding": [ { "type": "opencollective", @@ -15733,9 +16184,9 @@ } }, "node_modules/webpack": { - "version": "5.101.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", - "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", + "version": "5.102.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", + "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "license": "MIT", "peer": true, "dependencies": { @@ -15747,7 +16198,7 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", + "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", @@ -15759,10 +16210,10 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", + "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { @@ -16109,6 +16560,24 @@ "node": ">=12" } }, + "node_modules/yjs": { + "version": "13.6.27", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", + "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 1d843c5..0b53458 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "next dev", "prebuild": "node ./scripts/fetch-prebuild.js", - "build": "next build", + "build": "npm run test:ci -- --passWithNoTests && next build", "start": "next start", "lint": "next lint", "test": "jest --config ./config/jest.config.cjs", @@ -17,8 +17,17 @@ "test:e2e:debug": "playwright test --config=./config/playwright.config.ts --debug" }, "dependencies": { + "@lexical/link": "^0.38.2", + "@lexical/list": "^0.38.2", + "@lexical/react": "^0.38.2", + "@lexical/rich-text": "^0.38.2", + "@lexical/selection": "^0.38.2", + "@lexical/utils": "^0.38.2", "@sentry/nextjs": "^10.15.0", + "@types/dompurify": "^3.0.5", "clsx": "^2.1.1", + "dompurify": "^3.3.0", + "lexical": "^0.38.2", "lucide-react": "^0.511.0", "next": "15.3.2", "next-auth": "^4.24.11", diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..fa00aa8 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/assets/img/location-logos/birmingham-logos/bcc.png b/public/assets/img/location-logos/birmingham-logos/bcc.png new file mode 100644 index 0000000..e9b0050 Binary files /dev/null and b/public/assets/img/location-logos/birmingham-logos/bcc.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/bbo-bournemouth.png b/public/assets/img/location-logos/bournemouth-logos/bbo-bournemouth.png new file mode 100644 index 0000000..8c642fd Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/bbo-bournemouth.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/bcp-council.png b/public/assets/img/location-logos/bournemouth-logos/bcp-council.png new file mode 100644 index 0000000..9a37137 Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/bcp-council.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/bournemouth-council.png b/public/assets/img/location-logos/bournemouth-logos/bournemouth-council.png new file mode 100644 index 0000000..136c89d Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/bournemouth-council.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/bournemouth-tap.png b/public/assets/img/location-logos/bournemouth-logos/bournemouth-tap.png new file mode 100644 index 0000000..6d88133 Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/bournemouth-tap.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/bournemouth-university_250px.png b/public/assets/img/location-logos/bournemouth-logos/bournemouth-university_250px.png new file mode 100644 index 0000000..1ce16b3 Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/bournemouth-university_250px.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/change-for-good.png b/public/assets/img/location-logos/bournemouth-logos/change-for-good.png new file mode 100644 index 0000000..254d09e Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/change-for-good.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/christchurch-council.png b/public/assets/img/location-logos/bournemouth-logos/christchurch-council.png new file mode 100644 index 0000000..767d71e Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/christchurch-council.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/dorset-community-foundation.png b/public/assets/img/location-logos/bournemouth-logos/dorset-community-foundation.png new file mode 100644 index 0000000..c740761 Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/dorset-community-foundation.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/dorset-pcc.png b/public/assets/img/location-logos/bournemouth-logos/dorset-pcc.png new file mode 100644 index 0000000..4d34a9e Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/dorset-pcc.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/poole-council.png b/public/assets/img/location-logos/bournemouth-logos/poole-council.png new file mode 100644 index 0000000..815c0f3 Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/poole-council.png differ diff --git a/public/assets/img/location-logos/bournemouth-logos/wiseability-bournemouth.png b/public/assets/img/location-logos/bournemouth-logos/wiseability-bournemouth.png new file mode 100644 index 0000000..8aade47 Binary files /dev/null and b/public/assets/img/location-logos/bournemouth-logos/wiseability-bournemouth.png differ diff --git a/public/assets/img/location-logos/brighton-logos/antifreeze.png b/public/assets/img/location-logos/brighton-logos/antifreeze.png new file mode 100644 index 0000000..8efbef2 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/antifreeze.png differ diff --git a/public/assets/img/location-logos/brighton-logos/arch.jpg b/public/assets/img/location-logos/brighton-logos/arch.jpg new file mode 100644 index 0000000..e37b916 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/arch.jpg differ diff --git a/public/assets/img/location-logos/brighton-logos/brighton-and-hove-buses.png b/public/assets/img/location-logos/brighton-logos/brighton-and-hove-buses.png new file mode 100644 index 0000000..e491c16 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/brighton-and-hove-buses.png differ diff --git a/public/assets/img/location-logos/brighton-logos/care-for-our-city.jpg b/public/assets/img/location-logos/brighton-logos/care-for-our-city.jpg new file mode 100644 index 0000000..78a6ccf Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/care-for-our-city.jpg differ diff --git a/public/assets/img/location-logos/brighton-logos/city-council.png b/public/assets/img/location-logos/brighton-logos/city-council.png new file mode 100644 index 0000000..33696c3 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/city-council.png differ diff --git a/public/assets/img/location-logos/brighton-logos/emmaus-bh.jpg b/public/assets/img/location-logos/brighton-logos/emmaus-bh.jpg new file mode 100644 index 0000000..183018c Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/emmaus-bh.jpg differ diff --git a/public/assets/img/location-logos/brighton-logos/enjoolata.png b/public/assets/img/location-logos/brighton-logos/enjoolata.png new file mode 100644 index 0000000..fe2f8d5 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/enjoolata.png differ diff --git a/public/assets/img/location-logos/brighton-logos/fia.png b/public/assets/img/location-logos/brighton-logos/fia.png new file mode 100644 index 0000000..cb9a1af Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/fia.png differ diff --git a/public/assets/img/location-logos/brighton-logos/frontline-network.jpg b/public/assets/img/location-logos/brighton-logos/frontline-network.jpg new file mode 100644 index 0000000..506d55d Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/frontline-network.jpg differ diff --git a/public/assets/img/location-logos/brighton-logos/holland-rd.png b/public/assets/img/location-logos/brighton-logos/holland-rd.png new file mode 100644 index 0000000..183fdeb Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/holland-rd.png differ diff --git a/public/assets/img/location-logos/brighton-logos/holland-road.png b/public/assets/img/location-logos/brighton-logos/holland-road.png new file mode 100644 index 0000000..a2c3eba Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/holland-road.png differ diff --git a/public/assets/img/location-logos/brighton-logos/justlife.jpg b/public/assets/img/location-logos/brighton-logos/justlife.jpg new file mode 100644 index 0000000..44e2749 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/justlife.jpg differ diff --git a/public/assets/img/location-logos/brighton-logos/justlife.svg b/public/assets/img/location-logos/brighton-logos/justlife.svg new file mode 100644 index 0000000..d4e5622 --- /dev/null +++ b/public/assets/img/location-logos/brighton-logos/justlife.svg @@ -0,0 +1,19 @@ + + + logo-mobile + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/assets/img/location-logos/brighton-logos/off-the-fence.jpg b/public/assets/img/location-logos/brighton-logos/off-the-fence.jpg new file mode 100644 index 0000000..c38a872 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/off-the-fence.jpg differ diff --git a/public/assets/img/location-logos/brighton-logos/ppl-neighbourhood.jpg b/public/assets/img/location-logos/brighton-logos/ppl-neighbourhood.jpg new file mode 100644 index 0000000..ce7a3d5 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/ppl-neighbourhood.jpg differ diff --git a/public/assets/img/location-logos/brighton-logos/scf-logo.png b/public/assets/img/location-logos/brighton-logos/scf-logo.png new file mode 100644 index 0000000..a0d6319 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/scf-logo.png differ diff --git a/public/assets/img/location-logos/brighton-logos/shs-logo.png b/public/assets/img/location-logos/brighton-logos/shs-logo.png new file mode 100644 index 0000000..2a6c8b5 Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/shs-logo.png differ diff --git a/public/assets/img/location-logos/brighton-logos/sjrc.jpg b/public/assets/img/location-logos/brighton-logos/sjrc.jpg new file mode 100644 index 0000000..ffdd05a Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/sjrc.jpg differ diff --git a/public/assets/img/location-logos/brighton-logos/sussex-nightstop.jpg b/public/assets/img/location-logos/brighton-logos/sussex-nightstop.jpg new file mode 100644 index 0000000..d33030d Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/sussex-nightstop.jpg differ diff --git a/public/assets/img/location-logos/brighton-logos/sussex-police.png b/public/assets/img/location-logos/brighton-logos/sussex-police.png new file mode 100644 index 0000000..b3353da Binary files /dev/null and b/public/assets/img/location-logos/brighton-logos/sussex-police.png differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-ccf.jpg b/public/assets/img/location-logos/cambridge-logos/cambridge-ccf.jpg new file mode 100644 index 0000000..fcb0bce Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-ccf.jpg differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-council.png b/public/assets/img/location-logos/cambridge-logos/cambridge-council.png new file mode 100644 index 0000000..e6df1b8 Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-council.png differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-foodbank.jpg b/public/assets/img/location-logos/cambridge-logos/cambridge-foodbank.jpg new file mode 100644 index 0000000..274e885 Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-foodbank.jpg differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-gonville.jpg b/public/assets/img/location-logos/cambridge-logos/cambridge-gonville.jpg new file mode 100644 index 0000000..df1c95e Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-gonville.jpg differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-jimmys.png b/public/assets/img/location-logos/cambridge-logos/cambridge-jimmys.png new file mode 100644 index 0000000..bfcbaad Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-jimmys.png differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-partnership.png b/public/assets/img/location-logos/cambridge-logos/cambridge-partnership.png new file mode 100644 index 0000000..6b6a146 Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-partnership.png differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-salvation-army.jpg b/public/assets/img/location-logos/cambridge-logos/cambridge-salvation-army.jpg new file mode 100644 index 0000000..26e3f78 Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-salvation-army.jpg differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-street-aid-logo.png b/public/assets/img/location-logos/cambridge-logos/cambridge-street-aid-logo.png new file mode 100644 index 0000000..023a452 Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-street-aid-logo.png differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-street-aid.jpg b/public/assets/img/location-logos/cambridge-logos/cambridge-street-aid.jpg new file mode 100644 index 0000000..b358eb6 Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-street-aid.jpg differ diff --git a/public/assets/img/location-logos/cambridge-logos/cambridge-winter-comfort.jpg b/public/assets/img/location-logos/cambridge-logos/cambridge-winter-comfort.jpg new file mode 100644 index 0000000..8d7991e Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/cambridge-winter-comfort.jpg differ diff --git a/public/assets/img/location-logos/cambridge-logos/itac-white.png b/public/assets/img/location-logos/cambridge-logos/itac-white.png new file mode 100644 index 0000000..55e89b1 Binary files /dev/null and b/public/assets/img/location-logos/cambridge-logos/itac-white.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/aru-chelmsford.png b/public/assets/img/location-logos/chelmsford-logos/aru-chelmsford.png new file mode 100644 index 0000000..10739ee Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/aru-chelmsford.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/c2bk.png b/public/assets/img/location-logos/chelmsford-logos/c2bk.png new file mode 100644 index 0000000..7ce8cdc Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/c2bk.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/chelmsford-city-co.png b/public/assets/img/location-logos/chelmsford-logos/chelmsford-city-co.png new file mode 100644 index 0000000..e25d3cb Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/chelmsford-city-co.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/chelmsford-iss.jpg b/public/assets/img/location-logos/chelmsford-logos/chelmsford-iss.jpg new file mode 100644 index 0000000..f50fb53 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/chelmsford-iss.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/chelmsford-pcn.jpg b/public/assets/img/location-logos/chelmsford-logos/chelmsford-pcn.jpg new file mode 100644 index 0000000..6d37d20 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/chelmsford-pcn.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/chelmsford-rama.jpg b/public/assets/img/location-logos/chelmsford-logos/chelmsford-rama.jpg new file mode 100644 index 0000000..41ab41c Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/chelmsford-rama.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/chelmsford-rama.png b/public/assets/img/location-logos/chelmsford-logos/chelmsford-rama.png new file mode 100644 index 0000000..91d809d Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/chelmsford-rama.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/chess.jpg b/public/assets/img/location-logos/chelmsford-logos/chess.jpg new file mode 100644 index 0000000..93d4aee Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/chess.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/co-op-cf.jpg b/public/assets/img/location-logos/chelmsford-logos/co-op-cf.jpg new file mode 100644 index 0000000..0f58e88 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/co-op-cf.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/essex-cf.jpg b/public/assets/img/location-logos/chelmsford-logos/essex-cf.jpg new file mode 100644 index 0000000..0fc08b1 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/essex-cf.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/foodcycle.png b/public/assets/img/location-logos/chelmsford-logos/foodcycle.png new file mode 100644 index 0000000..e5ec3bf Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/foodcycle.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/heron-square.png b/public/assets/img/location-logos/chelmsford-logos/heron-square.png new file mode 100644 index 0000000..98bc825 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/heron-square.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/high-sheriff.jpg b/public/assets/img/location-logos/chelmsford-logos/high-sheriff.jpg new file mode 100644 index 0000000..3c446c6 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/high-sheriff.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/housing-dilemmas.jpg b/public/assets/img/location-logos/chelmsford-logos/housing-dilemmas.jpg new file mode 100644 index 0000000..7570497 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/housing-dilemmas.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/ideas-hub.jpg b/public/assets/img/location-logos/chelmsford-logos/ideas-hub.jpg new file mode 100644 index 0000000..8500896 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/ideas-hub.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/nightstop-essex.png b/public/assets/img/location-logos/chelmsford-logos/nightstop-essex.png new file mode 100644 index 0000000..052190d Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/nightstop-essex.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/open-road.png b/public/assets/img/location-logos/chelmsford-logos/open-road.png new file mode 100644 index 0000000..b51343e Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/open-road.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/peabody.png b/public/assets/img/location-logos/chelmsford-logos/peabody.png new file mode 100644 index 0000000..4841fa3 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/peabody.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/phoenix-futures.jpg b/public/assets/img/location-logos/chelmsford-logos/phoenix-futures.jpg new file mode 100644 index 0000000..fb1261e Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/phoenix-futures.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/pnc.jpg b/public/assets/img/location-logos/chelmsford-logos/pnc.jpg new file mode 100644 index 0000000..6d37d20 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/pnc.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/safer-chelmsford.jpg b/public/assets/img/location-logos/chelmsford-logos/safer-chelmsford.jpg new file mode 100644 index 0000000..24e9f40 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/safer-chelmsford.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/snctus.jpg b/public/assets/img/location-logos/chelmsford-logos/snctus.jpg new file mode 100644 index 0000000..af31124 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/snctus.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/streetfood.jpg b/public/assets/img/location-logos/chelmsford-logos/streetfood.jpg new file mode 100644 index 0000000..aa58abb Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/streetfood.jpg differ diff --git a/public/assets/img/location-logos/chelmsford-logos/the-change-project.png b/public/assets/img/location-logos/chelmsford-logos/the-change-project.png new file mode 100644 index 0000000..45e5a31 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/the-change-project.png differ diff --git a/public/assets/img/location-logos/chelmsford-logos/warm-hearts.jpg b/public/assets/img/location-logos/chelmsford-logos/warm-hearts.jpg new file mode 100644 index 0000000..1c03bf3 Binary files /dev/null and b/public/assets/img/location-logos/chelmsford-logos/warm-hearts.jpg differ diff --git a/public/assets/img/location-logos/coventry-logos/cia-logo.png b/public/assets/img/location-logos/coventry-logos/cia-logo.png new file mode 100644 index 0000000..0ff8110 Binary files /dev/null and b/public/assets/img/location-logos/coventry-logos/cia-logo.png differ diff --git a/public/assets/img/location-logos/coventry-logos/cov-city-council.png b/public/assets/img/location-logos/coventry-logos/cov-city-council.png new file mode 100644 index 0000000..3b494e6 Binary files /dev/null and b/public/assets/img/location-logos/coventry-logos/cov-city-council.png differ diff --git a/public/assets/img/location-logos/coventry-logos/crisis-coventry.png b/public/assets/img/location-logos/coventry-logos/crisis-coventry.png new file mode 100644 index 0000000..1e0ac5c Binary files /dev/null and b/public/assets/img/location-logos/coventry-logos/crisis-coventry.png differ diff --git a/public/assets/img/location-logos/coventry-logos/wmca.png b/public/assets/img/location-logos/coventry-logos/wmca.png new file mode 100644 index 0000000..4ab4891 Binary files /dev/null and b/public/assets/img/location-logos/coventry-logos/wmca.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/amber-valley.png b/public/assets/img/location-logos/derbyshire-logos/amber-valley.png new file mode 100644 index 0000000..6aec286 Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/amber-valley.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/bolsover.png b/public/assets/img/location-logos/derbyshire-logos/bolsover.png new file mode 100644 index 0000000..4688dca Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/bolsover.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/chesterfield.png b/public/assets/img/location-logos/derbyshire-logos/chesterfield.png new file mode 100644 index 0000000..a4b22d3 Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/chesterfield.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/derbyshire-dales.png b/public/assets/img/location-logos/derbyshire-logos/derbyshire-dales.png new file mode 100644 index 0000000..12e345c Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/derbyshire-dales.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/derbyshire-officers-group.png b/public/assets/img/location-logos/derbyshire-logos/derbyshire-officers-group.png new file mode 100644 index 0000000..e2941e3 Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/derbyshire-officers-group.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/erewash-council.png b/public/assets/img/location-logos/derbyshire-logos/erewash-council.png new file mode 100644 index 0000000..9f01aa7 Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/erewash-council.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/hp-borough-council.png b/public/assets/img/location-logos/derbyshire-logos/hp-borough-council.png new file mode 100644 index 0000000..e541ec4 Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/hp-borough-council.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/ne-derbyshire.png b/public/assets/img/location-logos/derbyshire-logos/ne-derbyshire.png new file mode 100644 index 0000000..6245570 Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/ne-derbyshire.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/south-derbyshire.png b/public/assets/img/location-logos/derbyshire-logos/south-derbyshire.png new file mode 100644 index 0000000..bd1ecd2 Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/south-derbyshire.png differ diff --git a/public/assets/img/location-logos/derbyshire-logos/staffordshire.png b/public/assets/img/location-logos/derbyshire-logos/staffordshire.png new file mode 100644 index 0000000..42be675 Binary files /dev/null and b/public/assets/img/location-logos/derbyshire-logos/staffordshire.png differ diff --git a/public/assets/img/location-logos/dudley-logos/dc.jpg b/public/assets/img/location-logos/dudley-logos/dc.jpg new file mode 100644 index 0000000..f88e93a Binary files /dev/null and b/public/assets/img/location-logos/dudley-logos/dc.jpg differ diff --git a/public/assets/img/location-logos/dudley-logos/dc.png b/public/assets/img/location-logos/dudley-logos/dc.png new file mode 100644 index 0000000..3a9e8e7 Binary files /dev/null and b/public/assets/img/location-logos/dudley-logos/dc.png differ diff --git a/public/assets/img/location-logos/edinburgh-logos/edin-uni.png b/public/assets/img/location-logos/edinburgh-logos/edin-uni.png new file mode 100644 index 0000000..42cd2e3 Binary files /dev/null and b/public/assets/img/location-logos/edinburgh-logos/edin-uni.png differ diff --git a/public/assets/img/location-logos/edinburgh-logos/edinburgh-council.png b/public/assets/img/location-logos/edinburgh-logos/edinburgh-council.png new file mode 100644 index 0000000..51719aa Binary files /dev/null and b/public/assets/img/location-logos/edinburgh-logos/edinburgh-council.png differ diff --git a/public/assets/img/location-logos/edinburgh-logos/simon-community.png b/public/assets/img/location-logos/edinburgh-logos/simon-community.png new file mode 100644 index 0000000..a4537cc Binary files /dev/null and b/public/assets/img/location-logos/edinburgh-logos/simon-community.png differ diff --git a/public/assets/img/location-logos/glasgow-logos/gcm.jpg b/public/assets/img/location-logos/glasgow-logos/gcm.jpg new file mode 100644 index 0000000..4535b48 Binary files /dev/null and b/public/assets/img/location-logos/glasgow-logos/gcm.jpg differ diff --git a/public/assets/img/location-logos/glasgow-logos/glasgow-change.png b/public/assets/img/location-logos/glasgow-logos/glasgow-change.png new file mode 100644 index 0000000..a78ea00 Binary files /dev/null and b/public/assets/img/location-logos/glasgow-logos/glasgow-change.png differ diff --git a/public/assets/img/location-logos/glasgow-logos/govan-glasgow.png b/public/assets/img/location-logos/glasgow-logos/govan-glasgow.png new file mode 100644 index 0000000..6ba2ea0 Binary files /dev/null and b/public/assets/img/location-logos/glasgow-logos/govan-glasgow.png differ diff --git a/public/assets/img/location-logos/glasgow-logos/lhm.png b/public/assets/img/location-logos/glasgow-logos/lhm.png new file mode 100644 index 0000000..ce9a295 Binary files /dev/null and b/public/assets/img/location-logos/glasgow-logos/lhm.png differ diff --git a/public/assets/img/location-logos/glasgow-logos/marie-trust.png b/public/assets/img/location-logos/glasgow-logos/marie-trust.png new file mode 100644 index 0000000..52201a3 Binary files /dev/null and b/public/assets/img/location-logos/glasgow-logos/marie-trust.png differ diff --git a/public/assets/img/location-logos/glasgow-logos/shelter-scotland.jpg b/public/assets/img/location-logos/glasgow-logos/shelter-scotland.jpg new file mode 100644 index 0000000..64fa1ce Binary files /dev/null and b/public/assets/img/location-logos/glasgow-logos/shelter-scotland.jpg differ diff --git a/public/assets/img/location-logos/glasgow-logos/waverley-cmyk.png b/public/assets/img/location-logos/glasgow-logos/waverley-cmyk.png new file mode 100644 index 0000000..ce7d9e1 Binary files /dev/null and b/public/assets/img/location-logos/glasgow-logos/waverley-cmyk.png differ diff --git a/public/assets/img/location-logos/leeds-logos/leedscharter.png b/public/assets/img/location-logos/leeds-logos/leedscharter.png new file mode 100644 index 0000000..427b6fb Binary files /dev/null and b/public/assets/img/location-logos/leeds-logos/leedscharter.png differ diff --git a/public/assets/img/location-logos/leeds-logos/leedscouncil.png b/public/assets/img/location-logos/leeds-logos/leedscouncil.png new file mode 100644 index 0000000..0e514b1 Binary files /dev/null and b/public/assets/img/location-logos/leeds-logos/leedscouncil.png differ diff --git a/public/assets/img/location-logos/leeds-logos/supportchange.png b/public/assets/img/location-logos/leeds-logos/supportchange.png new file mode 100644 index 0000000..c1ef013 Binary files /dev/null and b/public/assets/img/location-logos/leeds-logos/supportchange.png differ diff --git a/public/assets/img/location-logos/liverpool-logos/bg_2.jpg b/public/assets/img/location-logos/liverpool-logos/bg_2.jpg new file mode 100644 index 0000000..3a4dc63 Binary files /dev/null and b/public/assets/img/location-logos/liverpool-logos/bg_2.jpg differ diff --git a/public/assets/img/location-logos/liverpool-logos/change-liverpool-logo.png b/public/assets/img/location-logos/liverpool-logos/change-liverpool-logo.png new file mode 100644 index 0000000..8ac9665 Binary files /dev/null and b/public/assets/img/location-logos/liverpool-logos/change-liverpool-logo.png differ diff --git a/public/assets/img/location-logos/luton-logos/big-change-logo.png b/public/assets/img/location-logos/luton-logos/big-change-logo.png new file mode 100644 index 0000000..5fb6911 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/big-change-logo.png differ diff --git a/public/assets/img/location-logos/luton-logos/luton-aldwych.png b/public/assets/img/location-logos/luton-logos/luton-aldwych.png new file mode 100644 index 0000000..c2a2288 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-aldwych.png differ diff --git a/public/assets/img/location-logos/luton-logos/luton-beds.jpg b/public/assets/img/location-logos/luton-logos/luton-beds.jpg new file mode 100644 index 0000000..66e9319 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-beds.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-bid.jpg b/public/assets/img/location-logos/luton-logos/luton-bid.jpg new file mode 100644 index 0000000..d3e5741 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-bid.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-ca.png b/public/assets/img/location-logos/luton-logos/luton-ca.png new file mode 100644 index 0000000..202be24 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-ca.png differ diff --git a/public/assets/img/location-logos/luton-logos/luton-cvs.png b/public/assets/img/location-logos/luton-logos/luton-cvs.png new file mode 100644 index 0000000..d633114 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-cvs.png differ diff --git a/public/assets/img/location-logos/luton-logos/luton-homeless-partnership.jpg b/public/assets/img/location-logos/luton-logos/luton-homeless-partnership.jpg new file mode 100644 index 0000000..2494d96 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-homeless-partnership.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-keystone.png b/public/assets/img/location-logos/luton-logos/luton-keystone.png new file mode 100644 index 0000000..5d00d58 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-keystone.png differ diff --git a/public/assets/img/location-logos/luton-logos/luton-msha.jpg b/public/assets/img/location-logos/luton-logos/luton-msha.jpg new file mode 100644 index 0000000..994dc4e Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-msha.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-nhs.jpg b/public/assets/img/location-logos/luton-logos/luton-nhs.jpg new file mode 100644 index 0000000..bf012ee Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-nhs.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-noah.jpg b/public/assets/img/location-logos/luton-logos/luton-noah.jpg new file mode 100644 index 0000000..9a78d9b Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-noah.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-penrose.jpg b/public/assets/img/location-logos/luton-logos/luton-penrose.jpg new file mode 100644 index 0000000..0c5fc1b Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-penrose.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-resolutions.jpg b/public/assets/img/location-logos/luton-logos/luton-resolutions.jpg new file mode 100644 index 0000000..5c093b0 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-resolutions.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-signpost.jpg b/public/assets/img/location-logos/luton-logos/luton-signpost.jpg new file mode 100644 index 0000000..add1757 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-signpost.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-squared.jpg b/public/assets/img/location-logos/luton-logos/luton-squared.jpg new file mode 100644 index 0000000..c46a140 Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-squared.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-womens-aid.jpg b/public/assets/img/location-logos/luton-logos/luton-womens-aid.jpg new file mode 100644 index 0000000..f23920e Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-womens-aid.jpg differ diff --git a/public/assets/img/location-logos/luton-logos/luton-ymca.jpg b/public/assets/img/location-logos/luton-logos/luton-ymca.jpg new file mode 100644 index 0000000..2e5cbde Binary files /dev/null and b/public/assets/img/location-logos/luton-logos/luton-ymca.jpg differ diff --git a/public/assets/img/location-logos/manchester-logos/booth-centre.png b/public/assets/img/location-logos/manchester-logos/booth-centre.png new file mode 100644 index 0000000..28a0ef9 Binary files /dev/null and b/public/assets/img/location-logos/manchester-logos/booth-centre.png differ diff --git a/public/assets/img/location-logos/manchester-logos/bruntwood.png b/public/assets/img/location-logos/manchester-logos/bruntwood.png new file mode 100644 index 0000000..48b9e35 Binary files /dev/null and b/public/assets/img/location-logos/manchester-logos/bruntwood.png differ diff --git a/public/assets/img/location-logos/manchester-logos/cityco.png b/public/assets/img/location-logos/manchester-logos/cityco.png new file mode 100644 index 0000000..e28c1df Binary files /dev/null and b/public/assets/img/location-logos/manchester-logos/cityco.png differ diff --git a/public/assets/img/location-logos/manchester-logos/dentsu-aegis.png b/public/assets/img/location-logos/manchester-logos/dentsu-aegis.png new file mode 100644 index 0000000..1f61074 Binary files /dev/null and b/public/assets/img/location-logos/manchester-logos/dentsu-aegis.png differ diff --git a/public/assets/img/location-logos/manchester-logos/gm-mayors-fund.png b/public/assets/img/location-logos/manchester-logos/gm-mayors-fund.png new file mode 100644 index 0000000..7715733 Binary files /dev/null and b/public/assets/img/location-logos/manchester-logos/gm-mayors-fund.png differ diff --git a/public/assets/img/location-logos/manchester-logos/holy-name-manchester.png b/public/assets/img/location-logos/manchester-logos/holy-name-manchester.png new file mode 100644 index 0000000..a1ea5b5 Binary files /dev/null and b/public/assets/img/location-logos/manchester-logos/holy-name-manchester.png differ diff --git a/public/assets/img/location-logos/manchester-logos/human-appeal-logo.svg b/public/assets/img/location-logos/manchester-logos/human-appeal-logo.svg new file mode 100644 index 0000000..f7cfff2 --- /dev/null +++ b/public/assets/img/location-logos/manchester-logos/human-appeal-logo.svg @@ -0,0 +1 @@ +Artboard 1 \ No newline at end of file diff --git a/public/assets/img/location-logos/manchester-logos/manchester-city-council.png b/public/assets/img/location-logos/manchester-logos/manchester-city-council.png new file mode 100644 index 0000000..ef792b8 Binary files /dev/null and b/public/assets/img/location-logos/manchester-logos/manchester-city-council.png differ diff --git a/public/assets/img/location-logos/manchester-logos/mdc.png b/public/assets/img/location-logos/manchester-logos/mdc.png new file mode 100644 index 0000000..a90f215 Binary files /dev/null and b/public/assets/img/location-logos/manchester-logos/mdc.png differ diff --git a/public/assets/img/location-logos/manchester-logos/one-agency.png b/public/assets/img/location-logos/manchester-logos/one-agency.png new file mode 100644 index 0000000..da3bf6f Binary files /dev/null and b/public/assets/img/location-logos/manchester-logos/one-agency.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/aht.jpg b/public/assets/img/location-logos/nottingham-logos/aht.jpg new file mode 100644 index 0000000..3dccb87 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/aht.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/arimathea-trust.jpg b/public/assets/img/location-logos/nottingham-logos/arimathea-trust.jpg new file mode 100644 index 0000000..761a0ce Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/arimathea-trust.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/base-51.jpg b/public/assets/img/location-logos/nottingham-logos/base-51.jpg new file mode 100644 index 0000000..fb14c50 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/base-51.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/boots.png b/public/assets/img/location-logos/nottingham-logos/boots.png new file mode 100644 index 0000000..9c44d01 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/boots.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/coe.jpg b/public/assets/img/location-logos/nottingham-logos/coe.jpg new file mode 100644 index 0000000..a96b087 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/coe.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/emmanuel-house.png b/public/assets/img/location-logos/nottingham-logos/emmanuel-house.png new file mode 100644 index 0000000..cdc4b59 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/emmanuel-house.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/hope-into-action.jpg b/public/assets/img/location-logos/nottingham-logos/hope-into-action.jpg new file mode 100644 index 0000000..548fca0 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/hope-into-action.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/host-nottingham.png b/public/assets/img/location-logos/nottingham-logos/host-nottingham.png new file mode 100644 index 0000000..501a448 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/host-nottingham.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/jericho-road.jpg b/public/assets/img/location-logos/nottingham-logos/jericho-road.jpg new file mode 100644 index 0000000..bf32f6f Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/jericho-road.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/lottery.png b/public/assets/img/location-logos/nottingham-logos/lottery.png new file mode 100644 index 0000000..7663e2c Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/lottery.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/night-shelter.png b/public/assets/img/location-logos/nottingham-logos/night-shelter.png new file mode 100644 index 0000000..bb511b8 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/night-shelter.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/nottingham-bid.eps b/public/assets/img/location-logos/nottingham-logos/nottingham-bid.eps new file mode 100644 index 0000000..5a6ee7e Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/nottingham-bid.eps differ diff --git a/public/assets/img/location-logos/nottingham-logos/nottingham-bid.png b/public/assets/img/location-logos/nottingham-logos/nottingham-bid.png new file mode 100644 index 0000000..ed9f161 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/nottingham-bid.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/nottingham-building-society.png b/public/assets/img/location-logos/nottingham-logos/nottingham-building-society.png new file mode 100644 index 0000000..eb92c7c Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/nottingham-building-society.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/nottingham-street-aid.png b/public/assets/img/location-logos/nottingham-logos/nottingham-street-aid.png new file mode 100644 index 0000000..edb0760 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/nottingham-street-aid.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/notts-city-co.png b/public/assets/img/location-logos/nottingham-logos/notts-city-co.png new file mode 100644 index 0000000..8299573 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/notts-city-co.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/notts-logos.png b/public/assets/img/location-logos/nottingham-logos/notts-logos.png new file mode 100644 index 0000000..c71aeaa Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/notts-logos.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/openhomes.jpg b/public/assets/img/location-logos/nottingham-logos/openhomes.jpg new file mode 100644 index 0000000..af9a3f6 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/openhomes.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/refugee-forum.jpg b/public/assets/img/location-logos/nottingham-logos/refugee-forum.jpg new file mode 100644 index 0000000..31209fd Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/refugee-forum.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/salvation-army.gif b/public/assets/img/location-logos/nottingham-logos/salvation-army.gif new file mode 100644 index 0000000..ed75352 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/salvation-army.gif differ diff --git a/public/assets/img/location-logos/nottingham-logos/sea.jpg b/public/assets/img/location-logos/nottingham-logos/sea.jpg new file mode 100644 index 0000000..a0b37d1 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/sea.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/street-aid-diagram.jpg b/public/assets/img/location-logos/nottingham-logos/street-aid-diagram.jpg new file mode 100644 index 0000000..aa250d4 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/street-aid-diagram.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/street-pastors.jpg b/public/assets/img/location-logos/nottingham-logos/street-pastors.jpg new file mode 100644 index 0000000..b8ad633 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/street-pastors.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/the-arches.png b/public/assets/img/location-logos/nottingham-logos/the-arches.png new file mode 100644 index 0000000..ebca526 Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/the-arches.png differ diff --git a/public/assets/img/location-logos/nottingham-logos/the-friary.jpg b/public/assets/img/location-logos/nottingham-logos/the-friary.jpg new file mode 100644 index 0000000..e8653ff Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/the-friary.jpg differ diff --git a/public/assets/img/location-logos/nottingham-logos/ymca.jpg b/public/assets/img/location-logos/nottingham-logos/ymca.jpg new file mode 100644 index 0000000..5c399db Binary files /dev/null and b/public/assets/img/location-logos/nottingham-logos/ymca.jpg differ diff --git a/public/assets/img/location-logos/reading-logos/B4N.jpg b/public/assets/img/location-logos/reading-logos/B4N.jpg new file mode 100644 index 0000000..8d4948a Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/B4N.jpg differ diff --git a/public/assets/img/location-logos/reading-logos/RBC.jpg b/public/assets/img/location-logos/reading-logos/RBC.jpg new file mode 100644 index 0000000..f6ca092 Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/RBC.jpg differ diff --git a/public/assets/img/location-logos/reading-logos/citizens-advice.svg b/public/assets/img/location-logos/reading-logos/citizens-advice.svg new file mode 100644 index 0000000..6bf4fa4 --- /dev/null +++ b/public/assets/img/location-logos/reading-logos/citizens-advice.svg @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/img/location-logos/reading-logos/faith.jpg b/public/assets/img/location-logos/reading-logos/faith.jpg new file mode 100644 index 0000000..fb38e77 Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/faith.jpg differ diff --git a/public/assets/img/location-logos/reading-logos/faith2.jpg b/public/assets/img/location-logos/reading-logos/faith2.jpg new file mode 100644 index 0000000..4f25ec0 Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/faith2.jpg differ diff --git a/public/assets/img/location-logos/reading-logos/launchpad.png b/public/assets/img/location-logos/reading-logos/launchpad.png new file mode 100644 index 0000000..1c0ab80 Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/launchpad.png differ diff --git a/public/assets/img/location-logos/reading-logos/meam.png b/public/assets/img/location-logos/reading-logos/meam.png new file mode 100644 index 0000000..1c16ff1 Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/meam.png differ diff --git a/public/assets/img/location-logos/reading-logos/mungos.jpg b/public/assets/img/location-logos/reading-logos/mungos.jpg new file mode 100644 index 0000000..3161deb Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/mungos.jpg differ diff --git a/public/assets/img/location-logos/reading-logos/nhs-holt.png b/public/assets/img/location-logos/reading-logos/nhs-holt.png new file mode 100644 index 0000000..80ad56d Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/nhs-holt.png differ diff --git a/public/assets/img/location-logos/reading-logos/penta.jpg b/public/assets/img/location-logos/reading-logos/penta.jpg new file mode 100644 index 0000000..c827c2a Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/penta.jpg differ diff --git a/public/assets/img/location-logos/reading-logos/reading-communicare.jpg b/public/assets/img/location-logos/reading-logos/reading-communicare.jpg new file mode 100644 index 0000000..fb7475b Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/reading-communicare.jpg differ diff --git a/public/assets/img/location-logos/reading-logos/reading-foodbank.png b/public/assets/img/location-logos/reading-logos/reading-foodbank.png new file mode 100644 index 0000000..6477170 Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/reading-foodbank.png differ diff --git a/public/assets/img/location-logos/reading-logos/reading-healthwatch.png b/public/assets/img/location-logos/reading-logos/reading-healthwatch.png new file mode 100644 index 0000000..2b696b3 Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/reading-healthwatch.png differ diff --git a/public/assets/img/location-logos/reading-logos/readistreet.gif b/public/assets/img/location-logos/reading-logos/readistreet.gif new file mode 100644 index 0000000..0d44fba Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/readistreet.gif differ diff --git a/public/assets/img/location-logos/reading-logos/rehab.png b/public/assets/img/location-logos/reading-logos/rehab.png new file mode 100644 index 0000000..fa718f7 Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/rehab.png differ diff --git a/public/assets/img/location-logos/reading-logos/sadaka.png b/public/assets/img/location-logos/reading-logos/sadaka.png new file mode 100644 index 0000000..b582ee2 Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/sadaka.png differ diff --git a/public/assets/img/location-logos/reading-logos/salvation-army.gif b/public/assets/img/location-logos/reading-logos/salvation-army.gif new file mode 100644 index 0000000..5a434ca Binary files /dev/null and b/public/assets/img/location-logos/reading-logos/salvation-army.gif differ diff --git a/public/assets/img/location-logos/real-change-banner/bg-triangle-bottom-mobile.png b/public/assets/img/location-logos/real-change-banner/bg-triangle-bottom-mobile.png new file mode 100644 index 0000000..7d7b58d Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/bg-triangle-bottom-mobile.png differ diff --git a/public/assets/img/location-logos/real-change-banner/bg-triangle-top-mobile.png b/public/assets/img/location-logos/real-change-banner/bg-triangle-top-mobile.png new file mode 100644 index 0000000..8f2b44c Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/bg-triangle-top-mobile.png differ diff --git a/public/assets/img/location-logos/real-change-banner/oldham/bg-desktop.png b/public/assets/img/location-logos/real-change-banner/oldham/bg-desktop.png new file mode 100644 index 0000000..2f6baea Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/oldham/bg-desktop.png differ diff --git a/public/assets/img/location-logos/real-change-banner/oldham/logo.png b/public/assets/img/location-logos/real-change-banner/oldham/logo.png new file mode 100644 index 0000000..d8222ad Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/oldham/logo.png differ diff --git a/public/assets/img/location-logos/real-change-banner/oldham/title-desktop.png b/public/assets/img/location-logos/real-change-banner/oldham/title-desktop.png new file mode 100644 index 0000000..43a4ff7 Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/oldham/title-desktop.png differ diff --git a/public/assets/img/location-logos/real-change-banner/oldham/title-mobile.png b/public/assets/img/location-logos/real-change-banner/oldham/title-mobile.png new file mode 100644 index 0000000..0a87114 Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/oldham/title-mobile.png differ diff --git a/public/assets/img/location-logos/real-change-banner/rochdale/bg-desktop.png b/public/assets/img/location-logos/real-change-banner/rochdale/bg-desktop.png new file mode 100644 index 0000000..481841e Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/rochdale/bg-desktop.png differ diff --git a/public/assets/img/location-logos/real-change-banner/rochdale/logo.png b/public/assets/img/location-logos/real-change-banner/rochdale/logo.png new file mode 100644 index 0000000..17c764b Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/rochdale/logo.png differ diff --git a/public/assets/img/location-logos/real-change-banner/rochdale/title-desktop.png b/public/assets/img/location-logos/real-change-banner/rochdale/title-desktop.png new file mode 100644 index 0000000..a8e51e6 Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/rochdale/title-desktop.png differ diff --git a/public/assets/img/location-logos/real-change-banner/rochdale/title-mobile.png b/public/assets/img/location-logos/real-change-banner/rochdale/title-mobile.png new file mode 100644 index 0000000..fef2d2f Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/rochdale/title-mobile.png differ diff --git a/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/bg-desktop.png b/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/bg-desktop.png new file mode 100644 index 0000000..ae353b9 Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/bg-desktop.png differ diff --git a/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/logo.png b/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/logo.png new file mode 100644 index 0000000..7f37dda Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/logo.png differ diff --git a/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/title-desktop.png b/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/title-desktop.png new file mode 100644 index 0000000..74cc314 Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/title-desktop.png differ diff --git a/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/title-mobile.png b/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/title-mobile.png new file mode 100644 index 0000000..5db705d Binary files /dev/null and b/public/assets/img/location-logos/real-change-banner/wigan-and-leigh/title-mobile.png differ diff --git a/public/assets/img/location-logos/resource-icons/alternative-giving-icon.png b/public/assets/img/location-logos/resource-icons/alternative-giving-icon.png new file mode 100644 index 0000000..f621251 Binary files /dev/null and b/public/assets/img/location-logos/resource-icons/alternative-giving-icon.png differ diff --git a/public/assets/img/location-logos/resource-icons/branding-icon.png b/public/assets/img/location-logos/resource-icons/branding-icon.png new file mode 100644 index 0000000..b8a4db1 Binary files /dev/null and b/public/assets/img/location-logos/resource-icons/branding-icon.png differ diff --git a/public/assets/img/location-logos/resource-icons/charters-icon.png b/public/assets/img/location-logos/resource-icons/charters-icon.png new file mode 100644 index 0000000..6fe4576 Binary files /dev/null and b/public/assets/img/location-logos/resource-icons/charters-icon.png differ diff --git a/public/assets/img/location-logos/resource-icons/marketing-icon.png b/public/assets/img/location-logos/resource-icons/marketing-icon.png new file mode 100644 index 0000000..f1af328 Binary files /dev/null and b/public/assets/img/location-logos/resource-icons/marketing-icon.png differ diff --git a/public/assets/img/location-logos/resource-icons/partnership-comms-icon.png b/public/assets/img/location-logos/resource-icons/partnership-comms-icon.png new file mode 100644 index 0000000..44d88f2 Binary files /dev/null and b/public/assets/img/location-logos/resource-icons/partnership-comms-icon.png differ diff --git a/public/assets/img/location-logos/resource-icons/street-feeding-icon.png b/public/assets/img/location-logos/resource-icons/street-feeding-icon.png new file mode 100644 index 0000000..d0fabad Binary files /dev/null and b/public/assets/img/location-logos/resource-icons/street-feeding-icon.png differ diff --git a/public/assets/img/location-logos/resource-icons/user-guides.png b/public/assets/img/location-logos/resource-icons/user-guides.png new file mode 100644 index 0000000..0db8e84 Binary files /dev/null and b/public/assets/img/location-logos/resource-icons/user-guides.png differ diff --git a/public/assets/img/location-logos/resource-icons/volunteering-icon.png b/public/assets/img/location-logos/resource-icons/volunteering-icon.png new file mode 100644 index 0000000..4bb51ad Binary files /dev/null and b/public/assets/img/location-logos/resource-icons/volunteering-icon.png differ diff --git a/public/assets/img/location-logos/sandwell-logos/sandwell-council.png b/public/assets/img/location-logos/sandwell-logos/sandwell-council.png new file mode 100644 index 0000000..26d090e Binary files /dev/null and b/public/assets/img/location-logos/sandwell-logos/sandwell-council.png differ diff --git a/public/assets/img/location-logos/sandwell-logos/sandwell-foodbank.png b/public/assets/img/location-logos/sandwell-logos/sandwell-foodbank.png new file mode 100644 index 0000000..4a91624 Binary files /dev/null and b/public/assets/img/location-logos/sandwell-logos/sandwell-foodbank.png differ diff --git a/public/assets/img/location-logos/solihull-logos/scah.jpg b/public/assets/img/location-logos/solihull-logos/scah.jpg new file mode 100644 index 0000000..37ed3a1 Binary files /dev/null and b/public/assets/img/location-logos/solihull-logos/scah.jpg differ diff --git a/public/assets/img/location-logos/solihull-logos/sol-foodbank.png b/public/assets/img/location-logos/solihull-logos/sol-foodbank.png new file mode 100644 index 0000000..815f5da Binary files /dev/null and b/public/assets/img/location-logos/solihull-logos/sol-foodbank.png differ diff --git a/public/assets/img/location-logos/solihull-logos/solihull-ca.png b/public/assets/img/location-logos/solihull-logos/solihull-ca.png new file mode 100644 index 0000000..2e8160f Binary files /dev/null and b/public/assets/img/location-logos/solihull-logos/solihull-ca.png differ diff --git a/public/assets/img/location-logos/solihull-logos/solihull-council.png b/public/assets/img/location-logos/solihull-logos/solihull-council.png new file mode 100644 index 0000000..3f5eb0c Binary files /dev/null and b/public/assets/img/location-logos/solihull-logos/solihull-council.png differ diff --git a/public/assets/img/location-logos/southampton-logos/change-the-way-you-give.jpg b/public/assets/img/location-logos/southampton-logos/change-the-way-you-give.jpg new file mode 100644 index 0000000..5644343 Binary files /dev/null and b/public/assets/img/location-logos/southampton-logos/change-the-way-you-give.jpg differ diff --git a/public/assets/img/location-logos/southampton-logos/citylife-church.png b/public/assets/img/location-logos/southampton-logos/citylife-church.png new file mode 100644 index 0000000..c80798b Binary files /dev/null and b/public/assets/img/location-logos/southampton-logos/citylife-church.png differ diff --git a/public/assets/img/location-logos/southampton-logos/go-southampton.png b/public/assets/img/location-logos/southampton-logos/go-southampton.png new file mode 100644 index 0000000..ae85d41 Binary files /dev/null and b/public/assets/img/location-logos/southampton-logos/go-southampton.png differ diff --git a/public/assets/img/location-logos/southampton-logos/oasis.png b/public/assets/img/location-logos/southampton-logos/oasis.png new file mode 100644 index 0000000..21fd0ed Binary files /dev/null and b/public/assets/img/location-logos/southampton-logos/oasis.png differ diff --git a/public/assets/img/location-logos/southampton-logos/rotary-southampton.png b/public/assets/img/location-logos/southampton-logos/rotary-southampton.png new file mode 100644 index 0000000..ed2f7fd Binary files /dev/null and b/public/assets/img/location-logos/southampton-logos/rotary-southampton.png differ diff --git a/public/assets/img/location-logos/southampton-logos/society-of-james.png b/public/assets/img/location-logos/southampton-logos/society-of-james.png new file mode 100644 index 0000000..959eafd Binary files /dev/null and b/public/assets/img/location-logos/southampton-logos/society-of-james.png differ diff --git a/public/assets/img/location-logos/southampton-logos/southampton-city-council.png b/public/assets/img/location-logos/southampton-logos/southampton-city-council.png new file mode 100644 index 0000000..914f7e2 Binary files /dev/null and b/public/assets/img/location-logos/southampton-logos/southampton-city-council.png differ diff --git a/public/assets/img/location-logos/southampton-logos/southampton-voluntary-services.png b/public/assets/img/location-logos/southampton-logos/southampton-voluntary-services.png new file mode 100644 index 0000000..880c987 Binary files /dev/null and b/public/assets/img/location-logos/southampton-logos/southampton-voluntary-services.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/How can I make the most impact with my volunteering.png b/public/assets/img/location-logos/vfg-imgs/How can I make the most impact with my volunteering.png new file mode 100644 index 0000000..b6730ca Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/How can I make the most impact with my volunteering.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/How can my company volunteer effectively.png b/public/assets/img/location-logos/vfg-imgs/How can my company volunteer effectively.png new file mode 100644 index 0000000..396f353 Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/How can my company volunteer effectively.png differ diff --git "a/public/assets/img/location-logos/vfg-imgs/What if long-term volunteering isn\342\200\231t realistic for me or my company.png" "b/public/assets/img/location-logos/vfg-imgs/What if long-term volunteering isn\342\200\231t realistic for me or my company.png" new file mode 100644 index 0000000..39704fe Binary files /dev/null and "b/public/assets/img/location-logos/vfg-imgs/What if long-term volunteering isn\342\200\231t realistic for me or my company.png" differ diff --git a/public/assets/img/location-logos/vfg-imgs/c4c.png b/public/assets/img/location-logos/vfg-imgs/c4c.png new file mode 100644 index 0000000..09fd957 Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/c4c.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/company.png b/public/assets/img/location-logos/vfg-imgs/company.png new file mode 100644 index 0000000..549e828 Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/company.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/different-skills.png b/public/assets/img/location-logos/vfg-imgs/different-skills.png new file mode 100644 index 0000000..d876ac4 Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/different-skills.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/egg.png b/public/assets/img/location-logos/vfg-imgs/egg.png new file mode 100644 index 0000000..c117751 Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/egg.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/greatertogethermanchester.png b/public/assets/img/location-logos/vfg-imgs/greatertogethermanchester.png new file mode 100644 index 0000000..329399f Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/greatertogethermanchester.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/independent-choices.png b/public/assets/img/location-logos/vfg-imgs/independent-choices.png new file mode 100644 index 0000000..0364369 Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/independent-choices.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/loavesandfishes.png b/public/assets/img/location-logos/vfg-imgs/loavesandfishes.png new file mode 100644 index 0000000..5f5a5b3 Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/loavesandfishes.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/what-is.png b/public/assets/img/location-logos/vfg-imgs/what-is.png new file mode 100644 index 0000000..a34124e Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/what-is.png differ diff --git a/public/assets/img/location-logos/vfg-imgs/why-sector.png b/public/assets/img/location-logos/vfg-imgs/why-sector.png new file mode 100644 index 0000000..c9ee1ec Binary files /dev/null and b/public/assets/img/location-logos/vfg-imgs/why-sector.png differ diff --git a/public/assets/img/location-logos/wakefield-logos/iic.png b/public/assets/img/location-logos/wakefield-logos/iic.png new file mode 100644 index 0000000..8c43006 Binary files /dev/null and b/public/assets/img/location-logos/wakefield-logos/iic.png differ diff --git a/public/assets/img/location-logos/wakefield-logos/safer-together.png b/public/assets/img/location-logos/wakefield-logos/safer-together.png new file mode 100644 index 0000000..c9a3355 Binary files /dev/null and b/public/assets/img/location-logos/wakefield-logos/safer-together.png differ diff --git a/public/assets/img/location-logos/wakefield-logos/ssn.png b/public/assets/img/location-logos/wakefield-logos/ssn.png new file mode 100644 index 0000000..0890101 Binary files /dev/null and b/public/assets/img/location-logos/wakefield-logos/ssn.png differ diff --git a/public/assets/img/location-logos/walsall-logos/wal-council.png b/public/assets/img/location-logos/walsall-logos/wal-council.png new file mode 100644 index 0000000..b5dd8a2 Binary files /dev/null and b/public/assets/img/location-logos/walsall-logos/wal-council.png differ diff --git a/public/assets/img/location-logos/walsall-logos/wal-rmc.png b/public/assets/img/location-logos/walsall-logos/wal-rmc.png new file mode 100644 index 0000000..8cefd15 Binary files /dev/null and b/public/assets/img/location-logos/walsall-logos/wal-rmc.png differ diff --git a/public/assets/img/location-logos/walsall-logos/whg.png b/public/assets/img/location-logos/walsall-logos/whg.png new file mode 100644 index 0000000..0e7a752 Binary files /dev/null and b/public/assets/img/location-logos/walsall-logos/whg.png differ diff --git a/public/assets/img/location-logos/wolverhampton-logos/agc.png b/public/assets/img/location-logos/wolverhampton-logos/agc.png new file mode 100644 index 0000000..643fc20 Binary files /dev/null and b/public/assets/img/location-logos/wolverhampton-logos/agc.png differ diff --git a/public/assets/img/location-logos/wolverhampton-logos/crisis-wolverhampton.png b/public/assets/img/location-logos/wolverhampton-logos/crisis-wolverhampton.png new file mode 100644 index 0000000..1e0ac5c Binary files /dev/null and b/public/assets/img/location-logos/wolverhampton-logos/crisis-wolverhampton.png differ diff --git a/public/assets/img/location-logos/wolverhampton-logos/wmca.png b/public/assets/img/location-logos/wolverhampton-logos/wmca.png new file mode 100644 index 0000000..4ab4891 Binary files /dev/null and b/public/assets/img/location-logos/wolverhampton-logos/wmca.png differ diff --git a/public/assets/img/location-logos/wolverhampton-logos/wolves-city-co.png b/public/assets/img/location-logos/wolverhampton-logos/wolves-city-co.png new file mode 100644 index 0000000..0286c3d Binary files /dev/null and b/public/assets/img/location-logos/wolverhampton-logos/wolves-city-co.png differ diff --git a/public/assets/img/resource-icons/alternative-giving-icon.png b/public/assets/img/resource-icons/alternative-giving-icon.png new file mode 100644 index 0000000..b75979b Binary files /dev/null and b/public/assets/img/resource-icons/alternative-giving-icon.png differ diff --git a/public/assets/img/resource-icons/branding-icon.png b/public/assets/img/resource-icons/branding-icon.png new file mode 100644 index 0000000..3546ba9 Binary files /dev/null and b/public/assets/img/resource-icons/branding-icon.png differ diff --git a/public/assets/img/resource-icons/charters-icon.png b/public/assets/img/resource-icons/charters-icon.png new file mode 100644 index 0000000..7881382 Binary files /dev/null and b/public/assets/img/resource-icons/charters-icon.png differ diff --git a/public/assets/img/resource-icons/marketing-icon.png b/public/assets/img/resource-icons/marketing-icon.png new file mode 100644 index 0000000..a01098b Binary files /dev/null and b/public/assets/img/resource-icons/marketing-icon.png differ diff --git a/public/assets/img/resource-icons/partnership-comms-icon.png b/public/assets/img/resource-icons/partnership-comms-icon.png new file mode 100644 index 0000000..c6204f5 Binary files /dev/null and b/public/assets/img/resource-icons/partnership-comms-icon.png differ diff --git a/public/assets/img/resource-icons/streetfeeding-icon.png b/public/assets/img/resource-icons/streetfeeding-icon.png new file mode 100644 index 0000000..f3c01eb Binary files /dev/null and b/public/assets/img/resource-icons/streetfeeding-icon.png differ diff --git a/public/assets/img/resource-icons/user-guides-icon.png b/public/assets/img/resource-icons/user-guides-icon.png new file mode 100644 index 0000000..1743262 Binary files /dev/null and b/public/assets/img/resource-icons/user-guides-icon.png differ diff --git a/public/assets/img/resource-icons/volunteering-icon.png b/public/assets/img/resource-icons/volunteering-icon.png new file mode 100644 index 0000000..f2df899 Binary files /dev/null and b/public/assets/img/resource-icons/volunteering-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000..fa00aa8 Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000..fa00aa8 Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..8953ecf Binary files /dev/null and b/public/favicon.ico differ diff --git a/src/app/advice/[id]/edit/page.tsx b/src/app/advice/[id]/edit/page.tsx new file mode 100644 index 0000000..229045a --- /dev/null +++ b/src/app/advice/[id]/edit/page.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { useAuthorization } from '@/hooks/useAuthorization'; +import { ROLES } from '@/constants/roles'; +import { useSession } from 'next-auth/react'; +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { errorToast, successToast } from '@/utils/toast'; +import { validateFaq, transformErrorPath } from '@/schemas/faqSchema'; +import { Button } from '@/components/ui/Button'; +import { ErrorState } from '@/components/ui/ErrorState'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import { RichTextEditor } from '@/components/ui/RichTextEditor'; +import ErrorDisplay from '@/components/ui/ErrorDisplay'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { FormField } from '@/components/ui/FormField'; +import { useBreadcrumb } from '@/contexts/BreadcrumbContext'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { redirectToNotFound } from '@/utils/navigation'; + +interface ValidationError { + Path: string; + Message: string; +} + +export default function AdviceEditPage() { + const params = useParams(); + const router = useRouter(); + const id = params.id as string; + + // Check authorization FIRST + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.VOLUNTEER_ADMIN], + requiredPage: '/advice', + autoRedirect: true + }); + + const { data: session } = useSession(); + const userRoles = session?.user?.authClaims?.roles || []; + const canAccessGeneralAdvice = userRoles.includes(ROLES.SUPER_ADMIN) || userRoles.includes(ROLES.VOLUNTEER_ADMIN); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + const [showCancelModal, setShowCancelModal] = useState(false); + const [locations, setLocations] = useState>([]); + const [editorResetKey, setEditorResetKey] = useState(0); + const { setAdviceTitle } = useBreadcrumb(); + + // Form state + const [formData, setFormData] = useState({ + LocationKey: '', + Title: '', + Body: '', + SortPosition: 0, + }); + + const [originalData, setOriginalData] = useState(formData); + + useEffect(() => { + fetchLocations(); + }, []); + + useEffect(() => { + if (isAuthorized && id) { + fetchAdvice(); + } + + // Cleanup: Clear advice title when component unmounts + return () => { + setAdviceTitle(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthorized, id]); + + const fetchLocations = async () => { + try { + const response = await authenticatedFetch('/api/cities'); + + if (response.ok) { + const data = await response.json(); + setLocations(data.data || []); + } + } catch (err) { + console.error('Failed to fetch locations:', err); + } + }; + + const fetchAdvice = async () => { + let redirected = false; + + try { + setLoading(true); + const response = await authenticatedFetch(`/api/advice/${id}`); + const responseData = await response.json(); + + if (!response.ok) { + if (redirectToNotFound(response, router)) { + redirected = true; + return; + } + + throw new Error(responseData.error || 'Failed to fetch advice'); + } + + const data = responseData.data || responseData; + + const initialFormData = { + LocationKey: data.LocationKey || '', + Title: data.Title || '', + Body: data.Body || '', + SortPosition: data.SortPosition || 0, + }; + + setFormData(initialFormData); + setOriginalData(initialFormData); + setAdviceTitle(data.Title); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load advice'; + setError(errorMessage); + errorToast.generic(errorMessage); + } finally { + if (!redirected) { + setLoading(false); + } + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate form data + const validation = validateFaq(formData); + if (!validation.success) { + const errors = validation.errors.map(err => ({ + Path: transformErrorPath(err.path), + Message: err.message + })); + setValidationErrors(errors); + errorToast.validation(); + return; + } + + try { + setSaving(true); + setValidationErrors([]); + + const response = await authenticatedFetch(`/api/advice/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to update advice'); + } + + successToast.update('Advice'); + router.push(`/advice/${id}`); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update advice'; + errorToast.generic(errorMessage); + } finally { + setSaving(false); + } + }; + + const handleCancel = () => { + if (JSON.stringify(formData) !== JSON.stringify(originalData)) { + setShowCancelModal(true); + } else { + router.push(`/advice/${id}`); + } + }; + + const confirmCancel = () => { + // Revert to original data + if (originalData) { + setFormData(JSON.parse(JSON.stringify(originalData))); + setValidationErrors([]); + setEditorResetKey(prev => prev + 1); // Force editor remount + } + setShowCancelModal(false); + }; + + const hasChanges = JSON.stringify(formData) !== JSON.stringify(originalData); + + // Show loading while checking authorization or fetching data + if (isChecking || loading) { + return ; + } + + // Don't render anything if not authorized (redirect handled by hook) + if (!isAuthorized) { + return null; + } + + // Show error if fetch failed + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+
+ {/* All Form Fields in One Section */} +
+
+ {/* Title */} + + setFormData({ ...formData, Title: e.target.value })} + placeholder="Enter advice title..." + /> + + + {/* Location Select */} + + setFormData({ ...formData, SortPosition: parseInt(e.target.value) || 0 })} + /> + + + {/* Rich Text Editor */} +
+ setFormData({ ...formData, Body: value })} + placeholder="Enter the advice content..." + minHeight="400px" + required + resetKey={editorResetKey} + /> +
+ + {/* Validation Errors */} + {validationErrors.length > 0 && ( +
+ +
+ )} +
+ + {/* Action Buttons */} +
+ + +
+
+
+
+ + {/* Cancel Confirmation Modal */} + setShowCancelModal(false)} + onConfirm={confirmCancel} + title="Close without saving?" + message="You may lose unsaved changes." + variant="warning" + confirmLabel="Discard changes" + cancelLabel="Continue Editing" + /> +
+ ); +} diff --git a/src/app/advice/[id]/page.tsx b/src/app/advice/[id]/page.tsx new file mode 100644 index 0000000..05e6a67 --- /dev/null +++ b/src/app/advice/[id]/page.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useAuthorization } from '@/hooks/useAuthorization'; +import { ROLES } from '@/constants/roles'; +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { IFaq } from '@/types/IFaq'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { errorToast, successToast } from '@/utils/toast'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { AdvicePageActions } from '@/components/advice/AdvicePageActions'; +import { Calendar, MapPin } from 'lucide-react'; +import { sanitizeHtmlForDisplay } from '@/components/ui/RichTextEditor'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { useBreadcrumb } from '@/contexts/BreadcrumbContext'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { ErrorState } from '@/components/ui/ErrorState'; +import { redirectToNotFound } from '@/utils/navigation'; + +export default function AdviceViewPage() { + const params = useParams(); + const router = useRouter(); + const id = params.id as string; + + // Check authorization FIRST + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.VOLUNTEER_ADMIN], + requiredPage: '/advice', + autoRedirect: true + }); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [advice, setAdvice] = useState(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleting, setDeleting] = useState(false); + const [locations, setLocations] = useState>([]); + const { setAdviceTitle } = useBreadcrumb(); + + useEffect(() => { + fetchLocations(); + }, []); + + useEffect(() => { + if (isAuthorized && id) { + fetchAdvice(); + } + + // Cleanup: Clear advice title when component unmounts + return () => { + setAdviceTitle(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthorized, id]); + + const fetchLocations = async () => { + try { + const response = await authenticatedFetch('/api/cities'); + + if (response.ok) { + const data = await response.json(); + setLocations(data.data || []); + } + } catch (err) { + console.error('Failed to fetch locations:', err); + } + }; + + const fetchAdvice = async () => { + let redirected = false; + + try { + setLoading(true); + const response = await authenticatedFetch(`/api/advice/${id}`); + const data = await response.json(); + + if (!response.ok) { + if (redirectToNotFound(response, router)) { + redirected = true; + return; + } + + throw new Error(data.error || 'Failed to fetch advice'); + } + + const adviceData = data.data || data; + setAdvice(adviceData); + setAdviceTitle(adviceData.Title); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load advice'; + setError(errorMessage); + errorToast.generic(errorMessage); + } finally { + if (!redirected) { + setLoading(false); + } + } + }; + + const handleDelete = async () => { + if (!advice) return; + + try { + setDeleting(true); + const response = await authenticatedFetch(`/api/advice/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to delete advice'); + } + + successToast.delete('Advice'); + router.push('/advice'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to delete advice'; + errorToast.generic(errorMessage); + setDeleting(false); + setShowDeleteModal(false); + } + }; + + const formatDate = (date: Date | string): string => { + const d = new Date(date); + return d.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric' + }); + }; + + const getLocationName = (locationKey: string): string => { + if (locationKey === 'general') return 'General Advice'; + const location = locations.find(loc => loc.Key === locationKey); + return location?.Name || locationKey; + }; + + // Show loading while checking authorization or fetching data + if (isChecking || loading) { + return ; + } + + // Don't render anything if not authorized (redirect handled by hook) + if (!isAuthorized) { + return null; + } + + // Show error if fetch failed + if (error || !advice) { + return ( +
+ +
+ ); + } + + return ( +
+ setShowDeleteModal(true)} + /> + } + /> + + {/* Main Content */} +
+
+ {/* Banner Details */} +
+

Basic Information

+ +
+ {/* Title */} +
+

Title

+

{advice.Title}

+
+ + {/* Location & Sort Position */} +
+
+

Location

+
+ +

{getLocationName(advice.LocationKey)}

+
+
+
+

Sort Position

+

{advice.SortPosition}

+
+
+ + {/* Timestamps */} +
+
+

Created

+
+ +

{formatDate(advice.DocumentCreationDate)}

+
+
+ {advice.DocumentModifiedDate && ( +
+

Last Modified

+
+ +

{formatDate(advice.DocumentModifiedDate)}

+
+
+ )} +
+
+
+ + {/* Body Content */} +
+

Body Content

+ +
+
+
+
+ + {/* Delete Confirmation Modal */} + setShowDeleteModal(false)} + onConfirm={handleDelete} + title="Delete Advice?" + message={`Are you sure you want to delete "${advice.Title}"? This action cannot be undone.`} + confirmLabel="Delete" + variant="danger" + isLoading={deleting} + /> +
+ ); +} diff --git a/src/app/advice/new/page.tsx b/src/app/advice/new/page.tsx new file mode 100644 index 0000000..0065a49 --- /dev/null +++ b/src/app/advice/new/page.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useAuthorization } from '@/hooks/useAuthorization'; +import { ROLES } from '@/constants/roles'; +import { useSession } from 'next-auth/react'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { errorToast, successToast } from '@/utils/toast'; +import { validateFaq, transformErrorPath } from '@/schemas/faqSchema'; +import { Button } from '@/components/ui/Button'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import { RichTextEditor } from '@/components/ui/RichTextEditor'; +import ErrorDisplay from '@/components/ui/ErrorDisplay'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { FormField } from '@/components/ui/FormField'; +import { useBreadcrumb } from '@/contexts/BreadcrumbContext'; + +interface ValidationError { + Path: string; + Message: string; +} + +export default function NewAdvicePage() { + const router = useRouter(); + + // Check authorization FIRST + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.VOLUNTEER_ADMIN, ROLES.SWEP_ADMIN], + requiredPage: '/advice', + autoRedirect: true + }); + + const { data: session } = useSession(); + const userRoles = session?.user?.authClaims?.roles || []; + const canAccessGeneralAdvice = userRoles.includes(ROLES.SUPER_ADMIN) || userRoles.includes(ROLES.VOLUNTEER_ADMIN); + + const [saving, setSaving] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + const [showCancelModal, setShowCancelModal] = useState(false); + const [locations, setLocations] = useState>([]); + const [editorResetKey, setEditorResetKey] = useState(0); + const { setAdviceTitle } = useBreadcrumb(); + + // Form state + const [formData, setFormData] = useState({ + LocationKey: '', + Title: '', + Body: '', + SortPosition: 0, + }); + + const initialFormData = { + LocationKey: '', + Title: '', + Body: '', + SortPosition: 0, + }; + + useEffect(() => { + fetchLocations(); + // Clear any stale advice title from breadcrumbs + setAdviceTitle(null); + + // Cleanup on unmount + return () => { + setAdviceTitle(null); + }; + }, [setAdviceTitle]); + + const fetchLocations = async () => { + try { + const response = await authenticatedFetch('/api/cities'); + + if (response.ok) { + const data = await response.json(); + setLocations(data.data || []); + } + } catch (err) { + console.error('Failed to fetch locations:', err); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate form data + const validation = validateFaq(formData); + if (!validation.success) { + const errors = validation.errors.map(err => ({ + Path: transformErrorPath(err.path), + Message: err.message + })); + setValidationErrors(errors); + errorToast.validation(); + return; + } + + try { + setSaving(true); + setValidationErrors([]); + + const response = await authenticatedFetch('/api/advice', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to create advice'); + } + + const result = await response.json(); + const newAdvice = result.data || result; + + successToast.create('Advice'); + router.push(`/advice/${newAdvice._id}`); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create advice'; + errorToast.generic(errorMessage); + } finally { + setSaving(false); + } + }; + + const handleCancel = () => { + if (JSON.stringify(formData) !== JSON.stringify(initialFormData)) { + setShowCancelModal(true); + } else { + router.push('/advice'); + } + }; + + const confirmCancel = () => { + // Revert to initial data + setFormData(JSON.parse(JSON.stringify(initialFormData))); + setValidationErrors([]); + setEditorResetKey(prev => prev + 1); // Force editor remount + setShowCancelModal(false); + }; + + // const hasData = JSON.stringify(formData) !== JSON.stringify(initialFormData); + + // Show loading while checking authorization + if (isChecking) { + return ( +
+
+
+ ); + } + + // Don't render anything if not authorized (redirect handled by hook) + if (!isAuthorized) { + return null; + } + + return ( +
+ +
+
+ {/* All Form Fields in One Section */} +
+
+ {/* Title */} + + setFormData({ ...formData, Title: e.target.value })} + placeholder="Enter advice title..." + /> + + + {/* Location Select */} + + setFormData({ ...formData, SortPosition: parseInt(e.target.value) || 0 })} + /> + + + {/* Rich Text Editor */} +
+ setFormData({ ...formData, Body: value })} + placeholder="Enter the advice content..." + minHeight="400px" + required + resetKey={editorResetKey} + /> +
+ + {/* Validation Errors */} + {validationErrors.length > 0 && ( +
+ +
+ )} +
+ + {/* Action Buttons */} +
+ + +
+
+
+
+ + {/* Cancel Confirmation Modal */} + setShowCancelModal(false)} + onConfirm={confirmCancel} + title="Close without saving?" + message="You may lose unsaved changes." + variant="warning" + confirmLabel="Discard changes" + cancelLabel="Continue Editing" + /> +
+ ); +} diff --git a/src/app/advice/page.tsx b/src/app/advice/page.tsx index b8e3204..2b5e4ac 100644 --- a/src/app/advice/page.tsx +++ b/src/app/advice/page.tsx @@ -1,42 +1,53 @@ 'use client'; -import { withAuthorization } from '@/components/auth/withAuthorization'; +import { useAuthorization } from '@/hooks/useAuthorization'; import { ROLES } from '@/constants/roles'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { Button } from '@/components/ui/Button'; +import { Plus } from 'lucide-react'; +import Link from 'next/link'; +import AdviceManagement from '@/components/advice/AdviceManagement'; -function AdvicePage() { - return ( -
-
-

Advice

-

Manage content pages and FAQs

+export default function AdvicePage() { + // Check authorization FIRST before any other logic + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.VOLUNTEER_ADMIN], + requiredPage: '/advice', + autoRedirect: true + }); + + // Show loading while checking authorization + if (isChecking) { + return ( +
+
+ ); + } + + // Don't render anything if not authorized (redirect handled by hook) + if (!isAuthorized) { + return null; + } + + return ( +
+ + + + } + /> -
-
-
- - - -
-

Content Management

-

- This page will allow you to manage content pages and FAQs. -

-
- -
-
+
+
); } -export default withAuthorization(AdvicePage, { - allowedRoles: [ROLES.VOLUNTEER_ADMIN, ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN], - requiredPage: '/advice' -}); diff --git a/src/app/api/advice/[id]/route.ts b/src/app/api/advice/[id]/route.ts new file mode 100644 index 0000000..8d64b35 --- /dev/null +++ b/src/app/api/advice/[id]/route.ts @@ -0,0 +1,119 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { sendError, sendForbidden, sendInternalError, proxyResponse, sendNotFound } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL; + +type RouteParams = { + id: string; +}; + +// GET /api/advice/[id] - Get single advice item +const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + // Check authorization + if (!hasApiAccess(auth.session.user.authClaims, '/api/faqs', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + const params = await context.params; + const { id } = params; + const url = `${API_BASE_URL}/api/faqs/${id}`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + if (response.status === 404) { + return sendNotFound(); + } + return sendError(response.status, data.error || 'Failed to fetch advice'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching advice:', error); + return sendInternalError(); + } +}; + +// PUT /api/advice/[id] - Update advice item +const putHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + // Check authorization + if (!hasApiAccess(auth.session.user.authClaims, '/api/faqs', HTTP_METHODS.PUT)) { + return sendForbidden(); + } + + const params = await context.params; + const { id } = params; + const body = await req.json(); + const url = `${API_BASE_URL}/api/faqs/${id}`; + + const response = await fetch(url, { + method: HTTP_METHODS.PUT, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update advice'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating advice:', error); + return sendInternalError(); + } +}; + +// DELETE /api/advice/[id] - Delete advice item +const deleteHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + // Check authorization + if (!hasApiAccess(auth.session.user.authClaims, '/api/advice', HTTP_METHODS.DELETE)) { + return sendForbidden(); + } + + const params = await context.params; + const { id } = params; + const url = `${API_BASE_URL}/api/faqs/${id}`; + + const response = await fetch(url, { + method: HTTP_METHODS.DELETE, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to delete advice'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error deleting advice:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); +export const PUT = withAuth(putHandler); +export const DELETE = withAuth(deleteHandler); diff --git a/src/app/api/advice/route.ts b/src/app/api/advice/route.ts new file mode 100644 index 0000000..4887a30 --- /dev/null +++ b/src/app/api/advice/route.ts @@ -0,0 +1,91 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { sendError, sendForbidden, sendInternalError, proxyResponse } from '@/utils/apiResponses'; +import { getUserLocationSlugs } from '@/utils/locationUtils'; +import { UserAuthClaims } from '@/types/auth'; + +const API_BASE_URL = process.env.API_BASE_URL; + +// GET /api/advice - Get all advice items with filtering and pagination +const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + // Check authorization + if (!hasApiAccess(auth.session.user.authClaims, '/api/faqs', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + // Forward query parameters + const searchParams = req.nextUrl.searchParams; + + // Add location filtering for CityAdmin users when dropdown is empty (showing all their locations) + const userAuthClaims = auth.session.user.authClaims as UserAuthClaims; + const locationSlugs = getUserLocationSlugs(userAuthClaims); + const selectedLocation = searchParams.get('location'); + + // If CityAdmin or SwepAdmin with specific locations AND no location selected in dropdown + // Pass all their locations to show all users they have access to + if (locationSlugs && locationSlugs.length > 0 && !selectedLocation) { + searchParams.set('locations', locationSlugs.join(',')); + } + + const queryString = searchParams.toString(); + const url = `${API_BASE_URL}/api/faqs${queryString ? `?${queryString}` : ''}`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to fetch advice'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching advice:', error); + return sendInternalError(); + } +}; + +// POST /api/advice - Create new advice item +const postHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + // Check authorization + if (!hasApiAccess(auth.session.user.authClaims, '/api/faqs', HTTP_METHODS.POST)) { + return sendForbidden(); + } + + const body = await req.json(); + const url = `${API_BASE_URL}/api/faqs`; + + const response = await fetch(url, { + method: HTTP_METHODS.POST, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to create advice'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error creating advice:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); +export const POST = withAuth(postHandler); diff --git a/src/app/api/banners/[id]/route.ts b/src/app/api/banners/[id]/route.ts index 4824c07..8f8119b 100644 --- a/src/app/api/banners/[id]/route.ts +++ b/src/app/api/banners/[id]/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'; import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; import { hasApiAccess } from '@/lib/userService'; import { HTTP_METHODS } from '@/constants/httpMethods'; -import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; +import { sendForbidden, sendInternalError, proxyResponse, sendError, sendNotFound } from '@/utils/apiResponses'; const API_BASE_URL = process.env.API_BASE_URL; @@ -20,12 +20,16 @@ const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, au }, }); + const data = await response.json(); + if (!response.ok) { - const errorData = await response.json(); - return sendError(response.status, errorData.error || 'Failed to fetch banner'); + if (response.status === 404) { + return sendNotFound(); + } + + return sendError(response.status, data.error || 'Failed to fetch banner'); } - const data = await response.json(); return proxyResponse(data); } catch (error) { console.error('Error fetching banner:', error); @@ -90,6 +94,41 @@ const deleteHandler: AuthenticatedApiHandler = async (req: NextRequest, context, } }; +// PATCH /api/banners/[id] - Update banner activation with optional date range +const patchHandler: AuthenticatedApiHandler<{ id: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/banners', HTTP_METHODS.PATCH)) { + return sendForbidden(); + } + + const params = await context.params; + const { id } = params; + const body = await req.json(); + const url = `${API_BASE_URL}/api/banners/${id}/toggle-active`; + + const response = await fetch(url, { + method: HTTP_METHODS.PATCH, + headers: { + 'Authorization': `Bearer ${auth.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update banner status'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating banner status:', error); + return sendInternalError(); + } +}; + export const GET = withAuth(getHandler); export const PUT = withAuth(putHandler); export const DELETE = withAuth(deleteHandler); +export const PATCH = withAuth(patchHandler); diff --git a/src/app/api/banners/[id]/toggle/route.ts b/src/app/api/banners/[id]/toggle/route.ts deleted file mode 100644 index b250c6f..0000000 --- a/src/app/api/banners/[id]/toggle/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { HTTP_METHODS } from "@/constants/httpMethods"; -import { NextRequest } from "next/server"; -import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; -import { hasApiAccess } from '@/lib/userService'; -import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; - -const API_BASE_URL = process.env.API_BASE_URL; - -const patchHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { - try { - if (!hasApiAccess(auth.session.user.authClaims, '/api/banners', HTTP_METHODS.PATCH)) { - return sendForbidden(); - } - - const { id } = context.params; - const response = await fetch(`${API_BASE_URL}/api/banners/${id}/toggle`, { - method: HTTP_METHODS.PATCH, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${auth.accessToken}`, - }, - }); - - const data = await response.json(); - - if (!response.ok) { - return sendError(response.status, data.error || 'Failed to toggle banner status'); - } - - return proxyResponse(data); - } catch (error) { - console.error('Error toggling banner status:', error); - return sendInternalError('Failed to toggle banner status'); - } -}; - -export const PATCH = withAuth(patchHandler); diff --git a/src/app/api/cities/route.ts b/src/app/api/cities/route.ts index 83ec166..32a6ce8 100644 --- a/src/app/api/cities/route.ts +++ b/src/app/api/cities/route.ts @@ -19,7 +19,7 @@ const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, au const restrictVolunteerAdmin = req.nextUrl.searchParams.get('restrictVolunteerAdmin') === 'true'; const userAuthClaims = auth.session.user.authClaims as UserAuthClaims; const locationSlugs = getUserLocationSlugs(userAuthClaims, restrictVolunteerAdmin); - + // Build query string let url = `${API_BASE_URL}/api/cities`; @@ -44,6 +44,8 @@ const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, au return sendError(response.status, data.error || 'Failed to fetch cities'); } + data.data.sort((a: { Name: string }, b: { Name: string }) => a.Name.localeCompare(b.Name)); + return proxyResponse(data); } catch (error) { console.error('Locations API error:', error); diff --git a/src/app/api/location-logos/[id]/route.ts b/src/app/api/location-logos/[id]/route.ts new file mode 100644 index 0000000..57dbc3d --- /dev/null +++ b/src/app/api/location-logos/[id]/route.ts @@ -0,0 +1,107 @@ +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { proxyResponse, sendError, sendForbidden, sendInternalError, sendNotFound } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001'; + +// GET /api/location-logos/[id] - Get single location logo +const getHandler: AuthenticatedApiHandler<{ id: string }> = async (req, context, auth) => { + try { + // Check RBAC permissions + if (!hasApiAccess(auth.session.user.authClaims, '/api/location-logos', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + const { id } = await context.params; + + const response = await fetch(`${API_BASE_URL}/api/location-logos/${id}`, { + headers: { + 'Authorization': `Bearer ${auth.session.accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + if (response.status === 404) { + return sendNotFound(); + } + + return sendError(response.status, data.error || 'Failed to fetch location logo'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching location logo:', error); + return sendInternalError('Failed to fetch location logo'); + } +}; + +// PUT /api/location-logos/[id] - Update location logo +const putHandler: AuthenticatedApiHandler<{ id: string }> = async (req, context, auth) => { + try { + // Check RBAC permissions + if (!hasApiAccess(auth.session.user.authClaims, '/api/location-logos', HTTP_METHODS.PUT)) { + return sendForbidden(); + } + + const { id } = context.params; + const formData = await req.formData(); + + const response = await fetch(`${API_BASE_URL}/api/location-logos/${id}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${auth.session.accessToken}`, + }, + body: formData, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update location logo'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating location logo:', error); + return sendInternalError('Failed to update location logo'); + } +}; + +// DELETE /api/location-logos/[id] - Delete location logo +const deleteHandler: AuthenticatedApiHandler<{ id: string }> = async (req, context, auth) => { + try { + // Check RBAC permissions + if (!hasApiAccess(auth.session.user.authClaims, '/api/location-logos', HTTP_METHODS.DELETE)) { + return sendForbidden(); + } + + const { id } = context.params; + + const response = await fetch(`${API_BASE_URL}/api/location-logos/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${auth.session.accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to delete location logo'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error deleting location logo:', error); + return sendInternalError('Failed to delete location logo'); + } +}; + +export const GET = withAuth(getHandler); +export const PUT = withAuth(putHandler); +export const DELETE = withAuth(deleteHandler); diff --git a/src/app/api/location-logos/route.ts b/src/app/api/location-logos/route.ts new file mode 100644 index 0000000..30c8f78 --- /dev/null +++ b/src/app/api/location-logos/route.ts @@ -0,0 +1,88 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { getUserLocationSlugs } from '@/utils/locationUtils'; +import { UserAuthClaims } from '@/types/auth'; +import { proxyResponse, sendError, sendForbidden, sendInternalError } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001'; + +// GET /api/location-logos - Get all location logos with filtering +const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + // Check RBAC permissions + if (!hasApiAccess(auth.session.user.authClaims, '/api/location-logos', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + // Forward query parameters + const searchParams = req.nextUrl.searchParams; + + // Add location filtering for CityAdmin users + const userAuthClaims = auth.session.user.authClaims as UserAuthClaims; + const locationSlugs = getUserLocationSlugs(userAuthClaims); + const selectedLocation = searchParams.get('location'); + + // If user has location restrictions AND no location selected, add their locations + if (locationSlugs && locationSlugs.length > 0 && !selectedLocation) { + searchParams.set('locations', locationSlugs.join(',')); + } + + const queryString = searchParams.toString(); + const url = `${API_BASE_URL}/api/location-logos${queryString ? `?${queryString}` : ''}`; + + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${auth.session.accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to fetch location logo'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching location logos:', error); + return sendInternalError('Failed to fetch location logo'); + } +}; + +// POST /api/location-logos - Create new location logo +const postHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + // Check RBAC permissions + if (!hasApiAccess(auth.session.user.authClaims, '/api/location-logos', HTTP_METHODS.POST)) { + return sendForbidden(); + } + + const formData = await req.formData(); + + const response = await fetch(`${API_BASE_URL}/api/location-logos`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${auth.session.accessToken}`, + }, + body: formData, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to create location logo'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error creating location logo:', error); + return sendInternalError('Failed to create location logo'); + } +}; + +export const GET = withAuth(getHandler); +export const POST = withAuth(postHandler); diff --git a/src/app/api/organisations/route.ts b/src/app/api/organisations/route.ts index cd782ad..abb7b3b 100644 --- a/src/app/api/organisations/route.ts +++ b/src/app/api/organisations/route.ts @@ -16,10 +16,10 @@ const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, au // Forward query parameters const searchParams = req.nextUrl.searchParams; - + // Add location filtering for CityAdmin users when dropdown is empty (showing all their locations) const userAuthClaims = auth.session.user.authClaims as UserAuthClaims; - const locationSlugs = getUserLocationSlugs(userAuthClaims, true); + const locationSlugs = getUserLocationSlugs(userAuthClaims); const selectedLocation = searchParams.get('location'); // If CityAdmin with specific locations AND no location selected in dropdown diff --git a/src/app/api/resources/[key]/route.ts b/src/app/api/resources/[key]/route.ts new file mode 100644 index 0000000..86fd213 --- /dev/null +++ b/src/app/api/resources/[key]/route.ts @@ -0,0 +1,86 @@ +import { NextRequest } from 'next/server'; +import { sendForbidden, sendInternalError, proxyResponse, sendError, sendNotFound } from '@/utils/apiResponses'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; + +const API_BASE_URL = process.env.API_BASE_URL; + +type RouteParams = { + key: string; +}; + +// GET /api/resources/[key] - Get single resource +const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/resources', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + const params = await context.params; + const { key } = params; + + const url = `${API_BASE_URL}/api/resources/${key}`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + if (response.status === 404) { + return sendNotFound(); + } + + return sendError(response.status, data.error || 'Failed to fetch resource'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching resource:', error); + return sendInternalError(); + } +}; + +// PUT /api/resources/[key] - Update resource +const putHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/resources', HTTP_METHODS.PUT)) { + return sendForbidden(); + } + + const params = await context.params; + const { key } = params; + + // Get the FormData from the request + const formData = await req.formData(); + + const url = `${API_BASE_URL}/api/resources/${key}`; + + const response = await fetch(url, { + method: HTTP_METHODS.PUT, + headers: { + 'Authorization': `Bearer ${auth.accessToken}`, + }, + body: formData, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update resource'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating resource:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); +export const PUT = withAuth(putHandler); diff --git a/src/app/api/resources/route.ts b/src/app/api/resources/route.ts new file mode 100644 index 0000000..60e2902 --- /dev/null +++ b/src/app/api/resources/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from 'next/server'; +import { sendForbidden, sendInternalError, proxyResponse, sendError } from '@/utils/apiResponses'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; + +const API_BASE_URL = process.env.API_BASE_URL; + +// GET /api/resources - Get all resources +const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/resources', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + // Forward query parameters to backend API + const queryString = req.nextUrl.search; + const url = `${API_BASE_URL}/api/resources${queryString}`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Authorization': `Bearer ${auth.accessToken}`, + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to fetch resources'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching resources:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); diff --git a/src/app/api/swep-banners/[locationSlug]/route.ts b/src/app/api/swep-banners/[locationSlug]/route.ts new file mode 100644 index 0000000..ba010a7 --- /dev/null +++ b/src/app/api/swep-banners/[locationSlug]/route.ts @@ -0,0 +1,117 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { proxyResponse, sendError, sendForbidden, sendInternalError, sendNotFound } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001'; + +// GET /api/swep-banners/[locationSlug] - Get single SWEP banner by location +const getHandler: AuthenticatedApiHandler<{ locationSlug: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/swep-banners', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + const params = await context.params; + const { locationSlug } = params; + const url = `${API_BASE_URL}/api/swep-banners/${locationSlug}`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Authorization': `Bearer ${auth.accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + if (response.status === 404) { + return sendNotFound(); + } + + return sendError(response.status, data.error || 'Failed to fetch SWEP banner'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching SWEP banner:', error); + return sendForbidden(); + } +}; + +// PUT /api/swep-banners/[locationSlug] - Update SWEP banner +const putHandler: AuthenticatedApiHandler<{ locationSlug: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/swep-banners', HTTP_METHODS.PUT)) { + return sendForbidden(); + } + + const params = await context.params; + const { locationSlug } = params; + const url = `${API_BASE_URL}/api/swep-banners/${locationSlug}`; + + // ALWAYS expect FormData (Banner approach) - simpler and more consistent + const formData = await req.formData(); + + const response = await fetch(url, { + method: HTTP_METHODS.PUT, + headers: { + 'Authorization': `Bearer ${auth.accessToken}`, + // Don't set Content-Type for FormData - fetch will set it with boundary + }, + body: formData, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update SWEP banner'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating SWEP banner:', error); + return sendInternalError(); + } +}; + +// PATCH /api/swep-banners/[locationSlug] - Update SWEP banner activation with optional date range +const patchHandler: AuthenticatedApiHandler<{ locationSlug: string }> = async (req: NextRequest, context, auth) => { + try { + if (!hasApiAccess(auth.session.user.authClaims, '/api/swep-banners', HTTP_METHODS.PATCH)) { + return sendForbidden(); + } + + const params = await context.params; + const { locationSlug } = params; + const body = await req.json(); + const url = `${API_BASE_URL}/api/swep-banners/${locationSlug}/toggle-active`; + + const response = await fetch(url, { + method: HTTP_METHODS.PATCH, + headers: { + 'Authorization': `Bearer ${auth.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to update SWEP banner status'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error updating SWEP banner status:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); +export const PUT = withAuth(putHandler); +export const PATCH = withAuth(patchHandler); diff --git a/src/app/api/swep-banners/route.ts b/src/app/api/swep-banners/route.ts new file mode 100644 index 0000000..f7fb92e --- /dev/null +++ b/src/app/api/swep-banners/route.ts @@ -0,0 +1,57 @@ +import { NextRequest } from 'next/server'; +import { withAuth, AuthenticatedApiHandler } from '@/lib/withAuth'; +import { hasApiAccess } from '@/lib/userService'; +import { HTTP_METHODS } from '@/constants/httpMethods'; +import { getUserSwepLocationSlugs } from '@/utils/locationUtils'; +import { UserAuthClaims } from '@/types/auth'; +import { proxyResponse, sendError, sendForbidden, sendInternalError } from '@/utils/apiResponses'; + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001'; + +// GET /api/swep-banners - Get all SWEP banners with filtering +const getHandler: AuthenticatedApiHandler = async (req: NextRequest, context, auth) => { + try { + // Check RBAC permissions + if (!hasApiAccess(auth.session.user.authClaims, '/api/swep-banners', HTTP_METHODS.GET)) { + return sendForbidden(); + } + + // Forward query parameters + const searchParams = req.nextUrl.searchParams; + + // Add location filtering for CityAdmin users when dropdown is empty (showing all their locations) + const userAuthClaims = auth.session.user.authClaims as UserAuthClaims; + const locationSlugs = getUserSwepLocationSlugs(userAuthClaims); + const selectedLocation = searchParams.get('location'); + + // If CityAdmin or SwepAdmin with specific locations AND no location selected in dropdown + // Pass all their locations to show all users they have access to + if (locationSlugs && locationSlugs.length > 0 && !selectedLocation) { + searchParams.set('locations', locationSlugs.join(',')); + } + + const queryString = searchParams.toString(); + const url = `${API_BASE_URL}/api/swep-banners${queryString ? `?${queryString}` : ''}`; + + const response = await fetch(url, { + method: HTTP_METHODS.GET, + headers: { + 'Authorization': `Bearer ${auth.accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return sendError(response.status, data.error || 'Failed to fetch SWEP banners'); + } + + return proxyResponse(data); + } catch (error) { + console.error('Error fetching SWEP banners:', error); + return sendInternalError(); + } +}; + +export const GET = withAuth(getHandler); diff --git a/src/app/banners/[id]/edit/page.tsx b/src/app/banners/[id]/edit/page.tsx index e7a6bc1..50b4108 100644 --- a/src/app/banners/[id]/edit/page.tsx +++ b/src/app/banners/[id]/edit/page.tsx @@ -1,20 +1,22 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { BannerEditor, IBannerFormData } from '@/components/banners/BannerEditor'; import { BannerPreview } from '@/components/banners/BannerPreview'; import { useAuthorization } from '@/hooks/useAuthorization'; -import { validateBannerForm } from '@/schemas/bannerSchema'; +import { validateBannerForm, transformErrorPath } from '@/schemas/bannerSchema'; import { successToast, errorToast, loadingToast, toastUtils } from '@/utils/toast'; import { authenticatedFetch } from '@/utils/authenticatedFetch'; import { BannerPageHeader } from '@/components/banners/BannerPageHeader'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { ErrorState } from '@/components/ui/ErrorState'; import { IBanner, IMediaAsset } from '@/types'; -import { ArrowLeft } from 'lucide-react'; -import Link from 'next/link'; -import { Button } from '@/components/ui/Button'; import { ROLES } from '@/constants/roles'; import { HTTP_METHODS } from '@/constants/httpMethods'; +import { useBreadcrumb } from '@/contexts/BreadcrumbContext'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { redirectToNotFound } from '@/utils/navigation'; // Helper function to transform IBanner to IBannerFormData function transformBannerToFormData(banner: IBanner): IBannerFormData { @@ -32,6 +34,7 @@ function transformBannerToFormData(banner: IBanner): IBannerFormData { Priority: banner.Priority, TrackingContext: banner.TrackingContext, LocationSlug: banner.LocationSlug || '', + LocationName: banner.LocationName || '', BadgeText: banner.BadgeText || '', StartDate: banner.StartDate ? new Date(banner.StartDate) : undefined, EndDate: banner.EndDate ? new Date(banner.EndDate) : undefined, @@ -56,8 +59,11 @@ function transformBannerToFormData(banner: IBanner): IBannerFormData { ResourceProject: banner.ResourceProject ? { ...banner.ResourceProject, - // Keep existing ResourceFile as IResourceFile - ResourceFile: banner.ResourceProject.ResourceFile || null + // Keep existing ResourceFile as IResourceFile with proper Date conversion + ResourceFile: banner.ResourceProject.ResourceFile ? { + ...banner.ResourceProject.ResourceFile, + LastUpdated: new Date(banner.ResourceProject.ResourceFile.LastUpdated) + } : null } : undefined, }; } @@ -80,46 +86,60 @@ export default function EditBannerPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [validationErrors, setValidationErrors] = useState>([]); + const { setBannerTitle } = useBreadcrumb(); - // Fetch banner data only if authorized - useEffect(() => { - if (!isAuthorized) return; + // Fetch banner data function + const fetchBanner = useCallback(async () => { + let redirected = false; - const fetchBanner = async () => { - try { - setLoading(true); - setError(null); - const response = await authenticatedFetch(`/api/banners/${bannerId}`); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to fetch banner'); - } - - const result = await response.json(); - if (result.success && result.data) { - const banner = result.data as IBanner; - - // Transform banner data for form - const formData = transformBannerToFormData(banner); - setInitialFormData(formData); - setBannerData(formData); - } else { - throw new Error(result.message || 'Banner not found'); + try { + setLoading(true); + setError(null); + const response = await authenticatedFetch(`/api/banners/${bannerId}`); + const result = await response.json(); + + if (!response.ok || !result.success || !result.data) { + if (redirectToNotFound(response, router)) { + redirected = true; + return; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to load banner'; - setError(errorMessage); - errorToast.load(errorMessage); - } finally { + + const errorMessage = (result && (result.error || result.message)) || 'Failed to fetch banner'; + throw new Error(errorMessage); + } + + const banner = result.data as IBanner; + + // Transform banner data for form + const formData = transformBannerToFormData(banner); + setInitialFormData(formData); + setBannerData(formData); + // Set banner title for breadcrumbs + setBannerTitle(banner.Title); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load banner'; + setError(message); + errorToast.generic(message); + } finally { + if (!redirected) { setLoading(false); } - }; + } + }, [bannerId, router, setBannerTitle]); + + // Fetch banner data only if authorized + useEffect(() => { + if (!isAuthorized) return; if (bannerId) { fetchBanner(); } - }, [isAuthorized, bannerId, router]); + + // Cleanup: Clear banner title when component unmounts + return () => { + setBannerTitle(null); + }; + }, [isAuthorized, bannerId, setBannerTitle, fetchBanner]); const handleSave = async (data: IBannerFormData) => { const toastId = loadingToast.update('banner'); @@ -131,7 +151,12 @@ export default function EditBannerPage() { // Client-side validation using Zod const validation = validateBannerForm(data); if (!validation.success) { - setValidationErrors(validation.errors); + // Transform error paths for better user-friendly messages + const transformedErrors = validation.errors.map(error => ({ + ...error, + path: transformErrorPath(error.path) + })); + setValidationErrors(transformedErrors); toastUtils.dismiss(toastId); errorToast.validation('Please fix the validation errors below'); return; @@ -237,22 +262,18 @@ export default function EditBannerPage() { toastUtils.dismiss(toastId); successToast.update('Banner'); router.push(`/banners/${bannerId}`); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'An error occurred'; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update banner'; toastUtils.dismiss(toastId); - errorToast.update('banner', errorMessage); + errorToast.generic(message); } finally { setSaving(false); } }; -// Show loading while checking authorization -if (isChecking) { - return ( -
-
-
- ); +// Show loading while checking authorization or fetching data +if (isChecking || loading) { + return ; } // Don't render anything if not authorized @@ -260,67 +281,41 @@ if (!isAuthorized) { return null; } -if (loading) { - return ( -
-
-
-

Loading banner...

-
-
- ); -} - if (!bannerData || !initialFormData) { return ( -
-
-
-
-

Banner Not Found

-
-
-
-
-
-

Banner Not Found

-

- {error || 'The banner you are looking for does not exist or has been deleted.'} -

- - - -
-
+
+
); } return (
- + + -
- {/* Full-width Preview at Top */} -
- {bannerData && ( - - )} -
+ {/* Full-width Preview at Top - Outside page-container */} +
+ {bannerData && ( + + )} +
-
- -
+
+
+
+
); } \ No newline at end of file diff --git a/src/app/banners/[id]/page.tsx b/src/app/banners/[id]/page.tsx index 591cd27..48b8b70 100644 --- a/src/app/banners/[id]/page.tsx +++ b/src/app/banners/[id]/page.tsx @@ -3,16 +3,19 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { BannerPreview } from '@/components/banners/BannerPreview'; import { useAuthorization } from '@/hooks/useAuthorization'; -import { Button } from '@/components/ui/Button'; import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import ActivateBannerModal from '@/components/banners/ActivateBannerModal'; import { errorToast, successToast, loadingToast, toastUtils } from '@/utils/toast'; import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { redirectToNotFound } from '@/utils/navigation'; import { IBanner, IBannerFormData, BannerTemplateType } from '@/types/banners/IBanner'; -import { ArrowLeft } from 'lucide-react'; -import Link from 'next/link'; +import { ErrorState } from '@/components/ui/ErrorState'; +import { PageHeader } from '@/components/ui/PageHeader'; import { BannerPageHeader } from '@/components/banners/BannerPageHeader'; import { ROLES } from '@/constants/roles'; import { HTTP_METHODS } from '@/constants/httpMethods'; +import { useBreadcrumb } from '@/contexts/BreadcrumbContext'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; export default function BannerViewPage() { // Check authorization FIRST @@ -32,39 +35,55 @@ export default function BannerViewPage() { const [toggling, setToggling] = useState(false); const [error, setError] = useState(null); const [showConfirmModal, setShowConfirmModal] = useState(false); + const [showActivateModal, setShowActivateModal] = useState(false); + const { setBannerTitle } = useBreadcrumb(); const fetchBanner = useCallback(async () => { if (!id || !isAuthorized) return; + let redirected = false; + try { setLoading(true); setError(null); const response = await authenticatedFetch(`/api/banners/${id}`); + const data = await response.json(); if (!response.ok) { - if (response.status === 404) { - setError('Banner not found'); + if (redirectToNotFound(response, router)) { + redirected = true; return; } - throw new Error('Failed to fetch banner'); + throw new Error(data.error || 'Failed to fetch banner'); } - const data = await response.json(); - setBanner(data.data); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load banner'; - setError(errorMessage); - errorToast.generic(errorMessage); + const bannerData = data.data || data; + setBanner(bannerData); + // Set banner title for breadcrumbs + if (bannerData?.Title) { + setBannerTitle(bannerData.Title); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load banner'; + setError(message); + errorToast.generic(message); } finally { - setLoading(false); + if (!redirected) { + setLoading(false); + } } - }, [id, isAuthorized]); + }, [id, isAuthorized, router, setBannerTitle]); useEffect(() => { if (isAuthorized) { fetchBanner(); } - }, [isAuthorized, fetchBanner]); + + // Cleanup: Clear banner title when component unmounts + return () => { + setBannerTitle(null); + }; + }, [isAuthorized, fetchBanner, setBannerTitle]); const handleDelete = async () => { if (!banner) return; @@ -90,27 +109,32 @@ export default function BannerViewPage() { toastUtils.dismiss(toastId); successToast.delete('Banner'); router.push('/banners'); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to delete banner'; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete banner'; toastUtils.dismiss(toastId); - errorToast.delete('banner', errorMessage); + errorToast.generic(message); } finally { setDeleting(false); } }; - const handleToggleActive = async () => { + const handleToggleActive = async (bannerId: string, isActive: boolean, startDate?: Date, endDate?: Date) => { if (!banner) return; const toastId = loadingToast.update('banner status'); setToggling(true); try { - const response = await authenticatedFetch(`/api/banners/${id}/toggle`, { + const response = await authenticatedFetch(`/api/banners/${bannerId}`, { method: HTTP_METHODS.PATCH, headers: { 'Content-Type': 'application/json', }, + body: JSON.stringify({ + IsActive: isActive, + StartDate: startDate, + EndDate: endDate + }) }); if (!response.ok) { @@ -123,14 +147,19 @@ export default function BannerViewPage() { toastUtils.dismiss(toastId); successToast.update('Banner status'); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to update banner status'; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update banner status'; toastUtils.dismiss(toastId); - errorToast.update('banner status', errorMessage); + errorToast.generic(message); + throw error; // Re-throw for modal error handling } finally { setToggling(false); } }; + + const handleOpenActivateModal = () => { + setShowActivateModal(true); + }; // Transform IBanner to IBannerFormData for preview const transformForPreview = (banner: IBanner): IBannerFormData => { @@ -158,19 +187,13 @@ export default function BannerViewPage() { return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' + year: 'numeric' }); }; - // Show loading while checking authorization - if (isChecking) { - return ( -
-
-
- ); + // Show loading while checking authorization or fetching data + if (isChecking || loading) { + return ; } // Don't render anything if not authorized @@ -178,71 +201,40 @@ export default function BannerViewPage() { return null; } - if (loading) { - return ( -
-
-
-
-

-
-
-
-
-
-
-
-
-
- ); - } - if (error || !banner) { return ( -
-
-
-
-

Banner Not Found

-
-
-
-
-
-

Banner Not Found

-

- {error || 'The banner you are looking for does not exist or has been deleted.'} -

- - - -
-
+
+
); } return (
- + } /> -
- - {/* Banner Preview */} -
- -
+ {/* Full-width Preview at Top - Outside page-container */} +
+ +
+
{/* Banner Details */}

Banner Details

@@ -293,7 +285,7 @@ export default function BannerViewPage() { {banner.LocationSlug && (
Location
-
{banner.LocationSlug}
+
{banner.LocationName}
)} @@ -487,6 +479,14 @@ export default function BannerViewPage() { confirmLabel="Delete" cancelLabel="Cancel" /> + + {/* Activate/Deactivate Modal */} + setShowActivateModal(false)} + onActivate={handleToggleActive} + />
); diff --git a/src/app/banners/new/page.tsx b/src/app/banners/new/page.tsx index 7fb5311..42e8a00 100644 --- a/src/app/banners/new/page.tsx +++ b/src/app/banners/new/page.tsx @@ -4,17 +4,24 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { BannerEditor, IBannerFormData } from '@/components/banners/BannerEditor'; import { BannerPreview } from '@/components/banners/BannerPreview'; -import { withAuthorization } from '@/components/auth/withAuthorization'; -import { validateBannerForm } from '@/schemas/bannerSchema'; +import { useAuthorization } from '@/hooks/useAuthorization'; +import { validateBannerForm, transformErrorPath } from '@/schemas/bannerSchema'; import toastUtils, { successToast, errorToast, loadingToast } from '@/utils/toast'; import { authenticatedFetch } from '@/utils/authenticatedFetch'; -// TODO: Uncomment if AccentGraphic is needed. In the other case, remove. -// import type { IAccentGraphic } from '@/types'; import { BannerPageHeader } from '@/components/banners/BannerPageHeader'; import { ROLES } from '@/constants/roles'; import { HTTP_METHODS } from '@/constants/httpMethods'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { PageHeader } from '@/components/ui/PageHeader'; + +export default function NewBannerPage() { + // Check authorization FIRST before any other logic + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.VOLUNTEER_ADMIN], + requiredPage: '/banners', + autoRedirect: true + }); -function NewBannerPage() { const router = useRouter(); const [bannerData, setBannerData] = useState(null); const [saving, setSaving] = useState(false); @@ -30,7 +37,12 @@ function NewBannerPage() { // Client-side validation using Zod const validation = validateBannerForm(data); if (!validation.success) { - setValidationErrors(validation.errors); + // Transform error paths for better user-friendly messages + const transformedErrors = validation.errors.map(error => ({ + ...error, + path: transformErrorPath(error.path) + })); + setValidationErrors(transformedErrors); toastUtils.dismiss(toastId); errorToast.validation('Please fix the validation errors below'); return; @@ -61,20 +73,6 @@ function NewBannerPage() { } } } - // TODO: Uncomment if AccentGraphic is needed. In the other case, remove. - // else if (key === 'AccentGraphic' && value && typeof value === 'object') { - // const accentGraphic = value as (Partial & { File?: File }); - // // Handle AccentGraphic with metadata - // if (accentGraphic.File instanceof File) { - // // 1. AccentGraphic object with File and metadata - // formData.append('newfile_AccentGraphic', accentGraphic.File); - - // // 2. Send the complete AccentGraphic metadata (excluding the File property) - // const accentGraphicMetadata = { ...accentGraphic }; - // delete accentGraphicMetadata.File; // Remove the File object from metadata - // formData.append('newmetadata_AccentGraphic', JSON.stringify(accentGraphicMetadata)); - // } - // } else if (key === 'PartnershipCharter' && value && typeof value === 'object') { // Handle nested PartnershipCharter with PartnerLogos const partnershipCharter = value as NonNullable; @@ -136,27 +134,38 @@ function NewBannerPage() { successToast.create('Banner'); const newId = result?.data?._id || result?._id || result?.data?.id || result?.id; router.push(newId ? `/banners/${newId}` : '/banners'); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'An error occurred'; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create banner'; toastUtils.dismiss(toastId); - errorToast.create('banner', errorMessage); + errorToast.generic(message); } finally { setSaving(false); } }; + // Show loading while checking authorization + if (isChecking) { + return ; + } + + // Don't render anything if not authorized (redirect handled by hook) + if (!isAuthorized) { + return null; + } + return (
+ -
- {/* Full-width Preview at Top */} -
- {bannerData && ( - - )} -
+ {/* Full-width Preview at Top - Outside page-container */} +
+ {bannerData && ( + + )} +
+
); } - -export default withAuthorization(NewBannerPage, { - allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.VOLUNTEER_ADMIN], - requiredPage: '/banners' -}); diff --git a/src/app/banners/page.tsx b/src/app/banners/page.tsx index cb88414..5c68bc6 100644 --- a/src/app/banners/page.tsx +++ b/src/app/banners/page.tsx @@ -3,10 +3,14 @@ import { useState, useEffect } from 'react'; import '@/styles/pagination.css'; import { useAuthorization } from '@/hooks/useAuthorization'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; +import { ErrorState } from '@/components/ui/ErrorState'; +import { EmptyState } from '@/components/ui/EmptyState'; import { Pagination } from '@/components/ui/Pagination'; import { ConfirmModal } from '@/components/ui/ConfirmModal'; -import { Search, Plus } from 'lucide-react'; +import ActivateBannerModal from '@/components/banners/ActivateBannerModal'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { FiltersSection } from '@/components/ui/FiltersSection'; +import { Plus } from 'lucide-react'; import { ICity } from '@/types'; import { IBanner, BannerTemplateType } from '@/types/banners/IBanner'; import BannerCard from '@/components/banners/BannerCard'; @@ -15,6 +19,8 @@ import { errorToast, successToast, loadingToast, toastUtils } from '@/utils/toas import { authenticatedFetch } from '@/utils/authenticatedFetch'; import { ROLES } from '@/constants/roles'; import { HTTP_METHODS } from '@/constants/httpMethods'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { ResultsSummary } from '@/components/ui/ResultsSummary'; export default function BannersListPage() { // Check authorization FIRST @@ -27,6 +33,7 @@ export default function BannersListPage() { const [banners, setBanners] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [searchInput, setSearchInput] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [templateFilter, setTemplateFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); @@ -37,9 +44,11 @@ export default function BannersListPage() { const [total, setTotal] = useState(0); const [togglingId, setTogglingId] = useState(null); const [showConfirmModal, setShowConfirmModal] = useState(false); + const [showActivateModal, setShowActivateModal] = useState(false); const [bannerToDelete, setBannerToDelete] = useState(null); - - const limit = 5; + const [bannerToActivate, setBannerToActivate] = useState(null); + + const limit = 9; // Only run effects if authorized useEffect(() => { @@ -53,7 +62,7 @@ export default function BannersListPage() { fetchBanners(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAuthorized, currentPage, searchTerm, templateFilter, statusFilter, locationFilter, limit]); + }, [isAuthorized, currentPage, searchTerm, templateFilter, statusFilter, locationFilter]); const fetchBanners = async () => { try { @@ -64,7 +73,7 @@ export default function BannersListPage() { limit: limit.toString(), }); - if (searchTerm) params.append('search', searchTerm); + if (searchInput?.trim()) params.append('search', searchInput.trim()); if (templateFilter) params.append('templateType', templateFilter); if (statusFilter) params.append('isActive', statusFilter); if (locationFilter) params.append('location', locationFilter); @@ -88,8 +97,8 @@ export default function BannersListPage() { } }; - const handleSearch = (value: string) => { - setSearchTerm(value); + const handleSearchSubmit = () => { + setSearchTerm(searchInput); setCurrentPage(1); // Reset to first page when searching }; @@ -163,16 +172,29 @@ export default function BannersListPage() { } }; - const handleToggleActive = async (banner: IBanner) => { + const handleOpenActivateModal = (bannerId: string) => { + const banner = banners.find(b => b._id === bannerId); + if (banner) { + setBannerToActivate(banner); + setShowActivateModal(true); + } + }; + + const handleToggleActive = async (bannerId: string, isActive: boolean, startDate?: Date, endDate?: Date) => { const toastId = loadingToast.update('banner status'); - setTogglingId(banner._id); + setTogglingId(bannerId); try { - const response = await authenticatedFetch(`/api/banners/${banner._id}/toggle`, { + const response = await authenticatedFetch(`/api/banners/${bannerId}`, { method: HTTP_METHODS.PATCH, headers: { 'Content-Type': 'application/json', }, + body: JSON.stringify({ + IsActive: isActive, + StartDate: startDate, + EndDate: endDate + }) }); if (!response.ok) { @@ -184,27 +206,26 @@ export default function BannersListPage() { // Update the banner in the local state setBanners(prevBanners => - prevBanners.map(b => b._id === banner._id ? result.data : b) + prevBanners.map(b => b._id === bannerId ? result.data : b) ); toastUtils.dismiss(toastId); successToast.update('Banner status'); + setShowActivateModal(false); + setBannerToActivate(null); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to update banner status'; toastUtils.dismiss(toastId); errorToast.update('banner status', errorMessage); + throw err; // Re-throw for modal error handling } finally { setTogglingId(null); } }; - // Show loading while checking authorization - if (isChecking) { - return ( -
-
-
- ); + // Show loading while checking authorization or fetching data + if (isChecking || loading) { + return ; } // Don't render anything if not authorized @@ -214,119 +235,90 @@ export default function BannersListPage() { return (
- {/* Header */} -
-
-
-

Banners

- - - -
-
-
+ + + + } + />
{/* Filters */} -
-
-
-
- - handleSearch(e.target.value)} - className="pl-10" - /> -
-
- -
- - - - - -
-
-
+ ({ + label: city.Name, + value: city.Key + })) + }, + { + id: 'template-filter', + value: templateFilter, + onChange: handleTemplateFilter, + placeholder: 'All Templates', + options: [ + { label: 'Giving Campaign', value: BannerTemplateType.GIVING_CAMPAIGN }, + { label: 'Partnership Charter', value: BannerTemplateType.PARTNERSHIP_CHARTER }, + { label: 'Resource Project', value: BannerTemplateType.RESOURCE_PROJECT } + ] + }, + { + id: 'status-filter', + value: statusFilter, + onChange: handleStatusFilter, + placeholder: 'All Status', + options: [ + { label: 'Active', value: 'true' }, + { label: 'Inactive', value: 'false' } + ] + } + ]} + /> {/* Results Summary */} -
-

- {loading ? '' : `${total} banner${total !== 1 ? 's' : ''} found`} -

- -
- - {/* Loading State */} - {loading && ( -
-
-
- )} + {/* Error State */} {error && !loading && ( -
-

Error Loading Banners

-

{error}

- -
+ )} {/* Empty State */} {!loading && !error && banners.length === 0 && ( -
-

No Banners Found

-

- {searchTerm || templateFilter || statusFilter - ? 'No banners match your current filters. Try adjusting your search criteria.' - : 'Get started by creating your first banner.'} -

- - - -
+ No banners match your current filters. Try adjusting your search criteria.

+ ) : ( +

Get started by creating your first banner.

+ ) + } + action={{ + label: 'Create Your First Banner', + icon: , + href: '/banners/new', + variant: 'primary' + }} + /> )} {/* Banners Grid */} @@ -337,7 +329,7 @@ export default function BannersListPage() { key={banner._id} banner={banner} onDelete={handleDelete} - onToggleActive={handleToggleActive} + onToggleActive={handleOpenActivateModal} isToggling={togglingId === banner._id} /> ))} @@ -371,6 +363,19 @@ export default function BannersListPage() { confirmLabel="Delete" cancelLabel="Cancel" /> + + {/* Activate/Deactivate Modal */} + {bannerToActivate && ( + { + setShowActivateModal(false); + setBannerToActivate(null); + }} + onActivate={handleToggleActive} + /> + )}
); } diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 9bda5fe..362f8e8 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -20,4 +20,4 @@ export default function GlobalError({ error }: { error: Error & { digest?: strin ); -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fdfba2c..fd0dd7b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { Metadata } from 'next'; import './globals.css'; +import '@/styles/rich-text-editor.css'; import Footer from '@/components/partials/Footer'; import NextAuthProvider from '@/components/auth/NextAuthProvider'; @@ -7,9 +8,10 @@ import ProtectedLayout from '@/components/auth/ProtectedLayout'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import Nav from '@/components/partials/Nav'; import { Toaster } from 'react-hot-toast'; +import { BreadcrumbProvider } from '@/contexts/BreadcrumbContext'; -const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"; +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; export const metadata: Metadata = { metadataBase: new URL(baseUrl), @@ -31,6 +33,22 @@ export const metadata: Metadata = { 'max-snippet': -1, }, }, + icons: { + icon: [ + { url: '/favicon.ico' }, + { url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' }, + { url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' } + ], + apple: [ + { url: '/apple-touch-icon.png' } + ], + }, + other: { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + } }; export default function RootLayout({ @@ -42,7 +60,8 @@ export default function RootLayout({ - + +
{/*
*/}
- + + - diff --git a/src/app/location-logos/[id]/edit/page.tsx b/src/app/location-logos/[id]/edit/page.tsx new file mode 100644 index 0000000..c5cb03e --- /dev/null +++ b/src/app/location-logos/[id]/edit/page.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useAuthorization } from '@/hooks/useAuthorization'; +import { ROLES } from '@/constants/roles'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { PageHeader } from '@/components/ui/PageHeader'; +import LocationLogoForm from '@/components/location-logos/LocationLogoForm'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { successToast, errorToast } from '@/utils/toast'; +import { ErrorState } from '@/components/ui/ErrorState'; +import { ILocationLogo } from '@/types/ILocationLogo'; +import { useBreadcrumb } from '@/contexts/BreadcrumbContext'; +import { redirectToNotFound } from '@/utils/navigation'; + +export default function EditLocationLogoPage() { + const params = useParams(); + const router = useRouter(); + const id = params.id as string; + + const [logo, setLogo] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const { setLogoTitle } = useBreadcrumb(); + + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.VOLUNTEER_ADMIN, ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN], + requiredPage: `/location-logos/${id}/edit`, + autoRedirect: true + }); + + useEffect(() => { + if (isAuthorized && id) { + fetchLogo(); + } + + // Cleanup: Clear logo title when component unmounts + return () => { + setLogoTitle(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthorized, id]); + + const fetchLogo = async () => { + let redirected = false; + + try { + setLoading(true); + const response = await authenticatedFetch(`/api/location-logos/${id}`); + const data = await response.json(); + + if (!response.ok) { + if (redirectToNotFound(response, router)) { + redirected = true; + return; + } + + throw new Error(data.error || 'Failed to fetch location logo'); + } + + const logoData = data.data || data; + setLogo(logoData); + setLogoTitle(logoData.DisplayName); + setError(null); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load location logo'; + setError(errorMessage); + } finally { + if (!redirected) { + setLoading(false); + } + } + }; + + const handleSubmit = async (formData: FormData) => { + setSaving(true); + try { + const response = await authenticatedFetch(`/api/location-logos/${id}`, { + method: 'PUT', + body: formData + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to update location logo'); + } + + successToast.update('Location logo'); + router.push(`/location-logos/${id}`); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update location logo'; + errorToast.generic(message); + } finally { + setSaving(false); + } + }; + + if (isChecking || loading) { + return ; + } + + if (!isAuthorized) { + return null; + } + + if (error || !logo) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ + +
+
+ {}} + isEdit={true} + saving={saving} + /> +
+
+
+ ); +} diff --git a/src/app/location-logos/[id]/page.tsx b/src/app/location-logos/[id]/page.tsx new file mode 100644 index 0000000..1249f55 --- /dev/null +++ b/src/app/location-logos/[id]/page.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Image from 'next/image'; +import { useAuthorization } from '@/hooks/useAuthorization'; +import { ROLES } from '@/constants/roles'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { ExternalLink, MapPin, Calendar } from 'lucide-react'; +import { LocationLogoPageActions } from '@/components/location-logos/LocationLogoPageActions'; +import { useBreadcrumb } from '@/contexts/BreadcrumbContext'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { successToast, errorToast } from '@/utils/toast'; +import { ErrorState } from '@/components/ui/ErrorState'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { ILocationLogo } from '@/types/ILocationLogo'; +import { redirectToNotFound } from '@/utils/navigation'; + +export default function ViewLocationLogoPage() { + const params = useParams(); + const router = useRouter(); + const id = params.id as string; + + const [logo, setLogo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const { setLogoTitle } = useBreadcrumb(); + + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.VOLUNTEER_ADMIN, ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN], + requiredPage: `/location-logos/${id}`, + autoRedirect: true + }); + + useEffect(() => { + if (isAuthorized && id) { + fetchLogo(); + } + + // Cleanup: Clear logo title when component unmounts + return () => { + setLogoTitle(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthorized, id]); + + const fetchLogo = async () => { + let redirected = false; + + try { + setLoading(true); + const response = await authenticatedFetch(`/api/location-logos/${id}`); + const data = await response.json(); + + if (!response.ok) { + if (redirectToNotFound(response, router)) { + redirected = true; + return; + } + + throw new Error(data.error || 'Failed to fetch location logo'); + } + + const logoData = data.data || data; + setLogo(logoData); + setLogoTitle(logoData.DisplayName); + setError(null); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load location logo'; + setError(errorMessage); + } finally { + if (!redirected) { + setLoading(false); + } + } + }; + + const handleDelete = async () => { + try { + const response = await authenticatedFetch(`/api/location-logos/${id}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to delete location logo'); + } + + successToast.delete('Location logo'); + router.push('/location-logos'); + } catch { + errorToast.delete('Location logo'); + } + }; + + if (isChecking || loading) { + return ; + } + + if (!isAuthorized) { + return null; + } + + if (error || !logo) { + return ( +
+ +
+ +
+
+ ); + } + + const formatDate = (date: Date | string | undefined): string => { + if (!date) return 'N/A'; + const d = new Date(date); + if (isNaN(d.getTime())) return 'Invalid Date'; + return d.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric' + }); + }; + + return ( +
+ setShowDeleteModal(true)} + /> + } + /> + + {/* Main Content */} +
+
+ {/* Logo Preview */} +
+

Logo Preview

+
+
+ {logo.DisplayName} +
+
+
+ + {/* Logo Details */} +
+

Basic Information

+ +
+ + {/* Display Name */} +
+

Display Name

+

{logo.DisplayName}

+
+ + {/* Location */} +
+

Location

+
+ + {logo.LocationName} +
+
+ + {/* Website URL */} +
+

Website URL

+ + + {logo.Url} + +
+
+
+ + {/* Metadata */} +
+

Metadata

+ +
+
+

Created By

+

{logo.CreatedBy || 'System'}

+
+ +
+

Created Date

+
+ + {formatDate(logo.DocumentCreationDate)} +
+
+ +
+

Last Modified

+
+ + {formatDate(logo.DocumentModifiedDate)} +
+
+
+
+
+
+ + {/* Delete Confirmation Modal */} + setShowDeleteModal(false)} + onConfirm={handleDelete} + title="Delete Location Logo" + message={`Are you sure you want to delete "${logo.DisplayName}"? This action cannot be undone.`} + /> +
+ ); +} diff --git a/src/app/location-logos/new/page.tsx b/src/app/location-logos/new/page.tsx new file mode 100644 index 0000000..d19794a --- /dev/null +++ b/src/app/location-logos/new/page.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useAuthorization } from '@/hooks/useAuthorization'; +import { ROLES } from '@/constants/roles'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { PageHeader } from '@/components/ui/PageHeader'; +import LocationLogoForm from '@/components/location-logos/LocationLogoForm'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { successToast, errorToast } from '@/utils/toast'; + +export default function CreateLocationLogoPage() { + const router = useRouter(); + const [saving, setSaving] = useState(false); + + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.VOLUNTEER_ADMIN, ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN], + requiredPage: '/location-logos/new', + autoRedirect: true + }); + + if (isChecking) { + return ; + } + + if (!isAuthorized) { + return null; + } + + const handleSubmit = async (formData: FormData) => { + setSaving(true); + try { + const response = await authenticatedFetch('/api/location-logos', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to create location logo'); + } + + successToast.create('Location logo'); + router.push('/location-logos'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create location logo'; + errorToast.generic(message); + } finally { + setSaving(false); + } + }; + + return ( +
+ + +
+
+ {}} + saving={saving} + /> +
+
+
+ ); +} diff --git a/src/app/location-logos/page.tsx b/src/app/location-logos/page.tsx new file mode 100644 index 0000000..d1b90e9 --- /dev/null +++ b/src/app/location-logos/page.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useAuthorization } from '@/hooks/useAuthorization'; +import { ROLES } from '@/constants/roles'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { PageHeader } from '@/components/ui/PageHeader'; +import LocationLogoManagement from '@/components/location-logos/LocationLogoManagement'; +import Link from 'next/link'; +import { Button } from '@/components/ui/Button'; +import { Plus } from 'lucide-react'; + +export default function LocationLogosPage() { + // Check authorization FIRST before any other logic + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.VOLUNTEER_ADMIN, ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN], + requiredPage: '/location-logos', + autoRedirect: true + }); + + // Show loading while checking authorization + if (isChecking) { + return ; + } + + // Don't render anything if not authorized (redirect handled by hook) + if (!isAuthorized) { + return null; + } + + return ( +
+ + + + } + /> + +
+ +
+
+ ); +} + diff --git a/src/app/organisations/page.tsx b/src/app/organisations/page.tsx index 6095bdd..0e5536b 100644 --- a/src/app/organisations/page.tsx +++ b/src/app/organisations/page.tsx @@ -4,10 +4,13 @@ import { useState, useEffect } from 'react'; import '@/styles/pagination.css'; import { useAuthorization } from '@/hooks/useAuthorization'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; +import { ErrorState } from '@/components/ui/ErrorState'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { PageHeader } from '@/components/ui/PageHeader'; import { Pagination } from '@/components/ui/Pagination'; import { ConfirmModal } from '@/components/ui/ConfirmModal'; -import { Plus, Search } from 'lucide-react'; +import { FiltersSection } from '@/components/ui/FiltersSection'; +import { Plus } from 'lucide-react'; import { IOrganisation } from '@/types/organisations/IOrganisation'; import OrganisationCard from '@/components/organisations/OrganisationCard'; import AddUserToOrganisationModal from '@/components/organisations/AddUserToOrganisationModal'; @@ -22,6 +25,8 @@ import { useSession } from 'next-auth/react'; import { UserAuthClaims } from '@/types/auth'; import { authenticatedFetch } from '@/utils/authenticatedFetch'; import { exportOrganisationsToCsv } from '@/utils/csvExport'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { ResultsSummary } from '@/components/ui/ResultsSummary'; export default function OrganisationsPage() { // Check authorization FIRST before any other logic @@ -127,8 +132,8 @@ export default function OrganisationsPage() { page: currentPage.toString(), limit: limit.toString(), }); - - if (searchName) params.append('search', searchName); + + if (nameInput?.trim()) params.append('search', nameInput.trim()); if (isVerifiedFilter) params.append('isVerified', isVerifiedFilter); if (isPublishedFilter) params.append('isPublished', isPublishedFilter); if (locationFilter) params.append('location', locationFilter); @@ -153,15 +158,11 @@ export default function OrganisationsPage() { }; const handleSearchClick = () => { - setSearchName(nameInput.trim()); + const trimmedSearch = nameInput.trim(); + setSearchName(trimmedSearch); setCurrentPage(1); }; - const handleSearchKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearchClick(); - } - }; const handleIsVerifiedFilter = (value: string) => { setIsVerifiedFilter(value); @@ -371,13 +372,9 @@ export default function OrganisationsPage() { setOrganisationToDisable(null); }; - // Show loading while checking authorization - if (isChecking) { - return ( -
-
-
- ); + // Show loading while checking authorization or fetching data + if (isChecking || loading) { + return ; } // Don't render anything if not authorized (redirect handled by hook) @@ -387,94 +384,65 @@ export default function OrganisationsPage() { return (
- {/* Header - Always show but with conditional content */} -
-
-
-

{isOrgAdmin ? 'My Organisation' : 'Organisations'}

- {!isOrgAdmin && ( - - )} -
-
-
+ setIsAddOrganisationModalOpen(true)}> + + Add Organisation + + ) : undefined + } + />
{/* Filters - Hidden for OrgAdmin */} {!isOrgAdmin && ( -
-
-
-
-
- - setNameInput(e.target.value)} - onKeyPress={handleSearchKeyPress} - className="pl-10" - /> -
- -
-
- -
- - - - - -
-
-
+ ({ + label: city.Name, + value: city.Key + })) + }, + { + id: 'verified-filter', + value: isVerifiedFilter, + onChange: handleIsVerifiedFilter, + placeholder: 'Verified: Either', + options: [ + { label: 'Verified: Yes', value: 'true' }, + { label: 'Verified: No', value: 'false' } + ] + }, + { + id: 'published-filter', + value: isPublishedFilter, + onChange: handleIsPublishedFilter, + placeholder: 'Published: Either', + options: [ + { label: 'Published: Yes', value: 'true' }, + { label: 'Published: No', value: 'false' } + ] + } + ]} + /> )} - {/* Results Summary - Hidden for OrgAdmin */} + {/* Results Summary with Export Button - Hidden for OrgAdmin */} {!isOrgAdmin && (
-

- {loading ? '' : `${total} organisation${total !== 1 ? 's' : ''} found`} -

+ {!loading && total > 0 && ( -
+ )} {/* Empty State */} {!loading && !error && organisations.length === 0 && ( -
-

No Organisations Found

-
- {searchName || isVerifiedFilter || isPublishedFilter || locationFilter ? ( + No organisations match your current filters. Try adjusting your search criteria.

) : ( -

No organisations available.

- )} -
-
+

Get started by adding your first organisation.

+ ) + } + action={{ + label: 'Add Organisation', + icon: , + onClick: () => setIsAddOrganisationModalOpen(true), + variant: 'primary' + }} + /> )} {/* Organisations Grid */} diff --git a/src/app/resources/[key]/edit/page.tsx b/src/app/resources/[key]/edit/page.tsx new file mode 100644 index 0000000..c043a28 --- /dev/null +++ b/src/app/resources/[key]/edit/page.tsx @@ -0,0 +1,562 @@ +'use client'; + +import { useAuthorization } from '@/hooks/useAuthorization'; +import { ROLES } from '@/constants/roles'; +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { ILinkList, LinkListType } from '@/types/resources/ILinkList'; +import { ILink } from '@/types/resources/ILink'; +import { authenticatedFetch } from '@/utils/authenticatedFetch'; +import { errorToast, successToast } from '@/utils/toast'; +import { validateResourceForm, transformErrorPath } from '@/schemas/resourceSchema'; +import { RichTextEditor } from '@/components/ui/RichTextEditor'; +import ErrorDisplay from '@/components/ui/ErrorDisplay'; +import { Button } from '@/components/ui/Button'; +import { ErrorState } from '@/components/ui/ErrorState'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { Input } from '@/components/ui/Input'; +import { Textarea } from '@/components/ui/Textarea'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { FormField } from '@/components/ui/FormField'; +import { Select } from '@/components/ui/Select'; +import { Trash } from 'lucide-react'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { RESOURCE_FILE_ACCEPT_STRING } from '@/types'; +import { redirectToNotFound } from '@/utils/navigation'; + +interface ValidationError { + Path: string; + Message: string; +} + +// Safe runtime check for File (works in browser; avoids TS complaints if Value type is string) +const isFile = (v: unknown): v is File => typeof File !== 'undefined' && v instanceof File; + +export default function ResourceEditPage() { + const params = useParams(); + const router = useRouter(); + const key = params.key as string; + + // Check authorization FIRST + const { isChecking, isAuthorized } = useAuthorization({ + allowedRoles: [ROLES.SUPER_ADMIN, ROLES.CITY_ADMIN, ROLES.VOLUNTEER_ADMIN], + requiredPage: '/resources', + autoRedirect: true + }); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + const [showCancelModal, setShowCancelModal] = useState(false); + const [hasUploadedFiles, setHasUploadedFiles] = useState(false); // Track if any files were uploaded + const [editorResetKey, setEditorResetKey] = useState(0); + + // Form state + const [formData, setFormData] = useState({ + Key: '', + Name: '', + Header: '', + ShortDescription: '', + Body: '', + LinkList: [] as ILinkList[] + }); + + const [originalData, setOriginalData] = useState(formData); + + useEffect(() => { + if (isAuthorized && key) { + fetchResource(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthorized, key]); + + const fetchResource = async () => { + let redirected = false; + + try { + setLoading(true); + const response = await authenticatedFetch(`/api/resources/${key}`); + + const responseData = await response.json(); + + if (!response.ok) { + if (redirectToNotFound(response, router)) { + redirected = true; + return; + } + + throw new Error(responseData.error || 'Failed to fetch resource'); + } + + // Extract the actual resource data from the API response + // API returns { success: true, data: actualResource } + const data = responseData.data || responseData; + + const initialFormData = { + Key: data.Key || '', + Name: data.Name || '', + Header: data.Header || '', + ShortDescription: data.ShortDescription || '', + Body: data.Body || '', + LinkList: data.LinkList || [] + }; + + setFormData(initialFormData); + // Deep clone to ensure originalData is independent of formData + setOriginalData(JSON.parse(JSON.stringify(initialFormData))); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load resource'; + setError(errorMessage); + errorToast.generic(errorMessage); + } finally { + if (!redirected) { + setLoading(false); + } + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate form data + const validation = validateResourceForm(formData); + if (!validation.success) { + const errors = validation.errors.map(err => { + const pathString = Array.isArray(err.path) ? err.path.join('.') : err.path; + return { + Path: transformErrorPath(pathString), + Message: err.message + }; + }); + setValidationErrors(errors); + errorToast.validation(); + return; + } + + try { + setSaving(true); + setValidationErrors([]); + + // Create FormData for file uploads + const formDataToSend = new FormData(); + + // Add basic fields + formDataToSend.append('Key', formData.Key); + formDataToSend.append('Name', formData.Name); + formDataToSend.append('Header', formData.Header); + formDataToSend.append('ShortDescription', formData.ShortDescription); + formDataToSend.append('Body', formData.Body); + + // Add LinkList as JSON string first + formDataToSend.append('LinkList', JSON.stringify(formData.LinkList)); + + // Add file uploads from LinkList + formData.LinkList.forEach((linkList, listIndex) => { + linkList.Links.forEach((item, itemIndex) => { + if (isFile(item.Link)) { + const fieldName = `newfile_LinkList_${listIndex}_Links_${itemIndex}`; + formDataToSend.append(fieldName, item.Link); + } + }); + }); + + const response = await authenticatedFetch(`/api/resources/${key}`, { + method: 'PUT', + body: formDataToSend, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to update resource'); + } + + successToast.update('Resource'); + router.push('/resources'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update resource'; + errorToast.generic(errorMessage); + } finally { + setSaving(false); + } + }; + + const handleCancel = () => { + // Check if any File objects exist in current data - that's automatically a change + let hasFileUploads = false; + for (const list of formData.LinkList) { + for (const link of list.Links) { + if (isFile(link.Link)) { + hasFileUploads = true; + break; + } + } + if (hasFileUploads) break; + } + + if (hasFileUploads) { + setShowCancelModal(true); + return; + } + + // If any files were uploaded during this session (even if cleared), consider it a change + if (hasUploadedFiles) { + setShowCancelModal(true); + return; + } + + // No File objects, compare all data including URLs + // This will catch: cleared URLs, changed text fields, etc. + if (JSON.stringify(formData) !== JSON.stringify(originalData)) { + setShowCancelModal(true); + } else { + // No changes, reset form to default (deep clone to avoid reference issues) + setFormData(JSON.parse(JSON.stringify(originalData))); + setValidationErrors([]); + setHasUploadedFiles(false); + } + }; + + const confirmCancel = () => { + // Revert to original data (deep clone to avoid reference issues) + setFormData(JSON.parse(JSON.stringify(originalData))); + setValidationErrors([]); + setEditorResetKey(prev => prev + 1); // Force editor remount + setShowCancelModal(false); + setHasUploadedFiles(false); // Reset file upload tracking + }; + + // LinkList Management Functions + const addLinkList = () => { + setFormData({ + ...formData, + LinkList: [ + ...formData.LinkList, + { + Name: '', + Type: LinkListType.LINK, + Priority: 1, + Links: [{ Title: '', Link: '' }] + } + ] + }); + }; + + const removeLinkList = (index: number) => { + setFormData({ + ...formData, + LinkList: formData.LinkList.filter((_, i) => i !== index) + }); + }; + + const updateLinkList = (index: number, field: keyof ILinkList, value: string | number | LinkListType) => { + const newLinkList = [...formData.LinkList]; + newLinkList[index] = { ...newLinkList[index], [field]: value }; + setFormData({ ...formData, LinkList: newLinkList }); + }; + + const addLinkItem = (listIndex: number) => { + const newLinkList = [...formData.LinkList]; + newLinkList[listIndex].Links.push({ Title: '', Link: '' }); + setFormData({ ...formData, LinkList: newLinkList }); + }; + + const removeLinkItem = (listIndex: number, itemIndex: number) => { + const newLinkList = [...formData.LinkList]; + newLinkList[listIndex].Links = newLinkList[listIndex].Links.filter((_, i) => i !== itemIndex); + setFormData({ ...formData, LinkList: newLinkList }); + }; + + const updateLinkItem = (listIndex: number, itemIndex: number, field: keyof ILink, value: string | File) => { + const newLinkList = [...formData.LinkList]; + + // If clearing a file (setting Link to ''), mark as file interaction + if (field === 'Link' && value === '' && isFile(newLinkList[listIndex].Links[itemIndex].Link)) { + setHasUploadedFiles(true); + } + + newLinkList[listIndex].Links[itemIndex] = { + ...newLinkList[listIndex].Links[itemIndex], + [field]: value + }; + setFormData({ ...formData, LinkList: newLinkList }); + }; + + const handleFileChange = (listIndex: number, itemIndex: number, file: File | null) => { + if (file) { + updateLinkItem(listIndex, itemIndex, 'Link', file); + setHasUploadedFiles(true); // Mark that files have been uploaded this session + } + }; + + // Show loading while checking authorization or fetching data + if (isChecking || loading) { + return ; + } + + // Don't render anything if not authorized + if (!isAuthorized) { + return null; + } + + // Error State + if (error && !loading) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ +
+ {/* All Form Fields in One Section */} +
+
+ {/* Basic Information */} + + setFormData({ ...formData, Name: e.target.value })} + placeholder="Name shown on the resources list page" + /> + + + + setFormData({ ...formData, Header: e.target.value })} + placeholder="Header shown on the resource page" + /> + + + +