From e60340d2158f500ef8fea773af7432da02fd19bc Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:13:19 -0700 Subject: [PATCH] adding poster tech, ungating purge from prod --- backend/package-lock.json | 570 +++++++++++++++++- backend/package.json | 1 + backend/routes/pivotAdminRoutes.js | 1 + backend/routes/platformTenantRoutes.js | 112 ++++ backend/schemas/pivotPosterTemplate.js | 75 +++ backend/services/getGlobalModelService.js | 8 +- backend/services/pivotCatalogPurgeService.js | 59 +- .../services/pivotPosterTemplateService.js | 354 +++++++++++ backend/services/pivotTagSuggestService.js | 1 + .../unit/pivotCatalogPurgeService.test.js | 67 +- .../src/pages/InviteLanding/InviteLanding.jsx | 12 +- .../pages/InviteLanding/InviteLanding.scss | 20 + .../PlatformAdmin/PivotLab/PivotLabPage.jsx | 126 ++-- .../PivotInviteQRModal.jsx | 159 +++++ .../PivotInviteQRModal.scss | 141 +++++ .../PivotPosterDownloadModal.jsx | 160 +++++ .../PivotPosterDownloadModal.scss | 157 +++++ .../PivotPosterQrBoxEditor.jsx | 105 ++++ .../PivotPosterTemplatesModal.jsx | 322 ++++++++++ .../PivotPosterTemplatesModal.scss | 250 ++++++++ .../PivotReferralCodesPanel.jsx | 109 +++- .../PivotReferralCodesPanel.scss | 8 + 22 files changed, 2704 insertions(+), 113 deletions(-) create mode 100644 backend/schemas/pivotPosterTemplate.js create mode 100644 backend/services/pivotPosterTemplateService.js create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.jsx create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.scss create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.jsx create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.scss create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterQrBoxEditor.jsx create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.jsx create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.scss diff --git a/backend/package-lock.json b/backend/package-lock.json index 08418ff7..4af400b3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -42,6 +42,7 @@ "resend": "^4.0.1", "saml2": "^0.0.8", "samlify": "^2.10.0", + "sharp": "^0.35.3", "socket.io": "^4.7.5", "ws": "^8.18.0", "xml-crypto": "^6.1.2", @@ -669,10 +670,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "license": "MIT", "optional": true, "dependencies": { @@ -727,6 +727,506 @@ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", "license": "MIT" }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inngest/ai": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@inngest/ai/-/ai-0.1.6.tgz", @@ -5327,9 +5827,10 @@ } }, "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -10453,9 +10954,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10636,6 +11137,55 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index fbd2678e..ea8a3e66 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "resend": "^4.0.1", "saml2": "^0.0.8", "samlify": "^2.10.0", + "sharp": "^0.35.3", "socket.io": "^4.7.5", "ws": "^8.18.0", "xml-crypto": "^6.1.2", diff --git a/backend/routes/pivotAdminRoutes.js b/backend/routes/pivotAdminRoutes.js index 8d92291f..b08e424a 100644 --- a/backend/routes/pivotAdminRoutes.js +++ b/backend/routes/pivotAdminRoutes.js @@ -487,6 +487,7 @@ router.post('/dev/purge-catalog', verifyToken, requirePlatformAdmin, async (req, tenantKey: req.body?.tenantKey, confirm: req.body?.confirm, clearSnapshots: req.body?.clearSnapshots, + batchWeek: req.body?.batchWeek, }); if (result.error) { return res.status(result.status || 400).json({ diff --git a/backend/routes/platformTenantRoutes.js b/backend/routes/platformTenantRoutes.js index 8c711f5d..b1a36a7d 100644 --- a/backend/routes/platformTenantRoutes.js +++ b/backend/routes/platformTenantRoutes.js @@ -27,6 +27,14 @@ const { updateReferralCode, deleteReferralCode, } = require('../services/pivotReferralCodeService'); +const { + listPosterTemplates, + createPosterTemplate, + updatePosterTemplate, + deletePosterTemplate, + renderPoster, +} = require('../services/pivotPosterTemplateService'); +const { upload } = require('../services/imageUploadService'); const router = express.Router(); @@ -340,6 +348,110 @@ router.delete( } ); +router.get( + '/admin/platform/tenants/:tenantKey/pivot-poster-templates', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const result = await listPosterTemplates(req, tenantKey); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('GET pivot-poster-templates failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.post( + '/admin/platform/tenants/:tenantKey/pivot-poster-templates', + verifyToken, + requirePlatformAdmin, + upload.single('poster'), + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const result = await createPosterTemplate(req, tenantKey, req.file, req.body); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.status(201).json({ success: true, data: result }); + } catch (err) { + console.error('POST pivot-poster-templates failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.put( + '/admin/platform/tenants/:tenantKey/pivot-poster-templates/:id', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const id = String(req.params.id || '').trim(); + const result = await updatePosterTemplate(req, tenantKey, id, req.body); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('PUT pivot-poster-templates failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.delete( + '/admin/platform/tenants/:tenantKey/pivot-poster-templates/:id', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const id = String(req.params.id || '').trim(); + const result = await deletePosterTemplate(req, tenantKey, id); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('DELETE pivot-poster-templates failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + +router.get( + '/admin/platform/tenants/:tenantKey/pivot-poster-templates/:id/render', + verifyToken, + requirePlatformAdmin, + async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const id = String(req.params.id || '').trim(); + const code = String(req.query.code || '').trim(); + const origin = String(req.query.origin || '').trim(); + const result = await renderPoster(req, tenantKey, id, code, origin); + if (result.error) { + return res.status(result.status || 400).json({ success: false, message: result.error }); + } + res.set('Content-Type', 'image/png'); + res.set('Content-Disposition', `attachment; filename="${result.filename}"`); + res.set('Cache-Control', 'no-store'); + res.send(result.buffer); + } catch (err) { + console.error('GET pivot-poster-templates render failed:', err); + res.status(500).json({ success: false, message: err.message }); + } + } +); + router.post('/admin/platform/tenants/sync-cache', verifyToken, requirePlatformAdmin, async (req, res) => { try { const cache = await syncTenantUriCache(req); diff --git a/backend/schemas/pivotPosterTemplate.js b/backend/schemas/pivotPosterTemplate.js new file mode 100644 index 00000000..4a3cfa14 --- /dev/null +++ b/backend/schemas/pivotPosterTemplate.js @@ -0,0 +1,75 @@ +const mongoose = require('mongoose'); + +// A poster template is base artwork (stored in S3) plus a normalized square +// region where a per-code invite QR is stamped at render time. Coordinates are +// fractions of the poster's natural dimensions (0..1) so they are resolution +// independent: qrBox.x/y is the top-left of the square, qrBox.w is the side +// length as a fraction of the poster width (the region is always square in px). +const qrBoxSchema = new mongoose.Schema( + { + x: { type: Number, required: true, min: 0, max: 1, default: 0.5 }, + y: { type: Number, required: true, min: 0, max: 1, default: 0.5 }, + w: { type: Number, required: true, min: 0.02, max: 1, default: 0.25 }, + }, + { _id: false } +); + +const pivotPosterTemplateSchema = new mongoose.Schema( + { + tenantKey: { + type: String, + required: true, + trim: true, + lowercase: true, + }, + name: { + type: String, + required: true, + trim: true, + }, + imageUrl: { + type: String, + required: true, + }, + // S3 object key (folder/filename) so we can fetch/delete without relying on + // the poster being publicly readable. + imageKey: { + type: String, + required: true, + }, + // Natural pixel dimensions of the base artwork (from sharp metadata). + width: { type: Number, default: null }, + height: { type: Number, default: null }, + qrBox: { + type: qrBoxSchema, + required: true, + default: () => ({ x: 0.5, y: 0.5, w: 0.25 }), + }, + // QR foreground color. + qrColor: { + type: String, + default: '#1A1714', + trim: true, + }, + // When true, a rounded white card is drawn behind the QR for scannability on + // busy artwork; otherwise the QR is stamped with a transparent background. + plate: { + type: Boolean, + default: true, + }, + }, + { timestamps: true } +); + +pivotPosterTemplateSchema.pre('validate', function normalizeFields() { + if (this.tenantKey) { + this.tenantKey = String(this.tenantKey).trim().toLowerCase(); + } + if (this.name) { + this.name = String(this.name).trim(); + } +}); + +pivotPosterTemplateSchema.index({ tenantKey: 1, createdAt: -1 }); + +module.exports = pivotPosterTemplateSchema; diff --git a/backend/services/getGlobalModelService.js b/backend/services/getGlobalModelService.js index b7212b3b..7879a493 100644 --- a/backend/services/getGlobalModelService.js +++ b/backend/services/getGlobalModelService.js @@ -8,6 +8,7 @@ const pivotReferralRedemptionSchema = require('../schemas/pivotReferralRedemptio const pivotWeeklySnapshotSchema = require('../schemas/pivotWeeklySnapshot'); const pivotLabNotesSchema = require('../schemas/pivotLabNotes'); const pivotTagCatalogSchema = require('../schemas/pivotTagCatalog'); +const pivotPosterTemplateSchema = require('../schemas/pivotPosterTemplate'); /** * Get models from the global/platform DB (cross-tenant data). @@ -15,7 +16,7 @@ const pivotTagCatalogSchema = require('../schemas/pivotTagCatalog'); * Requires req.globalDb to be set (see app.js middleware). * * @param {object} req - request with req.globalDb - * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'PivotReferralCode', 'PivotReferralRedemption', 'PivotWeeklySnapshot', 'PivotLabNotes', 'PivotTagCatalog' + * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'PivotReferralCode', 'PivotReferralRedemption', 'PivotWeeklySnapshot', 'PivotLabNotes', 'PivotTagCatalog', 'PivotPosterTemplate' * @returns {object} map of requested models */ const getGlobalModels = (req, ...names) => { @@ -43,6 +44,11 @@ const getGlobalModels = (req, ...names) => { ), PivotLabNotes: db.model('PivotLabNotes', pivotLabNotesSchema, 'pivot_lab_notes'), PivotTagCatalog: db.model('PivotTagCatalog', pivotTagCatalogSchema, 'pivot_tag_catalog'), + PivotPosterTemplate: db.model( + 'PivotPosterTemplate', + pivotPosterTemplateSchema, + 'pivot_poster_templates' + ), }; return names.reduce((acc, name) => { diff --git a/backend/services/pivotCatalogPurgeService.js b/backend/services/pivotCatalogPurgeService.js index fb60b0b1..88df8132 100644 --- a/backend/services/pivotCatalogPurgeService.js +++ b/backend/services/pivotCatalogPurgeService.js @@ -4,15 +4,13 @@ const { getMergedTenants } = require('./tenantConfigService'); const { isPivotTenant } = require('./pivotReferralCodeService'); const { connectToDatabase } = require('../connectionsManager'); const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService'); +const { isValidIsoWeek } = require('../utilities/pivotIsoWeek'); const PURGE_CONFIRM_TOKEN = 'PURGE'; const PIVOT_CATALOG_EVENT_QUERY = { 'customFields.pivot': { $exists: true } }; -function isDevEnvironment() { - return process.env.NODE_ENV !== 'production'; -} - -async function purgeTenantPivotCatalog(tenantKey) { +async function purgeTenantPivotCatalog(tenantKey, options = {}) { + const batchWeek = options.batchWeek || null; const db = await connectToDatabase(tenantKey); const tenantReq = { db }; const { @@ -34,7 +32,11 @@ async function purgeTenantPivotCatalog(tenantKey) { 'AnalyticsEvent', ); - const events = await Event.find(PIVOT_CATALOG_EVENT_QUERY).select('_id').lean(); + const eventQuery = batchWeek + ? { ...PIVOT_CATALOG_EVENT_QUERY, 'customFields.pivot.batchWeek': batchWeek } + : PIVOT_CATALOG_EVENT_QUERY; + + const events = await Event.find(eventQuery).select('_id').lean(); const eventIds = events.map((event) => event._id); const eventIdStrings = eventIds.map(String); @@ -48,14 +50,25 @@ async function purgeTenantPivotCatalog(tenantKey) { analyticsEvents: 0, }; + // Attendee intents store batchWeek directly, so a weekly purge scopes to that field — + // this also cleans up intents whose event was already removed. A full purge falls back + // to event membership, and an empty filter sweeps any orphaned intents. const intentResult = await PivotEventIntent.deleteMany( - eventIds.length ? { eventId: { $in: eventIds } } : {}, + batchWeek + ? { batchWeek } + : eventIds.length + ? { eventId: { $in: eventIds } } + : {}, ); deleted.intents = intentResult.deletedCount || 0; const feedbackResult = await UniversalFeedback.deleteMany({ feature: PIVOT_EVENT_FEATURE, - ...(eventIds.length ? { processId: { $in: eventIds } } : {}), + ...(batchWeek + ? { 'metadata.batchWeek': batchWeek } + : eventIds.length + ? { processId: { $in: eventIds } } + : {}), }); deleted.feedback = feedbackResult.deletedCount || 0; @@ -81,21 +94,15 @@ async function purgeTenantPivotCatalog(tenantKey) { return deleted; } -async function purgeGlobalPivotSnapshots(req) { +async function purgeGlobalPivotSnapshots(req, options = {}) { const { PivotWeeklySnapshot } = getGlobalModels(req, 'PivotWeeklySnapshot'); - const result = await PivotWeeklySnapshot.deleteMany({}); + const result = await PivotWeeklySnapshot.deleteMany( + options.batchWeek ? { batchWeek: options.batchWeek } : {}, + ); return { weeklySnapshots: result.deletedCount || 0 }; } async function purgePivotCatalog(req, options = {}) { - if (!isDevEnvironment()) { - return { - error: 'Not available in production.', - status: 404, - code: 'NOT_FOUND', - }; - } - const confirm = options.confirm?.trim(); if (confirm !== PURGE_CONFIRM_TOKEN) { return { @@ -105,6 +112,15 @@ async function purgePivotCatalog(req, options = {}) { }; } + const batchWeek = options.batchWeek?.trim() || null; + if (batchWeek && !isValidIsoWeek(batchWeek)) { + return { + error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).', + status: 400, + code: 'INVALID_BATCH_WEEK', + }; + } + const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant); const tenantKeyFilter = options.tenantKey?.trim()?.toLowerCase(); @@ -123,7 +139,7 @@ async function purgePivotCatalog(req, options = {}) { const tenantResults = []; for (const tenant of tenantsToPurge) { - const counts = await purgeTenantPivotCatalog(tenant.tenantKey); + const counts = await purgeTenantPivotCatalog(tenant.tenantKey, { batchWeek }); tenantResults.push({ tenantKey: tenant.tenantKey, cityDisplayName: tenant.location || tenant.name || tenant.tenantKey, @@ -132,7 +148,7 @@ async function purgePivotCatalog(req, options = {}) { } const globalDeleted = - options.clearSnapshots === false ? {} : await purgeGlobalPivotSnapshots(req); + options.clearSnapshots === false ? {} : await purgeGlobalPivotSnapshots(req, { batchWeek }); const totals = tenantResults.reduce( (acc, row) => { @@ -146,6 +162,8 @@ async function purgePivotCatalog(req, options = {}) { return { data: { + batchWeek, + scope: batchWeek ? 'week' : 'all-weeks', tenants: tenantResults, totals, }, @@ -157,5 +175,4 @@ module.exports = { purgeTenantPivotCatalog, PURGE_CONFIRM_TOKEN, PIVOT_CATALOG_EVENT_QUERY, - isDevEnvironment, }; diff --git a/backend/services/pivotPosterTemplateService.js b/backend/services/pivotPosterTemplateService.js new file mode 100644 index 00000000..757f395c --- /dev/null +++ b/backend/services/pivotPosterTemplateService.js @@ -0,0 +1,354 @@ +const crypto = require('crypto'); +const path = require('path'); +const sharp = require('sharp'); +const QRCode = require('qrcode'); +const s3 = require('../aws-config'); +const getGlobalModels = require('./getGlobalModelService'); +const { getTenantByKey } = require('./tenantConfigService'); + +const POSTER_FOLDER = 'pivot-posters'; +const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + +function isPivotTenant(tenant) { + return tenant?.pivotPilot === true || tenant?.tenantType === 'pivot'; +} + +async function requirePivotTenant(req, tenantKey) { + const tenant = await getTenantByKey(req, tenantKey); + if (!tenant) { + return { error: 'Tenant not found.', status: 404 }; + } + if (!isPivotTenant(tenant)) { + return { error: 'Poster templates are only available for Pivot city tenants.', status: 403 }; + } + return { tenant }; +} + +function serializePosterTemplate(doc) { + const row = doc?.toObject ? doc.toObject() : doc; + return { + _id: String(row._id), + tenantKey: row.tenantKey, + name: row.name, + imageUrl: row.imageUrl, + width: row.width || null, + height: row.height || null, + qrBox: { + x: row.qrBox?.x ?? 0.5, + y: row.qrBox?.y ?? 0.5, + w: row.qrBox?.w ?? 0.25, + }, + qrColor: row.qrColor || '#1A1714', + plate: row.plate !== false, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function parseQrBox(raw) { + let box = raw; + if (typeof raw === 'string') { + try { + box = JSON.parse(raw); + } catch { + return { error: 'qrBox must be valid JSON.' }; + } + } + if (!box || typeof box !== 'object') return { error: 'qrBox is required.' }; + const x = Number(box.x); + const y = Number(box.y); + const w = Number(box.w); + if (![x, y, w].every(Number.isFinite)) { + return { error: 'qrBox.x, qrBox.y and qrBox.w must be numbers.' }; + } + if (x < 0 || x > 1 || y < 0 || y > 1) { + return { error: 'qrBox.x and qrBox.y must be between 0 and 1.' }; + } + if (w < 0.02 || w > 1) { + return { error: 'qrBox.w must be between 0.02 and 1.' }; + } + return { box: { x, y, w } }; +} + +function normalizeColor(raw, fallback = '#1A1714') { + const value = String(raw || '').trim(); + return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) ? value : fallback; +} + +function s3KeyForPoster(tenantKey, file) { + const ext = path.extname(file.originalname || '').toLowerCase() || '.png'; + const random = crypto.randomBytes(8).toString('hex'); + return `${POSTER_FOLDER}/${tenantKey}/${Date.now()}-${random}${ext}`; +} + +async function listPosterTemplates(req, tenantKey) { + const gate = await requirePivotTenant(req, tenantKey); + if (gate.error) return gate; + + const { PivotPosterTemplate } = getGlobalModels(req, 'PivotPosterTemplate'); + const docs = await PivotPosterTemplate.find({ tenantKey }).sort({ createdAt: -1 }).lean(); + return { tenantKey, templates: docs.map(serializePosterTemplate) }; +} + +async function createPosterTemplate(req, tenantKey, file, body = {}) { + const gate = await requirePivotTenant(req, tenantKey); + if (gate.error) return gate; + + if (!file) return { error: 'A poster image file is required.', status: 400 }; + if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { + return { error: 'Poster must be a JPEG, PNG, or WebP image.', status: 400 }; + } + + const name = String(body.name || '').trim(); + if (!name) return { error: 'Template name is required.', status: 400 }; + + const parsedBox = parseQrBox(body.qrBox); + if (parsedBox.error) return { error: parsedBox.error, status: 400 }; + + let meta; + try { + meta = await sharp(file.buffer).metadata(); + } catch { + return { error: 'Could not read the poster image.', status: 400 }; + } + + const key = s3KeyForPoster(tenantKey, file); + const uploaded = await s3 + .upload({ + Bucket: process.env.AWS_S3_BUCKET_NAME, + Key: key, + Body: file.buffer, + ContentType: file.mimetype, + ContentDisposition: 'inline', + CacheControl: 'public, max-age=31536000', + }) + .promise(); + + const { PivotPosterTemplate } = getGlobalModels(req, 'PivotPosterTemplate'); + const doc = await PivotPosterTemplate.create({ + tenantKey, + name, + imageUrl: uploaded.Location, + imageKey: key, + width: meta.width || null, + height: meta.height || null, + qrBox: parsedBox.box, + qrColor: normalizeColor(body.qrColor), + plate: body.plate === undefined ? true : body.plate === true || body.plate === 'true', + }); + + return { template: serializePosterTemplate(doc) }; +} + +async function updatePosterTemplate(req, tenantKey, id, body = {}) { + const gate = await requirePivotTenant(req, tenantKey); + if (gate.error) return gate; + + const { PivotPosterTemplate } = getGlobalModels(req, 'PivotPosterTemplate'); + const doc = await PivotPosterTemplate.findOne({ _id: id, tenantKey }); + if (!doc) return { error: 'Poster template not found.', status: 404 }; + + if (body.name !== undefined) { + const name = String(body.name || '').trim(); + if (!name) return { error: 'Template name cannot be empty.', status: 400 }; + doc.name = name; + } + if (body.qrBox !== undefined) { + const parsedBox = parseQrBox(body.qrBox); + if (parsedBox.error) return { error: parsedBox.error, status: 400 }; + doc.qrBox = parsedBox.box; + } + if (body.qrColor !== undefined) { + doc.qrColor = normalizeColor(body.qrColor, doc.qrColor); + } + if (body.plate !== undefined) { + doc.plate = body.plate === true || body.plate === 'true'; + } + + await doc.save(); + return { template: serializePosterTemplate(doc) }; +} + +async function deletePosterTemplate(req, tenantKey, id) { + const gate = await requirePivotTenant(req, tenantKey); + if (gate.error) return gate; + + const { PivotPosterTemplate } = getGlobalModels(req, 'PivotPosterTemplate'); + const doc = await PivotPosterTemplate.findOneAndDelete({ _id: id, tenantKey }); + if (!doc) return { error: 'Poster template not found.', status: 404 }; + + if (doc.imageKey) { + try { + await s3 + .deleteObject({ Bucket: process.env.AWS_S3_BUCKET_NAME, Key: doc.imageKey }) + .promise(); + } catch (err) { + // Non-fatal: the DB record is already gone; log and move on. + console.error('Failed to delete poster art from S3:', err.message); + } + } + return { deleted: true }; +} + +function resolveOrigin(requestedOrigin) { + const fallback = + process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : 'https://www.meridian.study'; + if (!requestedOrigin) return fallback; + try { + const parsed = new URL(requestedOrigin); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return fallback; + return parsed.origin; + } catch { + return fallback; + } +} + +function buildInviteUrl(code, requestedOrigin) { + return `${resolveOrigin(requestedOrigin)}/invite?code=${encodeURIComponent(code)}`; +} + +/** + * Build a rounded-style QR as an SVG string, matching the frontend "QR" option + * (qr-code-styling extra-rounded): data modules are drawn with corners rounded + * only where they have no filled neighbour (so runs connect into smooth shapes), + * and the three finder patterns are drawn as rounded square outlines with a + * rounded centre dot. Rasterized by sharp at composite time. + */ +function buildStyledQrSvg(text, { sizePx, color = '#1A1714', margin = 0 } = {}) { + const qr = QRCode.create(text, { errorCorrectionLevel: 'Q' }); + const N = qr.modules.size; + const data = qr.modules.data; + const total = N + margin * 2; + const m = sizePx / total; + const off = margin * m; + const get = (r, c) => (r < 0 || c < 0 || r >= N || c >= N ? 0 : data[r * N + c]); + const inFinder = (r, c) => + (r < 7 && c < 7) || (r < 7 && c >= N - 7) || (r >= N - 7 && c < 7); + const f = (n) => Math.round(n * 100) / 100; + + const s = m; + const r = m / 2; + let d = ''; + for (let row = 0; row < N; row++) { + for (let col = 0; col < N; col++) { + if (!get(row, col) || inFinder(row, col)) continue; + const px = col * m + off; + const py = row * m + off; + const rTL = !get(row - 1, col) && !get(row, col - 1); + const rTR = !get(row - 1, col) && !get(row, col + 1); + const rBR = !get(row + 1, col) && !get(row, col + 1); + const rBL = !get(row + 1, col) && !get(row, col - 1); + let p = `M ${f(px + (rTL ? r : 0))} ${f(py)} `; + p += `L ${f(px + s - (rTR ? r : 0))} ${f(py)} `; + p += rTR ? `A ${f(r)} ${f(r)} 0 0 1 ${f(px + s)} ${f(py + r)} ` : `L ${f(px + s)} ${f(py)} `; + p += `L ${f(px + s)} ${f(py + s - (rBR ? r : 0))} `; + p += rBR ? `A ${f(r)} ${f(r)} 0 0 1 ${f(px + s - r)} ${f(py + s)} ` : `L ${f(px + s)} ${f(py + s)} `; + p += `L ${f(px + (rBL ? r : 0))} ${f(py + s)} `; + p += rBL ? `A ${f(r)} ${f(r)} 0 0 1 ${f(px)} ${f(py + s - r)} ` : `L ${f(px)} ${f(py + s)} `; + p += `L ${f(px)} ${f(py + (rTL ? r : 0))} `; + p += rTL ? `A ${f(r)} ${f(r)} 0 0 1 ${f(px + r)} ${f(py)} ` : `L ${f(px)} ${f(py)} `; + d += `${p}Z `; + } + } + + const finders = [[0, 0], [0, N - 7], [N - 7, 0]]; + let finderSvg = ''; + for (const [r0, c0] of finders) { + const ox = c0 * m + off; + const oy = r0 * m + off; + finderSvg += + ``; + finderSvg += + ``; + } + + return ( + `` + + `${finderSvg}` + ); +} + +async function fetchPosterBuffer(doc) { + const obj = await s3 + .getObject({ Bucket: process.env.AWS_S3_BUCKET_NAME, Key: doc.imageKey }) + .promise(); + return obj.Body; +} + +/** + * Render a poster with the invite QR stamped into the template's box. + * @returns {{ buffer?: Buffer, filename?: string, error?: string, status?: number }} + */ +async function renderPoster(req, tenantKey, id, code, origin) { + const gate = await requirePivotTenant(req, tenantKey); + if (gate.error) return gate; + + const cleanCode = String(code || '').trim().toUpperCase(); + if (!cleanCode) return { error: 'A referral code is required.', status: 400 }; + + const { PivotPosterTemplate } = getGlobalModels(req, 'PivotPosterTemplate'); + const doc = await PivotPosterTemplate.findOne({ _id: id, tenantKey }).lean(); + if (!doc) return { error: 'Poster template not found.', status: 404 }; + + const posterBuffer = await fetchPosterBuffer(doc); + const base = sharp(posterBuffer); + const meta = await base.metadata(); + const W = meta.width; + const H = meta.height; + if (!W || !H) return { error: 'Poster image has no readable dimensions.', status: 400 }; + + const box = doc.qrBox || { x: 0.5, y: 0.5, w: 0.25 }; + const boxSize = Math.max(1, Math.round(box.w * W)); + let left = Math.round(box.x * W); + let top = Math.round(box.y * H); + left = Math.max(0, Math.min(left, W - boxSize)); + top = Math.max(0, Math.min(top, H - boxSize)); + + const inviteUrl = buildInviteUrl(cleanCode, origin); + const qrColor = normalizeColor(doc.qrColor); + const plate = doc.plate !== false; + + const composites = []; + let qrSize = boxSize; + let qrLeft = left; + let qrTop = top; + + if (plate) { + const pad = Math.max(2, Math.round(boxSize * 0.08)); + qrSize = Math.max(1, boxSize - pad * 2); + qrLeft = left + pad; + qrTop = top + pad; + const radius = Math.round(boxSize * 0.12); + const plateSvg = Buffer.from( + `` + + `` + + `` + ); + composites.push({ input: plateSvg, left, top }); + } + + // The white plate already supplies a quiet zone, so no internal margin is + // needed there; without a plate, add a couple of modules of quiet zone. + const qrSvg = buildStyledQrSvg(inviteUrl, { + sizePx: qrSize, + color: qrColor, + margin: plate ? 0 : 2, + }); + composites.push({ input: Buffer.from(qrSvg), left: qrLeft, top: qrTop }); + + const outBuffer = await base.composite(composites).png().toBuffer(); + const safeCode = cleanCode.replace(/[^a-z0-9]/gi, '-'); + const safeName = (doc.name || 'poster').replace(/[^a-z0-9]/gi, '-'); + return { buffer: outBuffer, filename: `${safeName}-${safeCode}.png` }; +} + +module.exports = { + listPosterTemplates, + createPosterTemplate, + updatePosterTemplate, + deletePosterTemplate, + renderPoster, + serializePosterTemplate, +}; diff --git a/backend/services/pivotTagSuggestService.js b/backend/services/pivotTagSuggestService.js index 038d836b..86310140 100644 --- a/backend/services/pivotTagSuggestService.js +++ b/backend/services/pivotTagSuggestService.js @@ -318,6 +318,7 @@ async function suggestPivotEventTagsBatch(req, rawEvents = []) { }); if (suggestedCount === 0 && failures.length > 0) { + console.log('failures', failures); return { error: failures[0].message, status: failures[0].code === 'LLM_NOT_CONFIGURED' ? 503 : 400, diff --git a/backend/tests/unit/pivotCatalogPurgeService.test.js b/backend/tests/unit/pivotCatalogPurgeService.test.js index 1c6dd0f9..a3e1d64d 100644 --- a/backend/tests/unit/pivotCatalogPurgeService.test.js +++ b/backend/tests/unit/pivotCatalogPurgeService.test.js @@ -18,23 +18,15 @@ const { isPivotTenant } = require('../../services/pivotReferralCodeService'); const { purgePivotCatalog, PURGE_CONFIRM_TOKEN, - isDevEnvironment, } = require('../../services/pivotCatalogPurgeService'); describe('pivotCatalogPurgeService', () => { - const originalNodeEnv = process.env.NODE_ENV; - beforeEach(() => { jest.clearAllMocks(); - process.env.NODE_ENV = 'development'; isPivotTenant.mockReturnValue(true); connectToDatabase.mockResolvedValue({}); }); - afterAll(() => { - process.env.NODE_ENV = originalNodeEnv; - }); - function buildTenantModels(eventIds = ['665a1b2c3d4e5f6789012345']) { const Event = { find: jest.fn().mockReturnValue({ @@ -84,13 +76,64 @@ describe('pivotCatalogPurgeService', () => { }; } - it('blocks purge in production', async () => { + it('runs in production (no environment gate)', async () => { process.env.NODE_ENV = 'production'; + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + ]); + buildTenantModels(); + getGlobalModels.mockReturnValue({ + PivotWeeklySnapshot: { + deleteMany: jest.fn().mockResolvedValue({ deletedCount: 1 }), + }, + }); + + const result = await purgePivotCatalog( + { globalDb: {} }, + { confirm: PURGE_CONFIRM_TOKEN, tenantKey: 'nyc' }, + ); - const result = await purgePivotCatalog({}, { confirm: PURGE_CONFIRM_TOKEN }); + expect(result.code).toBeUndefined(); + expect(result.data.tenants).toHaveLength(1); + }); - expect(result.code).toBe('NOT_FOUND'); - expect(isDevEnvironment()).toBe(false); + it('rejects an invalid batchWeek', async () => { + const result = await purgePivotCatalog( + {}, + { confirm: PURGE_CONFIRM_TOKEN, batchWeek: 'not-a-week' }, + ); + + expect(result.code).toBe('INVALID_BATCH_WEEK'); + }); + + it('scopes a weekly purge to the batch week', async () => { + getMergedTenants.mockResolvedValue([ + { tenantKey: 'nyc', tenantType: 'pivot', location: 'New York City' }, + ]); + const models = buildTenantModels(); + const snapshotDeleteMany = jest.fn().mockResolvedValue({ deletedCount: 1 }); + getGlobalModels.mockReturnValue({ + PivotWeeklySnapshot: { deleteMany: snapshotDeleteMany }, + }); + + const result = await purgePivotCatalog( + { globalDb: {} }, + { confirm: PURGE_CONFIRM_TOKEN, tenantKey: 'nyc', batchWeek: '2026-W21' }, + ); + + expect(result.data.batchWeek).toBe('2026-W21'); + expect(result.data.scope).toBe('week'); + expect(models.Event.find).toHaveBeenCalledWith( + expect.objectContaining({ 'customFields.pivot.batchWeek': '2026-W21' }), + ); + expect(models.PivotEventIntent.deleteMany).toHaveBeenCalledWith({ + batchWeek: '2026-W21', + }); + expect(models.UniversalFeedback.deleteMany).toHaveBeenCalledWith({ + feature: 'pivot_event', + 'metadata.batchWeek': '2026-W21', + }); + expect(snapshotDeleteMany).toHaveBeenCalledWith({ batchWeek: '2026-W21' }); }); it('requires PURGE confirmation token', async () => { diff --git a/frontend/src/pages/InviteLanding/InviteLanding.jsx b/frontend/src/pages/InviteLanding/InviteLanding.jsx index 5dc37354..cf79bba1 100644 --- a/frontend/src/pages/InviteLanding/InviteLanding.jsx +++ b/frontend/src/pages/InviteLanding/InviteLanding.jsx @@ -191,11 +191,10 @@ function InviteLanding() { alt="just go" /> -

your city, one tap at a time

+

don't overthink, just go!

just go is an experiment from Meridian. For this pilot - you'll use Meridian Go — our campus app on iPhone — - and your invite code unlocks the just go experience inside it. Android + you'll use Meridian Go and your invite code unlocks the just go experience inside it. Android isn't supported yet.

@@ -258,8 +257,7 @@ function InviteLanding() {

download Meridian Go (iPhone)

- grab Meridian Go from the App Store — it's the same Meridian - app students use on campus. your invite code switches you into + grab Meridian Go from the App Store, your invite code switches you into just go after install.

{isAndroid ? ( @@ -278,8 +276,7 @@ function InviteLanding() {

come back and tap open

- after installing, return here and open just go — your code - carries over. + after installing, click the button below to open just go.

{deepLink ? ( @@ -308,6 +305,7 @@ function InviteLanding() {
) : null} +

contact raven@meridian.study to leave feedback or feature requests.

); diff --git a/frontend/src/pages/InviteLanding/InviteLanding.scss b/frontend/src/pages/InviteLanding/InviteLanding.scss index 79ec0068..4a6716c5 100644 --- a/frontend/src/pages/InviteLanding/InviteLanding.scss +++ b/frontend/src/pages/InviteLanding/InviteLanding.scss @@ -150,6 +150,26 @@ font-weight: 700; } +.invite-landing__note { + margin: 0; + font-size: 0.72rem; + line-height: 1.55; + color: var(--jg-fg-muted); + font-weight: 400; + text-align: center; + margin-top: 1rem; +} + +.invite-landing__note a { + color: var(--jg-fg); + text-decoration: none; + font-weight: 700; +} + +.invite-landing__note a:hover { + text-decoration: underline; +} + .invite-landing__title { margin: 0 0 1rem; font-size: 1.35rem; diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx index 295bccc5..aff49f05 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx @@ -31,7 +31,6 @@ import './PivotDeckCardPreview.scss'; const EMPTY_LIST = []; const NO_FETCH_CACHE = { enabled: false }; -const IS_DEV = process.env.NODE_ENV !== 'production'; const PURGE_CONFIRM_TOKEN = 'PURGE'; const PIVOT_JSON_IMPORT_EXAMPLE = `{ @@ -612,6 +611,7 @@ function PivotLabPage() { const [editSaving, setEditSaving] = useState(false); const [deckPreviewState, setDeckPreviewState] = useState(null); const [purgeScope, setPurgeScope] = useState('selected'); + const [purgeWeekScope, setPurgeWeekScope] = useState('week'); const [purgeConfirm, setPurgeConfirm] = useState(''); const [purgingCatalog, setPurgingCatalog] = useState(false); const [manualImportOpen, setManualImportOpen] = useState(false); @@ -835,9 +835,11 @@ function PivotLabPage() { selectedTenantKey || 'this city'; + const weekLabel = purgeWeekScope === 'week' ? batchWeek : 'every batch week'; + if ( !window.confirm( - `Permanently delete all pivot catalog events and related intents, feedback, and analytics for ${scopeLabel}? This cannot be undone.`, + `Permanently delete pivot catalog events for ${weekLabel} and related intents, feedback, and analytics for ${scopeLabel}? This cannot be undone.`, ) ) { return; @@ -849,6 +851,7 @@ function PivotLabPage() { data: { confirm: PURGE_CONFIRM_TOKEN, tenantKey: purgeScope === 'all' ? undefined : selectedTenantKey || undefined, + batchWeek: purgeWeekScope === 'week' ? batchWeek : undefined, clearSnapshots: true, }, }); @@ -864,18 +867,21 @@ function PivotLabPage() { } const totals = data.data?.totals || {}; + const purgedWeekLabel = purgeWeekScope === 'week' ? ` for ${batchWeek}` : ' across all weeks'; setPurgeConfirm(''); refetchOverview(); refetchEvents(); addNotification({ title: 'Catalog purged', - message: `Removed ${totals.events ?? 0} events, ${totals.intents ?? 0} intents, and ${totals.feedback ?? 0} feedback rows.`, + message: `Removed ${totals.events ?? 0} events, ${totals.intents ?? 0} intents, and ${totals.feedback ?? 0} feedback rows${purgedWeekLabel}.`, type: 'success', }); }, [ addNotification, + batchWeek, purgeConfirm, purgeScope, + purgeWeekScope, refetchEvents, refetchOverview, selectedTenantKey, @@ -2611,57 +2617,71 @@ function PivotLabPage() { - {IS_DEV ? ( -
-

- Dev tools -

-

- Development only. Deletes all pivot catalog events (every batch week), attendee intents, - event feedback, and stored weekly snapshots. Referral codes and interview notes are kept. -

-
- - -
-
- +
+
) : null} diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.jsx new file mode 100644 index 00000000..f47f961a --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.jsx @@ -0,0 +1,159 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Icon } from '@iconify-icon/react'; +import Popup from '../../../../components/Popup/Popup'; +import './PivotInviteQRModal.scss'; + +// just go theme palette (see InviteLanding.scss). The ink is the default / current color. +const DEFAULT_FG = '#1A1714'; +const QR_SWATCHES = [ + { label: 'just go ink', value: '#1A1714' }, + { label: 'white', value: '#FFFFFF' }, + { label: 'accent', value: '#FF4F1F' }, + { label: 'burst', value: '#FF2A2A' }, + { label: 'pop', value: '#FFD23F' }, + { label: 'ticker', value: '#4AB5FF' }, +]; + +export function buildInviteLink(code) { + if (!code) return ''; + return `${window.location.origin}/invite?code=${encodeURIComponent(code)}`; +} + +function qrOptions(url, { size, type, fgColor }) { + return { + width: size, + height: size, + type, + data: url, + dotsOptions: { color: fgColor, type: 'extra-rounded' }, + backgroundOptions: { color: 'transparent' }, + cornersSquareOptions: { type: 'extra-rounded', color: fgColor }, + cornersDotOptions: { type: 'extra-rounded', color: fgColor }, + }; +} + +function StyledInviteQR({ url, fgColor, size = 240 }) { + const containerRef = useRef(null); + + useEffect(() => { + const node = containerRef.current; + if (!url || !node) return undefined; + let cancelled = false; + (async () => { + const { default: QRCodeStyling } = await import('qr-code-styling'); + if (cancelled || !node) return; + const qr = new QRCodeStyling(qrOptions(url, { size, type: 'svg', fgColor })); + node.innerHTML = ''; + qr.append(node); + })(); + return () => { + cancelled = true; + node.innerHTML = ''; + }; + }, [url, fgColor, size]); + + return
; +} + +function PivotInviteQRModal({ code, isOpen, onClose, onNotify }) { + const url = buildInviteLink(code); + const [fgColor, setFgColor] = useState(DEFAULT_FG); + const [downloading, setDownloading] = useState(false); + + // Reset the color each time a different code's modal is opened. + useEffect(() => { + if (isOpen) setFgColor(DEFAULT_FG); + }, [isOpen, code]); + + const handleCopyLink = useCallback(async () => { + if (!url) return; + try { + await navigator.clipboard.writeText(url); + onNotify?.({ title: 'Copied', message: 'Invite link copied to clipboard', type: 'success' }); + } catch { + onNotify?.({ title: 'Copy failed', message: 'Could not copy invite link', type: 'error' }); + } + }, [url, onNotify]); + + const handleDownload = useCallback(async () => { + if (!url) return; + setDownloading(true); + try { + const { default: QRCodeStyling } = await import('qr-code-styling'); + const qr = new QRCodeStyling(qrOptions(url, { size: 1024, type: 'png', fgColor })); + const blob = await qr.getRawData('png'); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `invite-${(code || 'code').replace(/[^a-z0-9]/gi, '-')}.png`; + a.click(); + URL.revokeObjectURL(a.href); + } catch { + onNotify?.({ title: 'Download failed', message: 'Could not generate QR image', type: 'error' }); + } finally { + setDownloading(false); + } + }, [url, code, fgColor, onNotify]); + + return ( + +
+
+

Invite QR

+

+ Scan to open the just go invite page with {code} prefilled. +

+
+ +
+ {url ? : null} +
+ +
+ {QR_SWATCHES.map((swatch) => { + const selected = fgColor.toLowerCase() === swatch.value.toLowerCase(); + return ( + + ); + })} +
+
+ {url} +
+ +
+ + +
+
+
+ ); +} + +export default PivotInviteQRModal; diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.scss b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.scss new file mode 100644 index 00000000..24125a24 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.scss @@ -0,0 +1,141 @@ +.popup-overlay .popup-content.pivot-invite-qr__shell { + --la-bg: #f3f4f6; + --la-surface: #ffffff; + --la-surface-inset: #f9fafb; + --la-border: #d1d5db; + --la-border-subtle: #e5e7eb; + --la-text: #171717; + --la-text-secondary: #6b7280; + --la-text-tertiary: #9ca3af; + --la-accent: #5e6ad2; + --la-accent-soft: rgba(94, 106, 210, 0.14); + --la-error: #dc2626; + --la-radius: 10px; + --la-radius-sm: 8px; + + max-width: 380px; + width: calc(100% - 32px); + padding: 0; + overflow: hidden; + background: var(--la-surface); + border: 1px solid var(--la-border-subtle); +} + +.pivot-invite-qr { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px 20px 20px; + color: var(--la-text); + + &__head { + text-align: center; + margin-bottom: 18px; + } + + &__title { + margin: 0; + font-size: 18px; + font-weight: 600; + } + + &__subtitle { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.45; + color: var(--la-text-secondary); + } + + &__frame { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + border-radius: var(--la-radius); + border: 1px solid var(--la-border-subtle); + // Checkerboard signals the transparent background and keeps both + // dark and white QR foregrounds visible in the preview. + background-color: #ffffff; + background-image: + linear-gradient(45deg, #e2e5ea 25%, transparent 25%), + linear-gradient(-45deg, #e2e5ea 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e2e5ea 75%), + linear-gradient(-45deg, transparent 75%, #e2e5ea 75%); + background-size: 16px 16px; + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + } + + &__canvas { + display: block; + } + + &__swatches { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + margin-top: 16px; + } + + &__swatch { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border-radius: 999px; + background: var(--swatch); + border: 1px solid rgba(0, 0, 0, 0.18); + box-shadow: 0 0 0 2px transparent; + cursor: pointer; + transition: box-shadow 0.12s ease, transform 0.12s ease; + color: #ffffff; + + iconify-icon { + font-size: 15px; + // Contrast check tuned for light swatches (white / pop yellow). + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.55)); + } + + &:hover { + transform: translateY(-1px); + } + + &.is-selected { + box-shadow: 0 0 0 2px var(--la-accent); + } + } + + &__swatch-note { + margin: 8px 0 0; + font-size: 12px; + color: var(--la-text-tertiary); + } + + &__link { + margin-top: 16px; + max-width: 100%; + padding: 8px 10px; + border-radius: var(--la-radius-sm); + background: var(--la-surface-inset); + border: 1px solid var(--la-border-subtle); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: var(--la-text-secondary); + word-break: break-all; + text-align: center; + } + + &__actions { + display: flex; + gap: 8px; + margin-top: 18px; + width: 100%; + + .linear-btn { + flex: 1; + justify-content: center; + } + } +} diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.jsx new file mode 100644 index 00000000..1b7d22ff --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.jsx @@ -0,0 +1,160 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Icon } from '@iconify-icon/react'; +import Popup from '../../../../components/Popup/Popup'; +import apiRequest from '../../../../utils/postRequest'; +import './PivotPosterDownloadModal.scss'; + +function PivotPosterDownloadModal({ code, tenantKey, templates, isOpen, onClose, onNotify, onManage }) { + const baseUrl = `/admin/platform/tenants/${tenantKey}/pivot-poster-templates`; + const objectUrlRef = useRef(null); + + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + const [error, setError] = useState(null); + + const revoke = () => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + }; + + const selectedTemplate = (templates || []).find((t) => t._id === selectedId) || null; + + const loadPreview = useCallback( + async (templateId) => { + if (!templateId || !code) return; + setLoading(true); + setError(null); + revoke(); + setPreviewUrl(null); + const res = await apiRequest(`${baseUrl}/${templateId}/render`, null, { + method: 'GET', + responseType: 'blob', + params: { code, origin: window.location.origin }, + }); + setLoading(false); + if (res instanceof Blob) { + const url = URL.createObjectURL(res); + objectUrlRef.current = url; + setPreviewUrl(url); + } else { + setError(res?.error || 'Could not render this poster.'); + } + }, + [baseUrl, code] + ); + + // On open (or code change) select the first template and render it. + useEffect(() => { + if (!isOpen) return; + const first = (templates || [])[0]; + if (first) { + setSelectedId(first._id); + loadPreview(first._id); + } else { + setSelectedId(null); + revoke(); + setPreviewUrl(null); + setError(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, code]); + + useEffect(() => () => revoke(), []); + + const handleSelect = (templateId) => { + if (templateId === selectedId) return; + setSelectedId(templateId); + loadPreview(templateId); + }; + + const handleDownload = () => { + if (!previewUrl || !selectedTemplate) return; + const safeName = (selectedTemplate.name || 'poster').replace(/[^a-z0-9]/gi, '-'); + const safeCode = (code || 'code').replace(/[^a-z0-9]/gi, '-'); + const a = document.createElement('a'); + a.href = previewUrl; + a.download = `${safeName}-${safeCode}.png`; + a.click(); + onNotify?.({ title: 'Downloaded', message: `${selectedTemplate.name} · ${code}`, type: 'success' }); + }; + + const hasTemplates = (templates || []).length > 0; + + return ( + +
+
+

Poster for {code}

+

+ Pick a template — the invite QR for this code is stamped in automatically. +

+
+ + {!hasTemplates ? ( +
+

No poster templates yet.

+ {onManage ? ( + + ) : null} +
+ ) : ( + <> +
+ {templates.map((tpl) => ( + + ))} +
+ +
+ {loading ? ( +
+ + Rendering… +
+ ) : error ? ( +
+ {error} +
+ ) : previewUrl ? ( + {`Poster + ) : null} +
+ +
+ {onManage ? ( + + ) : null} + +
+ + )} +
+
+ ); +} + +export default PivotPosterDownloadModal; diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.scss b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.scss new file mode 100644 index 00000000..e2b75eaf --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.scss @@ -0,0 +1,157 @@ +.popup-overlay .popup-content.pivot-poster-dl__shell { + --la-surface: #ffffff; + --la-surface-inset: #f9fafb; + --la-border: #d1d5db; + --la-border-subtle: #e5e7eb; + --la-text: #171717; + --la-text-secondary: #6b7280; + --la-text-tertiary: #9ca3af; + --la-accent: #5e6ad2; + --la-error: #dc2626; + --la-radius: 10px; + --la-radius-sm: 8px; + + max-width: 460px; + width: calc(100% - 32px); + max-height: min(90vh, 820px); + padding: 0; + overflow: hidden; + background: var(--la-surface); + border: 1px solid var(--la-border-subtle); +} + +.pivot-poster-dl { + display: flex; + flex-direction: column; + max-height: min(90vh, 820px); + overflow-y: auto; + padding: 22px 20px 20px; + color: var(--la-text); + + &__head { + margin-bottom: 14px; + } + + &__title { + margin: 0; + font-size: 18px; + font-weight: 600; + } + + &__subtitle { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.45; + color: var(--la-text-secondary); + } + + &__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 28px 16px; + text-align: center; + font-size: 13px; + color: var(--la-text-tertiary); + border: 1px dashed var(--la-border); + border-radius: var(--la-radius); + + p { + margin: 0; + } + } + + &__templates { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 4px; + margin-bottom: 14px; + } + + &__template { + flex: 0 0 auto; + width: 78px; + padding: 6px; + border: 1px solid var(--la-border-subtle); + border-radius: var(--la-radius-sm); + background: var(--la-surface); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + transition: border-color 0.12s ease, box-shadow 0.12s ease; + + img { + width: 100%; + aspect-ratio: 3 / 4; + object-fit: cover; + border-radius: 4px; + background: var(--la-surface-inset); + } + + span { + font-size: 11px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--la-text-secondary); + } + + &.is-selected { + border-color: var(--la-accent); + box-shadow: 0 0 0 1px var(--la-accent); + } + } + + &__preview { + display: flex; + align-items: center; + justify-content: center; + min-height: 220px; + padding: 12px; + border-radius: var(--la-radius); + background: var(--la-surface-inset); + border: 1px solid var(--la-border-subtle); + + img { + max-width: 100%; + max-height: 380px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + } + } + + &__preview-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--la-text-secondary); + + &--error { + color: var(--la-error); + } + } + + &__spinner { + animation: pivot-poster-dl-spin 0.8s linear infinite; + font-size: 18px; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + } +} + +@keyframes pivot-poster-dl-spin { + to { + transform: rotate(360deg); + } +} diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterQrBoxEditor.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterQrBoxEditor.jsx new file mode 100644 index 00000000..3d629da7 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterQrBoxEditor.jsx @@ -0,0 +1,105 @@ +import React, { useCallback, useRef } from 'react'; + +const MIN_W = 0.05; // minimum QR box side as a fraction of poster width + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function round(value) { + return Math.round(value * 10000) / 10000; +} + +/** + * Drag-to-place editor for the QR box. `value` is a normalized square region + * { x, y, w } where x/y are the top-left as fractions of the poster's width and + * height, and w is the side length as a fraction of poster width (the box is + * always square in pixels). Drag the box to move it, drag the corner to resize. + */ +function PivotPosterQrBoxEditor({ imageSrc, value, onChange, qrColor = '#1A1714', plate = true }) { + const containerRef = useRef(null); + const dragRef = useRef(null); + + const handlePointerMove = useCallback( + (e) => { + const drag = dragRef.current; + const rect = containerRef.current?.getBoundingClientRect(); + if (!drag || !rect) return; + + if (drag.mode === 'move') { + const dx = (e.clientX - drag.startX) / rect.width; + const dy = (e.clientY - drag.startY) / rect.height; + const hFrac = (drag.startBox.w * rect.width) / rect.height; + const x = clamp(drag.startBox.x + dx, 0, 1 - drag.startBox.w); + const y = clamp(drag.startBox.y + dy, 0, 1 - hFrac); + onChange({ x: round(x), y: round(y), w: round(drag.startBox.w) }); + } else if (drag.mode === 'resize') { + const leftPx = drag.startBox.x * rect.width; + const topPx = drag.startBox.y * rect.height; + const maxPx = Math.min(rect.width - leftPx, rect.height - topPx); + const sizePx = clamp(e.clientX - rect.left - leftPx, MIN_W * rect.width, maxPx); + onChange({ x: round(drag.startBox.x), y: round(drag.startBox.y), w: round(sizePx / rect.width) }); + } + }, + [onChange] + ); + + const endDrag = useCallback(() => { + dragRef.current = null; + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', endDrag); + }, [handlePointerMove]); + + const startDrag = useCallback( + (mode, e) => { + e.preventDefault(); + e.stopPropagation(); + dragRef.current = { mode, startX: e.clientX, startY: e.clientY, startBox: { ...value } }; + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', endDrag); + }, + [value, handlePointerMove, endDrag] + ); + + const boxStyle = { + left: `${value.x * 100}%`, + top: `${value.y * 100}%`, + // Square in pixels: side is a fraction of the container's width. + width: `${value.w * 100}%`, + aspectRatio: '1 / 1', + }; + + return ( +
+ Poster preview +
startDrag('move', e)} + role="button" + tabIndex={0} + aria-label="Drag to position QR" + > +
+ QR +
+ startDrag('resize', e)} + role="button" + tabIndex={0} + aria-label="Drag to resize QR" + /> +
+
+ ); +} + +export default PivotPosterQrBoxEditor; diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.jsx new file mode 100644 index 00000000..6832216b --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.jsx @@ -0,0 +1,322 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { Icon } from '@iconify-icon/react'; +import Popup from '../../../../components/Popup/Popup'; +import { authenticatedRequest } from '../../../../hooks/useFetch'; +import PivotPosterQrBoxEditor from './PivotPosterQrBoxEditor'; +import './PivotPosterTemplatesModal.scss'; + +// just go theme palette (mirrors PivotInviteQRModal / InviteLanding.scss). +const QR_SWATCHES = [ + { label: 'just go ink', value: '#1A1714' }, + { label: 'white', value: '#FFFFFF' }, + { label: 'accent', value: '#FF4F1F' }, + { label: 'burst', value: '#FF2A2A' }, + { label: 'pop', value: '#FFD23F' }, + { label: 'ticker', value: '#4AB5FF' }, +]; + +const DEFAULT_BOX = { x: 0.36, y: 0.4, w: 0.28 }; + +function centeredBox(naturalWidth, naturalHeight, w = 0.28) { + const aspect = naturalWidth && naturalHeight ? naturalWidth / naturalHeight : 1; + const hFrac = Math.min(0.96, w * aspect); + return { x: (1 - w) / 2, y: Math.max(0, (1 - hFrac) / 2), w }; +} + +function SwatchRow({ value, onChange }) { + return ( +
+ {QR_SWATCHES.map((swatch) => { + const selected = value.toLowerCase() === swatch.value.toLowerCase(); + return ( + + ); + })} +
+ ); +} + +function TemplateCard({ template, onEdit, onDelete }) { + return ( +
+
+ {template.name} + +
+
+ {template.name} +
+ + +
+
+
+ ); +} + +function PivotPosterTemplatesModal({ tenantKey, templates, isOpen, onClose, onRefetch, onNotify }) { + const baseUrl = `/admin/platform/tenants/${tenantKey}/pivot-poster-templates`; + const fileInputRef = useRef(null); + const objectUrlRef = useRef(null); + + const [view, setView] = useState('list'); // list | create | edit + const [editingId, setEditingId] = useState(null); + const [file, setFile] = useState(null); + const [imageSrc, setImageSrc] = useState(null); + const [name, setName] = useState(''); + const [box, setBox] = useState(DEFAULT_BOX); + const [qrColor, setQrColor] = useState('#1A1714'); + const [plate, setPlate] = useState(true); + const [saving, setSaving] = useState(false); + + const revokeObjectUrl = () => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + }; + + const resetForm = useCallback(() => { + revokeObjectUrl(); + setView('list'); + setEditingId(null); + setFile(null); + setImageSrc(null); + setName(''); + setBox(DEFAULT_BOX); + setQrColor('#1A1714'); + setPlate(true); + }, []); + + const handleClose = useCallback(() => { + resetForm(); + onClose(); + }, [resetForm, onClose]); + + const handleFilePicked = (e) => { + const picked = e.target.files?.[0]; + e.target.value = ''; + if (!picked) return; + revokeObjectUrl(); + const url = URL.createObjectURL(picked); + objectUrlRef.current = url; + setFile(picked); + setImageSrc(url); + setName(picked.name.replace(/\.[^.]+$/, '')); + setQrColor('#1A1714'); + setPlate(true); + setBox(DEFAULT_BOX); + setView('create'); + // Center the box once we know the aspect ratio. + const img = new Image(); + img.onload = () => setBox(centeredBox(img.naturalWidth, img.naturalHeight)); + img.src = url; + }; + + const startEdit = (template) => { + revokeObjectUrl(); + setEditingId(template._id); + setImageSrc(template.imageUrl); + setName(template.name); + setBox(template.qrBox); + setQrColor(template.qrColor || '#1A1714'); + setPlate(template.plate !== false); + setFile(null); + setView('edit'); + }; + + const handleCreate = async () => { + if (!file || !name.trim()) { + onNotify?.({ title: 'Missing info', message: 'A poster image and name are required.', type: 'error' }); + return; + } + setSaving(true); + const form = new FormData(); + form.append('poster', file); + form.append('name', name.trim()); + form.append('qrBox', JSON.stringify(box)); + form.append('qrColor', qrColor); + form.append('plate', String(plate)); + const { data: res, error } = await authenticatedRequest(baseUrl, { method: 'POST', data: form }); + setSaving(false); + if (error || !res?.success) { + onNotify?.({ title: 'Upload failed', message: res?.message || error || 'Could not save template', type: 'error' }); + return; + } + onNotify?.({ title: 'Template saved', message: name.trim(), type: 'success' }); + resetForm(); + onRefetch?.(); + }; + + const handleUpdate = async () => { + if (!editingId || !name.trim()) return; + setSaving(true); + const { data: res, error } = await authenticatedRequest(`${baseUrl}/${editingId}`, { + method: 'PUT', + data: { name: name.trim(), qrBox: box, qrColor, plate }, + headers: { 'Content-Type': 'application/json' }, + }); + setSaving(false); + if (error || !res?.success) { + onNotify?.({ title: 'Update failed', message: res?.message || error || 'Could not update template', type: 'error' }); + return; + } + onNotify?.({ title: 'Template updated', message: name.trim(), type: 'success' }); + resetForm(); + onRefetch?.(); + }; + + const handleDelete = async (template) => { + if (!window.confirm(`Delete poster template "${template.name}"?`)) return; + const { data: res, error } = await authenticatedRequest(`${baseUrl}/${template._id}`, { method: 'DELETE' }); + if (error || !res?.success) { + onNotify?.({ title: 'Delete failed', message: res?.message || error || 'Could not delete template', type: 'error' }); + return; + } + onNotify?.({ title: 'Deleted', message: template.name, type: 'success' }); + onRefetch?.(); + }; + + const isEditor = view === 'create' || view === 'edit'; + + return ( + +
+
+

Poster templates

+

+ Upload poster art and place the QR box once. Each referral code can then be exported as a + ready-to-print poster with its own invite QR stamped in. +

+
+ + + + {view === 'list' ? ( + <> +
+ {(templates || []).length === 0 ? ( +

No poster templates yet. Add one to get started.

+ ) : ( + templates.map((tpl) => ( + + )) + )} +
+
+ +
+ + ) : null} + + {isEditor ? ( +
+
+ {imageSrc ? ( + + ) : null} +

+ Drag the box to position the QR; drag its corner to resize. +

+
+ +
+ + +
+ QR color + +
+ + +

+ Keep the plate on for busy artwork so the QR stays scannable. Turn it off to drop the QR + straight onto a clean area (transparent background). +

+ +
+ + +
+
+
+ ) : null} +
+
+ ); +} + +export default PivotPosterTemplatesModal; diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.scss b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.scss new file mode 100644 index 00000000..20335aa8 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.scss @@ -0,0 +1,250 @@ +.popup-overlay .popup-content.pivot-poster-tpl__shell { + --la-bg: #f3f4f6; + --la-surface: #ffffff; + --la-surface-inset: #f9fafb; + --la-border: #d1d5db; + --la-border-subtle: #e5e7eb; + --la-text: #171717; + --la-text-secondary: #6b7280; + --la-text-tertiary: #9ca3af; + --la-accent: #5e6ad2; + --la-accent-soft: rgba(94, 106, 210, 0.14); + --la-error: #dc2626; + --la-radius: 10px; + --la-radius-sm: 8px; + + max-width: 680px; + width: calc(100% - 32px); + max-height: min(90vh, 860px); + padding: 0; + overflow: hidden; + background: var(--la-surface); + border: 1px solid var(--la-border-subtle); +} + +.pivot-poster-tpl { + display: flex; + flex-direction: column; + max-height: min(90vh, 860px); + overflow-y: auto; + padding: 22px 20px 20px; + color: var(--la-text); + + &__head { + margin-bottom: 16px; + } + + &__title { + margin: 0; + font-size: 18px; + font-weight: 600; + } + + &__subtitle { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.45; + color: var(--la-text-secondary); + } + + &__file { + display: none; + } + + &__empty { + grid-column: 1 / -1; + margin: 0; + padding: 24px; + text-align: center; + font-size: 13px; + color: var(--la-text-tertiary); + border: 1px dashed var(--la-border); + border-radius: var(--la-radius); + } + + &__list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 14px; + margin-bottom: 16px; + } + + &__card { + border: 1px solid var(--la-border-subtle); + border-radius: var(--la-radius); + overflow: hidden; + background: var(--la-surface); + } + + &__thumb { + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + background: var(--la-surface-inset); + + img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + } + + &__thumb-box { + position: absolute; + border: 2px solid var(--la-text); + border-radius: 4px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6); + pointer-events: none; + } + + &__card-body { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + } + + &__card-name { + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__card-actions { + display: flex; + gap: 2px; + flex-shrink: 0; + } + + &__delete { + color: var(--la-error); + } + + &__editor { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(200px, 240px); + gap: 20px; + + @media (max-width: 620px) { + grid-template-columns: 1fr; + } + } + + &__editor-main { + min-width: 0; + } + + &__editor-hint, + &__editor-note { + margin: 10px 0 0; + font-size: 12px; + line-height: 1.45; + color: var(--la-text-tertiary); + } + + &__editor-fields { + display: flex; + flex-direction: column; + gap: 14px; + } + + &__swatches { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; + } + + &__swatch { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border-radius: 999px; + background: var(--swatch); + border: 1px solid rgba(0, 0, 0, 0.18); + cursor: pointer; + color: #ffffff; + transition: box-shadow 0.12s ease, transform 0.12s ease; + + iconify-icon { + font-size: 14px; + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.55)); + } + + &:hover { + transform: translateY(-1px); + } + + &.is-selected { + box-shadow: 0 0 0 2px var(--la-accent); + } + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; + } +} + +// Drag-box editor +.pivot-poster-editor__stage { + position: relative; + width: 100%; + border-radius: var(--la-radius); + overflow: hidden; + border: 1px solid var(--la-border-subtle); + background: var(--la-surface-inset); + user-select: none; + touch-action: none; +} + +.pivot-poster-editor__img { + display: block; + width: 100%; + height: auto; + pointer-events: none; +} + +.pivot-poster-editor__box { + position: absolute; + border: 2px solid var(--la-accent); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.7), 0 0 0 9999px rgba(15, 23, 42, 0.28); + cursor: move; + box-sizing: border-box; +} + +.pivot-poster-editor__qr-preview { + position: absolute; + inset: 8%; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid; + border-radius: 8px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + opacity: 0.92; +} + +.pivot-poster-editor__handle { + position: absolute; + right: -8px; + bottom: -8px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--la-accent); + border: 2px solid #ffffff; + cursor: nwse-resize; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotReferralCodesPanel.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotReferralCodesPanel.jsx index 98d8371c..a2ed3655 100644 --- a/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotReferralCodesPanel.jsx +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotReferralCodesPanel.jsx @@ -2,6 +2,9 @@ import React, { useCallback, useMemo, useState } from 'react'; import { Icon } from '@iconify-icon/react'; import { useFetch, authenticatedRequest } from '../../../../hooks/useFetch'; import { useNotification } from '../../../../NotificationContext'; +import PivotInviteQRModal, { buildInviteLink } from './PivotInviteQRModal'; +import PivotPosterTemplatesModal from './PivotPosterTemplatesModal'; +import PivotPosterDownloadModal from './PivotPosterDownloadModal'; import './PivotReferralCodesPanel.scss'; const EMPTY_CREATE = { @@ -124,11 +127,20 @@ function PivotReferralCodesPanel({ tenantKey }) { cache: { enabled: false }, }); + const posterListUrl = `/admin/platform/tenants/${tenantKey}/pivot-poster-templates`; + const { data: posterData, refetch: refetchPosters } = useFetch(posterListUrl, { + cache: { enabled: false }, + }); + const posterTemplates = posterData?.success ? posterData.data?.templates || [] : []; + const [showCreate, setShowCreate] = useState(false); const [createForm, setCreateForm] = useState(EMPTY_CREATE); const [editing, setEditing] = useState(null); const [editForm, setEditForm] = useState(null); const [saving, setSaving] = useState(false); + const [qrCode, setQrCode] = useState(null); + const [showPosterManager, setShowPosterManager] = useState(false); + const [posterCode, setPosterCode] = useState(null); const codes = data?.success ? data.data?.codes || [] : []; const currentBatchWeek = data?.data?.currentBatchWeek; @@ -261,6 +273,19 @@ function PivotReferralCodesPanel({ tenantKey }) { [addNotification, editing, listUrl, refetch] ); + const handleCopyLink = useCallback( + async (row) => { + const link = buildInviteLink(row.code); + try { + await navigator.clipboard.writeText(link); + addNotification({ title: 'Copied', message: 'Invite link copied to clipboard', type: 'success' }); + } catch { + addNotification({ title: 'Copy failed', message: 'Could not copy invite link', type: 'error' }); + } + }, + [addNotification] + ); + const panelId = `pivot-referral-${tenantKey}`; return ( @@ -277,15 +302,25 @@ function PivotReferralCodesPanel({ tenantKey }) { {currentBatchWeek || '—'}

- +
+ + +
{error ?

{error}

: null} @@ -365,6 +400,33 @@ function PivotReferralCodesPanel({ tenantKey }) { + + +