Merge pull request 'Git auf Vordermann bringen 🫡' (#8) from dev into main
Reviewed-on: #8
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
64
ToDo.txt
Normal file
@ -0,0 +1,64 @@
|
||||
================================================================================
|
||||
PROJECT PLANNING - JANUARY 2026
|
||||
================================================================================
|
||||
|
||||
Last updated: 2026-01-20
|
||||
|
||||
=== DK404 TODOS ===
|
||||
(Loggin / Translate / Refactor)
|
||||
|
||||
• [ ] Logging for the First Part aka Quickaction Login Logout edit profile (SAT)
|
||||
• [ ] Loggin Instance Setup (SAT)
|
||||
• [ ] Log Collector Setup (SAT)
|
||||
• [ ] Refactor Login Logout Register Quickactions - CLEAN UP all possible ai bullshit
|
||||
• [ ] Document till User Verify
|
||||
• [ ] Privacy Policy, Imprint, ToS (SAT)
|
||||
• [ ] Backup of our Instances currently Raw :(
|
||||
• [ ] profit-planet.com Redirects to profit-planet.partners
|
||||
• [ ] Mail HTML instead of TXT
|
||||
• [ ] IP Loopback Login (SAT)
|
||||
• [ ] Referral Management anzeigen wenn die Permission gesetzt ist + Navbar + Dashboard
|
||||
|
||||
=== SEAZN TODOS ===
|
||||
(Compromised User / Pool )
|
||||
|
||||
• [x] Compromised User Fix (SAT)
|
||||
• [x] Pools Complete Setup check and refactor -- Implementing Logging Layout from Alex -- Talk with him (SAT)
|
||||
• [x] Adjust and add Functionality for Download Acc Data and Delete Acc (SAT)
|
||||
• [ ] News Management (own pages for news) + Adjust the Dashboard to Display Latest news
|
||||
• [ ] Unified Modal Design
|
||||
• [ ] Autorefresh of Site??
|
||||
• [ ] UserMgmt table refactor with actions and filter options (SAT?)
|
||||
• [ ] Remove irrelevant statuses in userverify filter
|
||||
• [ ] User Status 1 Feld das wir nicht benutzen
|
||||
• [ ] Pool mulit user actions (select 5 -> add to pool)
|
||||
• [ ] reset edit templates
|
||||
|
||||
|
||||
================================================================================
|
||||
QUICK SHARED / CROSSOVER ITEMS
|
||||
================================================================================
|
||||
|
||||
• [ ] Security Headers
|
||||
• [ ] Dependency Management
|
||||
• [ ] SYSTEM ABSCHALTUNG VERHINDERN -- Exoscale Role? / Security Konzept
|
||||
• [ ] new npm version
|
||||
|
||||
================================================================================
|
||||
ICEBOX / LATER / BACKLOG
|
||||
================================================================================
|
||||
|
||||
• Matrix
|
||||
• Banking
|
||||
• Coffee Abos
|
||||
• Admin only accessible via PC
|
||||
• Old iOS/Safari Functionality
|
||||
• branch and number_of_employees are unused in company_profiles
|
||||
• X button mobile toast notif too small
|
||||
• Languages
|
||||
• Light- / Darkmode
|
||||
• Finance Management?
|
||||
•
|
||||
|
||||
|
||||
================================================================================
|
||||
24
components.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@react-bits": "https://reactbits.dev/r/{name}.json"
|
||||
}
|
||||
}
|
||||
34
ecosystem.config.js
Normal file
@ -0,0 +1,34 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'profit-planet-frontend',
|
||||
cwd: __dirname,
|
||||
script: './node_modules/next/dist/bin/next',
|
||||
args: 'start -p 3000',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
time: true,
|
||||
env: {
|
||||
// runtime
|
||||
NODE_ENV: 'production',
|
||||
PORT: '3000',
|
||||
|
||||
// inlined env (keep in sync with .env used during `npm run build`)
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://api.profit-planet.partners',
|
||||
NEXT_PUBLIC_MODE: 'production',
|
||||
NEXT_PUBLIC_I18N_DEBUG_MODE: 'false',
|
||||
NEXT_PUBLIC_GEO_FALLBACK_COUNTRY: 'false',
|
||||
|
||||
NEXT_PUBLIC_SHOW_SHOP: 'false',
|
||||
NEXT_PUBLIC_DISPLAY_NEWS: 'false',
|
||||
NEXT_PUBLIC_DISPLAY_MEMBERSHIP: 'false',
|
||||
NEXT_PUBLIC_DISPLAY_ABOUT_US: 'false',
|
||||
NEXT_PUBLIC_DISPLAY_MATRIX: 'false',
|
||||
NEXT_PUBLIC_DISPLAY_ABONEMENTS: 'false',
|
||||
NEXT_PUBLIC_DISPLAY_POOLS: 'false',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
25
eslint.config.mjs
Normal file
@ -0,0 +1,25 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
29
middleware.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Next.js middleware to protect admin routes.
|
||||
* - Runs for paths matched by the config `matcher` (see bottom).
|
||||
* - Checks for the `refreshToken` cookie; if missing, redirects to `/login` before any page renders.
|
||||
* - No manual import/use needed—Next.js automatically executes this for matching requests.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Backend sets 'refreshToken' cookie on login; use it as auth presence
|
||||
const AUTH_COOKIES = ['refreshToken', '__Secure-refreshToken']
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl
|
||||
|
||||
// Only guard admin routes
|
||||
if (pathname.startsWith('/admin')) {
|
||||
const hasAuthCookie = AUTH_COOKIES.some((name) => !!req.cookies.get(name)?.value)
|
||||
if (!hasAuthCookie) {
|
||||
const loginUrl = new URL('/login', req.url)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*'],
|
||||
}
|
||||
7
next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
11043
package-lock.json
generated
Normal file
68
package.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "profit-planet-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lottiefiles/react-lottie-player": "^3.6.0",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindplus/elements": "^1.0.15",
|
||||
"@tailwindui/react": "^0.1.1",
|
||||
"axios": "^1.12.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.5.21",
|
||||
"country-select-js": "^2.1.0",
|
||||
"gsap": "^3.14.2",
|
||||
"intl-tel-input": "^25.15.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^16.0.7",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-pdf": "^10.1.0",
|
||||
"react-phone-number-input": "^3.4.12",
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.167.1",
|
||||
"winston": "^3.17.0",
|
||||
"yup": "^1.7.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-env": "^10.4.0",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/images/icons/favicon_bw.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/icons/favicon_bw_round.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/icons/favicon_gold.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/logos/PP_Logo_BW.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/images/logos/PP_Logo_BW_round.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
public/images/logos/pp_logo_gold_transparent.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
public/images/misc/MaybeBackround2_.jpg
Normal file
|
After Width: | Height: | Size: 15 MiB |
BIN
public/images/misc/coffeebeans_background.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/images/misc/community_hands.jpg
Normal file
|
After Width: | Height: | Size: 40 MiB |
BIN
public/images/misc/cow1.png
Normal file
|
After Width: | Height: | Size: 838 KiB |
BIN
public/images/misc/grey_BG.jpg
Normal file
|
After Width: | Height: | Size: 833 KiB |
BIN
public/images/misc/marble_bluegoldwhite_BG.jpg
Normal file
|
After Width: | Height: | Size: 20 MiB |
BIN
public/images/misc/marble_gold_BG.jpg
Normal file
|
After Width: | Height: | Size: 5.9 MiB |
BIN
public/images/misc/marble_white_BG.jpg
Normal file
|
After Width: | Height: | Size: 838 KiB |
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
BIN
public/videos/WORLD_SPINNING_VIDEO.mp4
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
14
src/app/ClientWrapper.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { I18nProvider } from './i18n/useTranslation';
|
||||
import AuthInitializer from './components/AuthInitializer';
|
||||
|
||||
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AuthInitializer>
|
||||
{children}
|
||||
</AuthInitializer>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
392
src/app/about-us/page.tsx
Normal file
@ -0,0 +1,392 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
CheckCircleIcon,
|
||||
HandRaisedIcon,
|
||||
RocketLaunchIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
|
||||
|
||||
const stats = [
|
||||
{ label: 'Business was founded', value: '2024' },
|
||||
{ label: 'People on the team', value: '10+' },
|
||||
{ label: 'Users on the platform', value: '250k' },
|
||||
{ label: 'Paid out gold members', value: '$70M' },
|
||||
]
|
||||
const values = [
|
||||
{
|
||||
name: 'Be world-class.',
|
||||
description: 'Lorem ipsum, dolor sit amet consectetur adipisicing elit aute id magna.',
|
||||
icon: RocketLaunchIcon,
|
||||
},
|
||||
{
|
||||
name: 'Take responsibility.',
|
||||
description: 'Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo.',
|
||||
icon: HandRaisedIcon,
|
||||
},
|
||||
{
|
||||
name: 'Be supportive.',
|
||||
description: 'Ac tincidunt sapien vehicula erat auctor pellentesque rhoncus voluptas blanditiis et.',
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
name: 'Always learning.',
|
||||
description: 'Iure sed ab. Aperiam optio placeat dolor facere. Officiis pariatur eveniet atque et dolor.',
|
||||
icon: AcademicCapIcon,
|
||||
},
|
||||
{
|
||||
name: 'Share everything you know.',
|
||||
description: 'Laudantium tempora sint ut consectetur ratione. Ut illum ut rem numquam fuga delectus.',
|
||||
icon: SparklesIcon,
|
||||
},
|
||||
{
|
||||
name: 'Enjoy downtime.',
|
||||
description: 'Culpa dolorem voluptatem velit autem rerum qui et corrupti. Quibusdam quo placeat.',
|
||||
icon: SunIcon,
|
||||
},
|
||||
]
|
||||
const team = [
|
||||
{
|
||||
name: 'Leslie Alexander',
|
||||
role: 'Co-Founder / CEO',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
|
||||
location: 'Toronto, Canada',
|
||||
},
|
||||
{
|
||||
name: 'Michael Foster',
|
||||
role: 'Co-Founder / CTO',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
|
||||
location: 'Glasgow, Scotland',
|
||||
},
|
||||
{
|
||||
name: 'Dries Vincent',
|
||||
role: 'Business Relations',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
|
||||
location: 'Niagara Falls, Canada',
|
||||
},
|
||||
{
|
||||
name: 'Lindsay Walton',
|
||||
role: 'Front-end Developer',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1517841905240-472988babdf9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
|
||||
location: 'London, England',
|
||||
},
|
||||
{
|
||||
name: 'Courtney Henry',
|
||||
role: 'Designer',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
|
||||
location: 'Toronto, Canada',
|
||||
},
|
||||
{
|
||||
name: 'Tom Cook',
|
||||
role: 'Director of Product',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
|
||||
location: 'Toronto, Canada',
|
||||
},
|
||||
{
|
||||
name: 'Whitney Francis',
|
||||
role: 'Copywriter',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
|
||||
location: 'Toronto, Canada',
|
||||
},
|
||||
{
|
||||
name: 'Leonard Krasner',
|
||||
role: 'Senior Designer',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519345182560-3f2917c472ef?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
|
||||
location: 'Toronto, Canada',
|
||||
},
|
||||
]
|
||||
const benefits = [
|
||||
'Competitive salaries',
|
||||
'Flexible work hours',
|
||||
'0 days of paid vacation',
|
||||
'Annual team retreats',
|
||||
'Benefits for you and your family',
|
||||
'A great work environment',
|
||||
]
|
||||
const footerNavigation = {
|
||||
solutions: [
|
||||
{ name: 'Marketing', href: '#' },
|
||||
{ name: 'Analytics', href: '#' },
|
||||
{ name: 'Automation', href: '#' },
|
||||
{ name: 'Commerce', href: '#' },
|
||||
{ name: 'Insights', href: '#' },
|
||||
],
|
||||
support: [
|
||||
{ name: 'Submit ticket', href: '#' },
|
||||
{ name: 'Documentation', href: '#' },
|
||||
{ name: 'Guides', href: '#' },
|
||||
],
|
||||
company: [
|
||||
{ name: 'About', href: '#' },
|
||||
{ name: 'Blog', href: '#' },
|
||||
{ name: 'Jobs', href: '#' },
|
||||
{ name: 'Press', href: '#' },
|
||||
],
|
||||
legal: [
|
||||
{ name: 'Terms of service', href: '#' },
|
||||
{ name: 'Privacy policy', href: '#' },
|
||||
{ name: 'License', href: '#' },
|
||||
],
|
||||
social: [
|
||||
{
|
||||
name: 'Facebook',
|
||||
href: '#',
|
||||
icon: (props: any) => (
|
||||
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Instagram',
|
||||
href: '#',
|
||||
icon: (props: any) => (
|
||||
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'X',
|
||||
href: '#',
|
||||
icon: (props: any) => (
|
||||
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
|
||||
<path d="M13.6823 10.6218L20.2391 3H18.6854L12.9921 9.61788L8.44486 3H3.2002L10.0765 13.0074L3.2002 21H4.75404L10.7663 14.0113L15.5685 21H20.8131L13.6819 10.6218H13.6823ZM11.5541 13.0956L10.8574 12.0991L5.31391 4.16971H7.70053L12.1742 10.5689L12.8709 11.5655L18.6861 19.8835H16.2995L11.5541 13.096V13.0956Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'GitHub',
|
||||
href: '#',
|
||||
icon: (props: any) => (
|
||||
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'YouTube',
|
||||
href: '#',
|
||||
icon: (props: any) => (
|
||||
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M19.812 5.418c.861.23 1.538.907 1.768 1.768C21.998 8.746 22 12 22 12s0 3.255-.418 4.814a2.504 2.504 0 0 1-1.768 1.768c-1.56.419-7.814.419-7.814.419s-6.255 0-7.814-.419a2.505 2.505 0 0 1-1.768-1.768C2 15.255 2 12 2 12s0-3.255.417-4.814a2.507 2.507 0 0 1 1.768-1.768C5.744 5 11.998 5 11.998 5s6.255 0 7.814.418ZM15.194 12 10 15V9l5.194 3Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function AboutUsPage() {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gray-900 pb-24 sm:pb-32">
|
||||
<main className="relative isolate">
|
||||
{/* Background */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-4 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)',
|
||||
}}
|
||||
className="aspect-1108/632 w-277 flex-none bg-linear-to-r from-[#80caff] to-[#4f46e5] opacity-25"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header section */}
|
||||
<div className="px-6 pt-14 lg:px-8">
|
||||
<div className="mx-auto max-w-2xl pt-24 text-center sm:pt-40">
|
||||
<h1 className="text-5xl font-semibold tracking-tight text-white sm:text-7xl">We are a community</h1>
|
||||
<p className="mt-8 text-lg font-medium text-pretty text-gray-400 sm:text-xl/8">
|
||||
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
|
||||
fugiat veniam occaecat fugiat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat section */}
|
||||
<div className="mx-auto mt-20 max-w-7xl px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-2xl lg:mx-0 lg:max-w-none">
|
||||
<div className="grid max-w-xl grid-cols-1 gap-8 text-base/7 text-gray-300 lg:max-w-none lg:grid-cols-2">
|
||||
<div>
|
||||
<p>
|
||||
Faucibus commodo massa rhoncus, volutpat. Dignissim sed eget risus enim. Mattis mauris semper sed amet
|
||||
vitae sed turpis id. Id dolor praesent donec est. Odio penatibus risus viverra tellus varius sit neque
|
||||
erat velit. Faucibus commodo massa rhoncus, volutpat. Dignissim sed eget risus enim. Mattis mauris
|
||||
semper sed amet vitae sed turpis id.
|
||||
</p>
|
||||
<p className="mt-8">
|
||||
Et vitae blandit facilisi magna lacus commodo. Vitae sapien duis odio id et. Id blandit molestie
|
||||
auctor fermentum dignissim. Lacus diam tincidunt ac cursus in vel. Mauris varius vulputate et ultrices
|
||||
hac adipiscing egestas.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
Erat pellentesque dictumst ligula porttitor risus eget et eget. Ultricies tellus felis id dignissim
|
||||
eget. Est augue maecenas risus nulla ultrices congue nunc tortor. Enim et nesciunt doloremque nesciunt
|
||||
voluptate.
|
||||
</p>
|
||||
<p className="mt-8">
|
||||
Et vitae blandit facilisi magna lacus commodo. Vitae sapien duis odio id et. Id blandit molestie
|
||||
auctor fermentum dignissim. Lacus diam tincidunt ac cursus in vel. Mauris varius vulputate et ultrices
|
||||
hac adipiscing egestas. Iaculis convallis ac tempor et ut. Ac lorem vel integer orci.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-16 grid grid-cols-1 gap-x-8 gap-y-12 sm:mt-20 sm:grid-cols-2 sm:gap-y-16 lg:mt-28 lg:grid-cols-4">
|
||||
{stats.map((stat, statIdx) => (
|
||||
<div key={statIdx} className="flex flex-col-reverse gap-y-3 border-l border-white/20 pl-6">
|
||||
<dt className="text-base/7 text-gray-300">{stat.label}</dt>
|
||||
<dd className="text-3xl font-semibold tracking-tight text-white">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image section */}
|
||||
<div className="mt-32 sm:mt-40 xl:mx-auto xl:max-w-7xl xl:px-8">
|
||||
<img
|
||||
alt=""
|
||||
src="https://images.unsplash.com/photo-1521737852567-6949f3f9f2b5?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2894&q=80"
|
||||
className="aspect-9/4 w-full object-cover outline-1 -outline-offset-1 outline-white/10 xl:rounded-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feature section */}
|
||||
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">Our values</h2>
|
||||
<p className="mt-6 text-lg/8 text-gray-300">
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste
|
||||
dolor cupiditate blanditiis.
|
||||
</p>
|
||||
</div>
|
||||
<dl className="mx-auto mt-16 grid max-w-2xl grid-cols-1 gap-8 text-base/7 text-gray-400 sm:grid-cols-2 lg:mx-0 lg:max-w-none lg:grid-cols-3 lg:gap-x-16">
|
||||
{values.map((value) => (
|
||||
<div key={value.name} className="relative pl-9">
|
||||
<dt className="inline font-semibold text-white">
|
||||
<value.icon aria-hidden="true" className="absolute top-1 left-1 size-5 text-indigo-500" />
|
||||
{value.name}
|
||||
</dt>{' '}
|
||||
<dd className="inline">{value.description}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Team section */}
|
||||
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">Our team</h2>
|
||||
<p className="mt-6 text-lg/8 text-gray-400">
|
||||
We’re a dynamic group of individuals who are passionate about what we do and dedicated to delivering the
|
||||
best results for our clients.
|
||||
</p>
|
||||
</div>
|
||||
<ul
|
||||
role="list"
|
||||
className="mx-auto mt-20 grid max-w-2xl grid-cols-1 gap-x-8 gap-y-14 sm:grid-cols-2 lg:mx-0 lg:max-w-none lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
{team.map((person) => (
|
||||
<li key={person.name}>
|
||||
<img
|
||||
alt=""
|
||||
src={person.imageUrl}
|
||||
className="aspect-14/13 w-full rounded-2xl object-cover outline-1 -outline-offset-1 outline-white/10"
|
||||
/>
|
||||
<h3 className="mt-6 text-lg/8 font-semibold tracking-tight text-white">{person.name}</h3>
|
||||
<p className="text-base/7 text-gray-300">{person.role}</p>
|
||||
<p className="text-sm/6 text-gray-500">{person.location}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* CTA section */}
|
||||
<div className="relative isolate -z-10 mt-32 sm:mt-40">
|
||||
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-16 bg-white/3 px-6 py-16 ring-1 ring-white/10 sm:rounded-3xl sm:p-8 lg:mx-0 lg:max-w-none lg:flex-row lg:items-center lg:py-20 xl:gap-x-20 xl:px-20">
|
||||
<img
|
||||
alt=""
|
||||
src="https://images.unsplash.com/photo-1519338381761-c7523edc1f46?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=800&q=80"
|
||||
className="h-96 w-full flex-none rounded-2xl object-cover shadow-xl lg:aspect-square lg:h-auto lg:max-w-sm"
|
||||
/>
|
||||
<div className="w-full flex-auto">
|
||||
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">
|
||||
Join our team
|
||||
</h2>
|
||||
<p className="mt-6 text-lg/8 text-pretty text-gray-400">
|
||||
Lorem ipsum dolor sit amet consect adipisicing elit. Possimus magnam voluptatum cupiditate veritatis
|
||||
in accusamus quisquam.
|
||||
</p>
|
||||
<ul
|
||||
role="list"
|
||||
className="mt-10 grid grid-cols-1 gap-x-8 gap-y-3 text-base/7 text-gray-200 sm:grid-cols-2"
|
||||
>
|
||||
{benefits.map((benefit) => (
|
||||
<li key={benefit} className="flex gap-x-3">
|
||||
<CheckCircleIcon aria-hidden="true" className="h-7 w-5 flex-none text-gray-200" />
|
||||
{benefit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-10 flex">
|
||||
<a href="#" className="text-sm/6 font-semibold text-indigo-400 hover:text-indigo-300">
|
||||
See our job postings
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 -top-16 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)',
|
||||
}}
|
||||
className="aspect-1318/752 w-329.5 flex-none bg-linear-to-r from-[#80caff] to-[#4f46e5] opacity-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { Point, Area } from 'react-easy-crop'
|
||||
|
||||
interface AffiliateCropModalProps {
|
||||
isOpen: boolean
|
||||
imageSrc: string
|
||||
onClose: () => void
|
||||
onCropComplete: (croppedImageBlob: Blob) => void
|
||||
}
|
||||
|
||||
export default function AffiliateCropModal({ isOpen, imageSrc, onClose, onCropComplete }: AffiliateCropModalProps) {
|
||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||
|
||||
const onCropAreaComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels)
|
||||
}, [])
|
||||
|
||||
const createCroppedImage = async () => {
|
||||
if (!croppedAreaPixels) return
|
||||
|
||||
const image = new Image()
|
||||
image.src = imageSrc
|
||||
await new Promise((resolve) => {
|
||||
image.onload = resolve
|
||||
})
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Set canvas size to cropped area
|
||||
canvas.width = croppedAreaPixels.width
|
||||
canvas.height = croppedAreaPixels.height
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
croppedAreaPixels.x,
|
||||
croppedAreaPixels.y,
|
||||
croppedAreaPixels.width,
|
||||
croppedAreaPixels.height,
|
||||
0,
|
||||
0,
|
||||
croppedAreaPixels.width,
|
||||
croppedAreaPixels.height
|
||||
)
|
||||
|
||||
return new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob)
|
||||
}, 'image/jpeg', 0.95)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const croppedBlob = await createCroppedImage()
|
||||
if (croppedBlob) {
|
||||
onCropComplete(croppedBlob)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70">
|
||||
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
||||
<h2 className="text-xl font-semibold text-blue-900">Crop Affiliate Logo</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 transition"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Crop Area */}
|
||||
<div className="relative bg-gray-900" style={{ height: '500px' }}>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={3 / 2}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropAreaComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">
|
||||
Zoom: {zoom.toFixed(1)}x
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="w-full h-2 bg-blue-200 rounded-lg appearance-none cursor-pointer accent-blue-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
||||
>
|
||||
Apply Crop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
src/app/admin/affiliate-management/hooks/addAffiliate.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
|
||||
export type AddAffiliatePayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
category: string;
|
||||
commissionRate?: string;
|
||||
isActive?: boolean;
|
||||
logoFile?: File;
|
||||
};
|
||||
|
||||
export async function addAffiliate(payload: AddAffiliatePayload) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/affiliates`;
|
||||
|
||||
// Use FormData if there's a logo file, otherwise JSON
|
||||
let body: FormData | string;
|
||||
let headers: Record<string, string>;
|
||||
|
||||
if (payload.logoFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', payload.name);
|
||||
formData.append('description', payload.description);
|
||||
formData.append('url', payload.url);
|
||||
formData.append('category', payload.category);
|
||||
if (payload.commissionRate) formData.append('commission_rate', payload.commissionRate);
|
||||
formData.append('is_active', String(payload.isActive ?? true));
|
||||
formData.append('logo', payload.logoFile);
|
||||
|
||||
body = formData;
|
||||
headers = { Accept: 'application/json' }; // Don't set Content-Type, browser will set it with boundary
|
||||
} else {
|
||||
body = JSON.stringify({
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
url: payload.url,
|
||||
category: payload.category,
|
||||
commission_rate: payload.commissionRate,
|
||||
is_active: payload.isActive ?? true,
|
||||
});
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
let responseBody: any = null;
|
||||
try {
|
||||
responseBody = await res.json();
|
||||
} catch {
|
||||
responseBody = null;
|
||||
}
|
||||
|
||||
const ok = res.status === 201 || res.ok;
|
||||
const message =
|
||||
responseBody?.message ||
|
||||
(res.status === 409
|
||||
? 'Affiliate already exists.'
|
||||
: res.status === 400
|
||||
? 'Invalid request. Check affiliate data.'
|
||||
: res.status === 401
|
||||
? 'Unauthorized.'
|
||||
: res.status === 403
|
||||
? 'Forbidden.'
|
||||
: res.status === 500
|
||||
? 'Internal server error.'
|
||||
: !ok
|
||||
? `Request failed (${res.status}).`
|
||||
: '');
|
||||
|
||||
return {
|
||||
ok,
|
||||
status: res.status,
|
||||
body: responseBody,
|
||||
message,
|
||||
};
|
||||
}
|
||||
34
src/app/admin/affiliate-management/hooks/deleteAffiliate.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
|
||||
export async function deleteAffiliate(id: string) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/affiliates/${id}`;
|
||||
const res = await authFetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
const ok = res.ok;
|
||||
const message =
|
||||
body?.message ||
|
||||
(res.status === 404
|
||||
? 'Affiliate not found.'
|
||||
: res.status === 403
|
||||
? 'Forbidden.'
|
||||
: res.status === 500
|
||||
? 'Server error.'
|
||||
: !ok
|
||||
? `Request failed (${res.status}).`
|
||||
: 'Affiliate deleted successfully.');
|
||||
|
||||
return { ok, status: res.status, body, message };
|
||||
}
|
||||
116
src/app/admin/affiliate-management/hooks/getAffiliates.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
import { log } from '../../../utils/logger';
|
||||
|
||||
export type AdminAffiliate = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
logoUrl?: string;
|
||||
category: string;
|
||||
isActive: boolean;
|
||||
commissionRate?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function useAdminAffiliates() {
|
||||
const [affiliates, setAffiliates] = useState<AdminAffiliate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const url = `${BASE_URL}/api/admin/affiliates`;
|
||||
log("🌐 Affiliates: GET", url);
|
||||
try {
|
||||
const headers = { Accept: 'application/json' };
|
||||
log("📤 Affiliates: Request headers:", headers);
|
||||
|
||||
const res = await authFetch(url, { headers });
|
||||
log("📡 Affiliates: Response status:", res.status);
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
const preview = JSON.stringify(body).slice(0, 600);
|
||||
log("📦 Affiliates: Response body preview:", preview);
|
||||
} catch {
|
||||
log("📦 Affiliates: Response body is not JSON or failed to parse");
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
if (!cancelled) setError('Unauthorized. Please log in.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
if (!cancelled) setError('Forbidden. Admin access required.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setError('Failed to load affiliates.');
|
||||
return;
|
||||
}
|
||||
|
||||
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
|
||||
log("🔧 Affiliates: Mapping items count:", apiItems.length);
|
||||
|
||||
const mapped: AdminAffiliate[] = apiItems.map(item => ({
|
||||
id: String(item.id),
|
||||
name: String(item.name ?? 'Unnamed Affiliate'),
|
||||
description: String(item.description ?? ''),
|
||||
url: String(item.url ?? ''),
|
||||
logoUrl: item.logoUrl ? String(item.logoUrl) : undefined,
|
||||
category: String(item.category ?? 'Other'),
|
||||
isActive: Boolean(item.is_active),
|
||||
commissionRate: item.commission_rate ? String(item.commission_rate) : undefined,
|
||||
createdAt: String(item.created_at ?? new Date().toISOString()),
|
||||
}));
|
||||
log("✅ Affiliates: Mapped sample:", mapped.slice(0, 3));
|
||||
|
||||
if (!cancelled) setAffiliates(mapped);
|
||||
} catch (e: any) {
|
||||
log("❌ Affiliates: Network or parsing error:", e?.message || e);
|
||||
if (!cancelled) setError('Network error while loading affiliates.');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [BASE_URL]);
|
||||
|
||||
return {
|
||||
affiliates,
|
||||
loading,
|
||||
error,
|
||||
refresh: async () => {
|
||||
const url = `${BASE_URL}/api/admin/affiliates`;
|
||||
log("🔁 Affiliates: Refresh GET", url);
|
||||
const res = await authFetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) {
|
||||
log("❌ Affiliates: Refresh failed status:", res.status);
|
||||
return false;
|
||||
}
|
||||
const body = await res.json();
|
||||
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
|
||||
setAffiliates(apiItems.map(item => ({
|
||||
id: String(item.id),
|
||||
name: String(item.name ?? 'Unnamed Affiliate'),
|
||||
description: String(item.description ?? ''),
|
||||
url: String(item.url ?? ''),
|
||||
logoUrl: item.logoUrl ? String(item.logoUrl) : undefined,
|
||||
category: String(item.category ?? 'Other'),
|
||||
isActive: Boolean(item.is_active),
|
||||
commissionRate: item.commission_rate ? String(item.commission_rate) : undefined,
|
||||
createdAt: String(item.created_at ?? new Date().toISOString()),
|
||||
})));
|
||||
log("✅ Affiliates: Refresh succeeded, items:", apiItems.length);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
80
src/app/admin/affiliate-management/hooks/updateAffiliate.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
|
||||
export type UpdateAffiliatePayload = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
category: string;
|
||||
commissionRate?: string;
|
||||
isActive: boolean;
|
||||
logoFile?: File;
|
||||
removeLogo?: boolean;
|
||||
};
|
||||
|
||||
export async function updateAffiliate(payload: UpdateAffiliatePayload) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/affiliates/${payload.id}`;
|
||||
|
||||
// Use FormData if there's a logo file or removeLogo flag, otherwise JSON
|
||||
let body: FormData | string;
|
||||
let headers: Record<string, string>;
|
||||
|
||||
if (payload.logoFile || payload.removeLogo) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', payload.name);
|
||||
formData.append('description', payload.description);
|
||||
formData.append('url', payload.url);
|
||||
formData.append('category', payload.category);
|
||||
if (payload.commissionRate) formData.append('commission_rate', payload.commissionRate);
|
||||
formData.append('is_active', String(payload.isActive));
|
||||
if (payload.logoFile) formData.append('logo', payload.logoFile);
|
||||
if (payload.removeLogo) formData.append('removeLogo', 'true');
|
||||
|
||||
body = formData;
|
||||
headers = { Accept: 'application/json' };
|
||||
} else {
|
||||
body = JSON.stringify({
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
url: payload.url,
|
||||
category: payload.category,
|
||||
commission_rate: payload.commissionRate,
|
||||
is_active: payload.isActive,
|
||||
});
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
let responseBody: any = null;
|
||||
try {
|
||||
responseBody = await res.json();
|
||||
} catch {
|
||||
responseBody = null;
|
||||
}
|
||||
|
||||
const ok = res.ok;
|
||||
const message =
|
||||
responseBody?.message ||
|
||||
(res.status === 404
|
||||
? 'Affiliate not found.'
|
||||
: res.status === 400
|
||||
? 'Invalid request.'
|
||||
: res.status === 403
|
||||
? 'Forbidden.'
|
||||
: res.status === 500
|
||||
? 'Server error.'
|
||||
: !ok
|
||||
? `Request failed (${res.status}).`
|
||||
: '');
|
||||
|
||||
return { ok, status: res.status, body: responseBody, message };
|
||||
}
|
||||
1007
src/app/admin/affiliate-management/page.tsx
Normal file
367
src/app/admin/contract-management/components/contractEditor.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import useContractManagement from '../hooks/useContractManagement';
|
||||
|
||||
type Props = {
|
||||
editingTemplateId?: string | null;
|
||||
onCancelEdit?: () => void;
|
||||
onSaved?: (info?: { action: 'created' | 'revised'; templateId: string }) => void;
|
||||
};
|
||||
|
||||
export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdit }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [htmlCode, setHtmlCode] = useState('');
|
||||
const [isPreview, setIsPreview] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
|
||||
const [lang, setLang] = useState<'en' | 'de'>('en');
|
||||
const [type, setType] = useState<'contract' | 'bill' | 'other'>('contract');
|
||||
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract');
|
||||
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
|
||||
const [description, setDescription] = useState<string>('');
|
||||
|
||||
const [editingMeta, setEditingMeta] = useState<{ id: string; version: number; state: string } | null>(null);
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const { uploadTemplate, updateTemplateState, getTemplate, reviseTemplate } = useContractManagement();
|
||||
|
||||
const resetEditorFields = () => {
|
||||
setName('');
|
||||
setHtmlCode('');
|
||||
setDescription('');
|
||||
setIsPreview(false);
|
||||
setEditingMeta(null);
|
||||
};
|
||||
|
||||
// Load template into editor when editing
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!editingTemplateId) {
|
||||
setEditingMeta(null);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const tpl = await getTemplate(editingTemplateId);
|
||||
setName(tpl.name || '');
|
||||
setHtmlCode(tpl.html || '');
|
||||
setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
|
||||
setLang((tpl.lang as any) || 'en');
|
||||
setType((tpl.type as any) || 'contract');
|
||||
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
|
||||
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
|
||||
setEditingMeta({
|
||||
id: editingTemplateId,
|
||||
version: Number(tpl.version || 1),
|
||||
state: String(tpl.state || 'inactive')
|
||||
});
|
||||
setStatusMsg(`Loaded template for editing (v${Number(tpl.version || 1)}).`);
|
||||
} catch (e: any) {
|
||||
setStatusMsg(e?.message || 'Failed to load template.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editingTemplateId]);
|
||||
|
||||
// Build a full HTML doc if user pasted only a snippet
|
||||
const wrapIfNeeded = (src: string) => {
|
||||
const hasDoc = /<!DOCTYPE|<html[\s>]/i.test(src);
|
||||
if (hasDoc) return src;
|
||||
// Minimal A4 skeleton so snippets render and print correctly
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Preview</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 0; }
|
||||
html, body { margin:0; padding:0; background:#eee; }
|
||||
body { display:flex; justify-content:center; }
|
||||
.a4 { width:210mm; min-height:297mm; background:#fff; box-shadow:0 0 5mm rgba(0,0,0,0.1); box-sizing:border-box; padding:20mm; }
|
||||
@media print {
|
||||
html, body { background:#fff; }
|
||||
.a4 { box-shadow:none; margin:0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="a4">${src}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
// Write/refresh iframe preview
|
||||
useEffect(() => {
|
||||
if (!isPreview) return;
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||
if (!doc) return;
|
||||
|
||||
const html = wrapIfNeeded(htmlCode);
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
|
||||
const resize = () => {
|
||||
// Allow time for layout/styles
|
||||
requestAnimationFrame(() => {
|
||||
const h = doc.body ? Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight) : 1200;
|
||||
iframe.style.height = Math.min(Math.max(h, 1123), 2000) + 'px'; // clamp for UX
|
||||
});
|
||||
};
|
||||
|
||||
// Initial resize and after load
|
||||
resize();
|
||||
const onLoad = () => resize();
|
||||
iframe.addEventListener('load', onLoad);
|
||||
// Also observe mutations to adjust height if content changes
|
||||
const mo = new MutationObserver(resize);
|
||||
mo.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
|
||||
|
||||
return () => {
|
||||
iframe.removeEventListener('load', onLoad);
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [isPreview, htmlCode]);
|
||||
|
||||
const printPreview = () => {
|
||||
const w = iframeRef.current?.contentWindow;
|
||||
w?.focus();
|
||||
w?.print();
|
||||
};
|
||||
|
||||
const slug = (s: string) =>
|
||||
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'template';
|
||||
|
||||
// NEW: all-fields-required guard
|
||||
const canSave = Boolean(
|
||||
name.trim() &&
|
||||
htmlCode.trim() &&
|
||||
type &&
|
||||
(type === 'contract' ? contractType : true) &&
|
||||
userType &&
|
||||
lang
|
||||
)
|
||||
|
||||
const save = async (publish: boolean) => {
|
||||
const html = htmlCode.trim();
|
||||
// NEW: validate all fields
|
||||
if (!canSave) {
|
||||
setStatusMsg('Please fill all required fields (name, HTML, type, user type, language).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (publish && type === 'contract') {
|
||||
const kind = contractType === 'gdpr' ? 'GDPR' : 'Contract';
|
||||
const ok = window.confirm(
|
||||
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setStatusMsg(null);
|
||||
|
||||
try {
|
||||
// Build a file from HTML code
|
||||
const file = new File([html], `${slug(name)}.html`, { type: 'text/html' });
|
||||
// If editing: revise (new object + version bump) and deactivate previous
|
||||
if (editingTemplateId) {
|
||||
const revised = await reviseTemplate(editingTemplateId, {
|
||||
file,
|
||||
name,
|
||||
type,
|
||||
contract_type: type === 'contract' ? contractType : undefined,
|
||||
lang,
|
||||
description: description || undefined,
|
||||
user_type: userType,
|
||||
state: publish ? 'active' : 'inactive',
|
||||
});
|
||||
setStatusMsg(publish ? 'New version created and activated (previous deactivated).' : 'New version created (previous deactivated).');
|
||||
if (onSaved && revised?.id) onSaved({ action: 'revised', templateId: String(revised.id) });
|
||||
resetEditorFields();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise: create new
|
||||
const created = await uploadTemplate({
|
||||
file,
|
||||
name,
|
||||
type,
|
||||
contract_type: type === 'contract' ? contractType : undefined,
|
||||
lang,
|
||||
description: description || undefined,
|
||||
user_type: userType,
|
||||
});
|
||||
|
||||
if (publish && created?.id) {
|
||||
await updateTemplateState(created.id, 'active');
|
||||
}
|
||||
|
||||
setStatusMsg(publish ? 'Template created and activated.' : 'Template created.');
|
||||
if (onSaved && created?.id) onSaved({ action: 'created', templateId: String(created.id) });
|
||||
// Reset so another template can be created immediately
|
||||
resetEditorFields();
|
||||
} catch (e: any) {
|
||||
setStatusMsg(e?.message || 'Save failed.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{editingMeta && (
|
||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-indigo-900">
|
||||
<span className="font-semibold">Editing:</span> {name || 'Untitled'} (v{editingMeta.version}) • state: {editingMeta.state}
|
||||
</div>
|
||||
{onCancelEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetEditorFields();
|
||||
onCancelEdit();
|
||||
}}
|
||||
className="inline-flex items-center rounded-lg bg-white hover:bg-gray-50 text-gray-900 px-3 py-1.5 text-sm font-medium shadow border border-gray-200 transition"
|
||||
>
|
||||
Cancel editing
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Template name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full sm:w-1/2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreview((v) => !v)}
|
||||
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow transition"
|
||||
>
|
||||
{isPreview ? 'Switch to Code' : 'Preview HTML'}
|
||||
</button>
|
||||
{isPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={printPreview}
|
||||
className="inline-flex items-center rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-4 py-2 text-sm font-medium shadow transition"
|
||||
>
|
||||
Print
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New metadata inputs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'other')}
|
||||
required
|
||||
className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
>
|
||||
<option value="contract">Contract</option>
|
||||
<option value="bill">Bill</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
{type === 'contract' && (
|
||||
<select
|
||||
value={contractType}
|
||||
onChange={(e) => setContractType(e.target.value as 'contract' | 'gdpr')}
|
||||
required
|
||||
className="w-full sm:w-40 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
>
|
||||
<option value="contract">Contract</option>
|
||||
<option value="gdpr">GDPR</option>
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={userType}
|
||||
onChange={(e) => setUserType(e.target.value as 'personal' | 'company' | 'both')}
|
||||
required
|
||||
className="w-full sm:w-40 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value as 'en' | 'de')}
|
||||
required
|
||||
className="w-full sm:w-32 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
>
|
||||
<option value="en">English (en)</option>
|
||||
<option value="de">Deutsch (de)</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isPreview && (
|
||||
<textarea
|
||||
value={htmlCode}
|
||||
onChange={(e) => setHtmlCode(e.target.value)}
|
||||
placeholder="Paste your full HTML (or snippet) here…"
|
||||
required
|
||||
className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPreview && (
|
||||
<div className="rounded-lg border border-gray-300 bg-white shadow">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="Contract Preview"
|
||||
className="w-full rounded-lg"
|
||||
style={{ height: 1200, background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => save(false)}
|
||||
disabled={saving || !canSave}
|
||||
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
|
||||
>
|
||||
Create (inactive)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save(true)}
|
||||
disabled={saving || !canSave}
|
||||
className="inline-flex items-center rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
|
||||
>
|
||||
Create & Activate
|
||||
</button>
|
||||
{/* NEW: helper text */}
|
||||
{!canSave && <span className="text-xs text-red-600">Fill all fields to proceed.</span>}
|
||||
{saving && <span className="text-xs text-gray-500">Saving…</span>}
|
||||
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import useContractManagement from '../hooks/useContractManagement';
|
||||
|
||||
type Props = {
|
||||
refreshKey?: number;
|
||||
onEdit?: (id: string) => void;
|
||||
};
|
||||
|
||||
type ContractTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
contract_type?: string | null;
|
||||
user_type?: string | null;
|
||||
version: number;
|
||||
status: 'draft' | 'published' | 'archived' | string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
function Pill({ children, className }: { children: React.ReactNode; className: string }) {
|
||||
return <span className={`px-2 py-0.5 rounded text-xs font-semibold border ${className}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-800 border border-gray-300',
|
||||
published: 'bg-green-100 text-green-800 border border-green-300',
|
||||
archived: 'bg-yellow-100 text-yellow-800 border border-yellow-300',
|
||||
};
|
||||
const cls = map[status] || 'bg-blue-100 text-blue-800 border border-blue-300';
|
||||
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{status}</span>;
|
||||
}
|
||||
|
||||
export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) {
|
||||
const [items, setItems] = useState<ContractTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
|
||||
const {
|
||||
listTemplates,
|
||||
openPreviewInNewTab,
|
||||
generatePdf,
|
||||
downloadPdf,
|
||||
updateTemplateState,
|
||||
downloadBlobFile,
|
||||
} = useContractManagement();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = q.trim().toLowerCase();
|
||||
if (!term) return items;
|
||||
return items.filter((i) => i.name.toLowerCase().includes(term) || String(i.version).includes(term) || i.status.toLowerCase().includes(term));
|
||||
}, [items, q]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listTemplates();
|
||||
const mapped: ContractTemplate[] = data.map((x: any) => ({
|
||||
id: x.id ?? x._id ?? x.uuid,
|
||||
name: x.name ?? 'Untitled',
|
||||
type: x.type,
|
||||
contract_type: x.contract_type ?? x.contractType ?? null,
|
||||
user_type: x.user_type ?? x.userType ?? null,
|
||||
version: Number(x.version ?? 1),
|
||||
status: (x.state === 'active') ? 'published' : 'draft',
|
||||
updatedAt: x.updatedAt ?? x.modifiedAt ?? x.updated_at,
|
||||
}));
|
||||
setItems(mapped);
|
||||
} catch {
|
||||
setItems((prev) => prev.length ? prev : [
|
||||
{ id: 'ex1', name: 'Sample Contract A', version: 1, status: 'draft', updatedAt: new Date().toISOString() },
|
||||
{ id: 'ex2', name: 'NDA Template', version: 3, status: 'published', updatedAt: new Date().toISOString() },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshKey]);
|
||||
|
||||
const onToggleState = async (id: string, current: string) => {
|
||||
const target = current === 'published' ? 'inactive' : 'active';
|
||||
|
||||
// Confirmation: activating a contract/GDPR will deactivate other active templates of the same kind
|
||||
if (target === 'active') {
|
||||
const tpl = items.find((i) => i.id === id);
|
||||
if (tpl?.type === 'contract') {
|
||||
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract';
|
||||
const ok = window.confirm(
|
||||
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
|
||||
// Update clicked item immediately, then refresh list to reflect any auto-deactivations.
|
||||
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
|
||||
await load();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const onPreview = (id: string) => openPreviewInNewTab(id);
|
||||
|
||||
const onGenPdf = async (id: string) => {
|
||||
try {
|
||||
const blob = await generatePdf(id, { preview: true });
|
||||
downloadBlobFile(blob, `${id}-preview.pdf`);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const onDownloadPdf = async (id: string) => {
|
||||
try {
|
||||
const blob = await downloadPdf(id);
|
||||
downloadBlobFile(blob, `${id}.pdf`);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
placeholder="Search templates…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
/>
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-4 py-2 text-sm font-medium shadow disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filtered.map((c) => (
|
||||
<div key={c.id} className="rounded-xl border border-gray-100 bg-white shadow-sm p-4 flex flex-col gap-2 hover:shadow-md transition">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
|
||||
<StatusBadge status={c.status} />
|
||||
{c.type && (
|
||||
<Pill className="bg-slate-50 text-slate-800 border-slate-200">
|
||||
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : 'Other'}
|
||||
</Pill>
|
||||
)}
|
||||
{c.type === 'contract' && (
|
||||
<Pill className="bg-indigo-50 text-indigo-800 border-indigo-200">
|
||||
{c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'}
|
||||
</Pill>
|
||||
)}
|
||||
{c.user_type && (
|
||||
<Pill className="bg-emerald-50 text-emerald-800 border-emerald-200">
|
||||
{c.user_type === 'personal' ? 'Personal' : c.user_type === 'company' ? 'Company' : 'Both'}
|
||||
</Pill>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Version {c.version}{c.updatedAt ? ` • Updated ${new Date(c.updatedAt).toLocaleString()}` : ''}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(c.id)}
|
||||
className="px-3 py-1 text-xs rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 transition"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onPreview(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Preview</button>
|
||||
<button onClick={() => onGenPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">PDF</button>
|
||||
<button onClick={() => onDownloadPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Download</button>
|
||||
<button onClick={() => onToggleState(c.id, c.status)} className={`px-3 py-1 text-xs rounded-lg font-semibold transition
|
||||
${c.status === 'published'
|
||||
? 'bg-red-100 hover:bg-red-200 text-red-700 border border-red-200'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 text-white border border-indigo-600'}`}>
|
||||
{c.status === 'published' ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!filtered.length && (
|
||||
<div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,435 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useContractManagement, { CompanyStamp } from '../hooks/useContractManagement';
|
||||
import DeleteConfirmationModal from '../../../components/delete/deleteConfirmationModal';
|
||||
|
||||
type Props = {
|
||||
onUploaded?: () => void;
|
||||
};
|
||||
|
||||
export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [label, setLabel] = useState<string>('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [stamps, setStamps] = useState<CompanyStamp[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalFile, setModalFile] = useState<File | null>(null);
|
||||
const [modalLabel, setModalLabel] = useState<string>('');
|
||||
const [modalError, setModalError] = useState<string | null>(null);
|
||||
const [modalUploading, setModalUploading] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState<{ open: boolean; id?: string; label?: string; active?: boolean }>({ open: false });
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const {
|
||||
uploadCompanyStamp,
|
||||
listStampsAll,
|
||||
activateCompanyStamp,
|
||||
deactivateCompanyStamp,
|
||||
deleteCompanyStamp,
|
||||
} = useContractManagement();
|
||||
|
||||
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);
|
||||
const modalPreviewUrl = useMemo(() => (modalFile ? URL.createObjectURL(modalFile) : null), [modalFile]);
|
||||
|
||||
const onPick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) setFile(f);
|
||||
};
|
||||
|
||||
const loadStamps = async () => {
|
||||
try {
|
||||
const { stamps, active, activeId } = await listStampsAll();
|
||||
console.debug('[CM/UI] loadStamps (/all):', {
|
||||
count: stamps.length,
|
||||
activeId,
|
||||
withImgCount: stamps.filter((s) => !!s.base64).length,
|
||||
});
|
||||
setStamps(stamps);
|
||||
} catch (e) {
|
||||
console.warn('[CM/UI] loadStamps error:', e);
|
||||
setStamps([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStamps();
|
||||
}, []);
|
||||
|
||||
const upload = async () => {
|
||||
if (!file) {
|
||||
setMsg('Select an image first.');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await uploadCompanyStamp(file, label || undefined);
|
||||
console.debug('[CM/UI] upload success, refreshing list');
|
||||
setMsg('Company stamp uploaded.');
|
||||
if (onUploaded) onUploaded();
|
||||
await loadStamps();
|
||||
// Optional clear
|
||||
// setFile(null);
|
||||
// setLabel('');
|
||||
// if (inputRef.current) inputRef.current.value = '';
|
||||
} catch (e: any) {
|
||||
console.warn('[CM/UI] upload error:', e);
|
||||
setMsg(e?.message || 'Upload failed.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onActivate = async (id: string) => {
|
||||
try {
|
||||
await activateCompanyStamp(id);
|
||||
console.debug('[CM/UI] activated:', id);
|
||||
await loadStamps();
|
||||
setMsg('Activated company stamp.');
|
||||
} catch (e: any) {
|
||||
console.warn('[CM/UI] activate error:', e);
|
||||
setMsg(e?.message || 'Activation failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const onDeactivate = async (id: string) => {
|
||||
try {
|
||||
await deactivateCompanyStamp(id);
|
||||
console.debug('[CM/UI] deactivated:', id);
|
||||
await loadStamps();
|
||||
setMsg('Deactivated company stamp.');
|
||||
} catch (e: any) {
|
||||
console.warn('[CM/UI] deactivate error:', e);
|
||||
setMsg(e?.message || 'Deactivation failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = (id: string, active?: boolean, label?: string) => {
|
||||
setDeleteModal({ open: true, id, label, active });
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteModal.id) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteCompanyStamp(deleteModal.id);
|
||||
console.debug('[CM/UI] deleted:', deleteModal.id);
|
||||
await loadStamps();
|
||||
setMsg('Deleted company stamp.');
|
||||
} catch (e: any) {
|
||||
console.warn('[CM/UI] delete error:', e);
|
||||
setMsg(e?.message || 'Delete failed.');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
setDeleteModal({ open: false });
|
||||
}
|
||||
};
|
||||
|
||||
// Validation helpers for modal
|
||||
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
|
||||
const MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const validateFile = (f: File) => {
|
||||
if (!ACCEPTED_TYPES.includes(f.type)) return `Invalid file type (${f.type}). Allowed: PNG, JPEG, WebP, SVG.`;
|
||||
if (f.size > MAX_BYTES) return `File too large (${Math.round(f.size / 1024 / 1024)}MB). Max 5MB.`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setModalError(null);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (!f) return;
|
||||
const err = validateFile(f);
|
||||
if (err) { setModalError(err); setModalFile(null); return; }
|
||||
setModalFile(f);
|
||||
};
|
||||
|
||||
const onBrowse = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
setModalError(null);
|
||||
const err = validateFile(f);
|
||||
if (err) { setModalError(err); setModalFile(null); return; }
|
||||
setModalFile(f);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setModalLabel('');
|
||||
setModalFile(null);
|
||||
setModalError(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setModalUploading(false);
|
||||
};
|
||||
|
||||
const confirmUpload = async () => {
|
||||
if (!modalFile) {
|
||||
setModalError('Please select an image.');
|
||||
return;
|
||||
}
|
||||
setModalUploading(true);
|
||||
setModalError(null);
|
||||
try {
|
||||
await uploadCompanyStamp(modalFile, modalLabel || undefined);
|
||||
if (onUploaded) onUploaded();
|
||||
await loadStamps();
|
||||
closeModal();
|
||||
} catch (e: any) {
|
||||
setModalError(e?.message || 'Upload failed.');
|
||||
} finally {
|
||||
setModalUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeStamp = stamps.find((s) => s.active);
|
||||
useEffect(() => {
|
||||
if (activeStamp) {
|
||||
console.debug('[CM/UI] activeStamp:', {
|
||||
id: activeStamp.id,
|
||||
label: activeStamp.label,
|
||||
hasImg: !!activeStamp.base64,
|
||||
mime: activeStamp.mimeType,
|
||||
});
|
||||
if (!activeStamp.base64) {
|
||||
console.warn('[CM/UI] Active stamp has no image data; preview will show placeholder.');
|
||||
}
|
||||
}
|
||||
}, [activeStamp?.id, activeStamp?.base64]);
|
||||
|
||||
const toImgSrc = (s: CompanyStamp) => {
|
||||
if (!s?.base64) {
|
||||
console.warn('[CM/UI] toImgSrc: missing base64 for stamp', s?.id);
|
||||
return '';
|
||||
}
|
||||
return s.base64.startsWith('data:') ? s.base64 : `data:${s.mimeType || 'image/png'};base64,${s.base64}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Add New Stamp modal trigger */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-700">Manage your company stamps. One active at a time.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openModal}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-semibold shadow transition"
|
||||
>
|
||||
<svg width="18" height="18" fill="currentColor" className="opacity-90"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>
|
||||
Add New Stamp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Emphasized Active stamp */}
|
||||
{activeStamp && (
|
||||
<div className="relative rounded-2xl p-[2px] bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 shadow-lg">
|
||||
<div className="rounded-2xl bg-white p-5 flex items-center gap-6">
|
||||
<div className="relative">
|
||||
{activeStamp.base64 ? (
|
||||
<img
|
||||
src={toImgSrc(activeStamp)}
|
||||
alt="Active stamp"
|
||||
className="h-24 w-24 object-contain rounded-xl ring-2 ring-indigo-200 shadow"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-24 w-24 flex items-center justify-center rounded-xl ring-2 ring-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
no image
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute -top-2 -right-2 rounded-full bg-green-600 text-white text-xs px-3 py-1 shadow font-bold">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-base font-semibold text-gray-900 truncate">{activeStamp.label || activeStamp.id}</p>
|
||||
<p className="text-xs text-gray-500">Auto-applied to documents where applicable.</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onDeactivate(activeStamp.id)}
|
||||
className="text-xs px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 shadow transition"
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stamps list */}
|
||||
{!!stamps.length && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-medium text-gray-900 mb-2">Your Company Stamps</p>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{stamps.map((s) => {
|
||||
const src = toImgSrc(s);
|
||||
const activeCls = s.active
|
||||
? 'border-green-300 bg-green-50 shadow'
|
||||
: 'border-gray-200 hover:border-indigo-300 transition-colors';
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className={`flex items-center justify-between gap-3 p-4 border rounded-xl ${activeCls}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{s.base64 ? (
|
||||
<img
|
||||
src={src}
|
||||
alt="Stamp"
|
||||
className="h-14 w-14 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-14 w-14 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-xs text-gray-500">
|
||||
no image
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-gray-900">{s.label || s.id}</span>
|
||||
{s.active && (
|
||||
<span className="text-xs mt-1 px-2 py-0.5 rounded bg-green-100 text-green-800 w-fit font-semibold">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{s.active ? (
|
||||
<button
|
||||
onClick={() => onDeactivate(s.id)}
|
||||
className="text-xs px-3 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 transition"
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onActivate(s.id)}
|
||||
className="text-xs px-3 py-2 rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 transition"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(s.id, s.active, s.label ?? undefined)}
|
||||
className="text-xs px-3 py-2 rounded-lg bg-red-50 hover:bg-red-100 text-red-700 border border-red-200 transition"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Add New Stamp */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={closeModal} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5">
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<h3 className="text-lg font-bold text-indigo-700">Add New Stamp</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Accepted types: PNG, JPEG, WebP, SVG. Max size: 5MB.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modalLabel}
|
||||
onChange={(e) => setModalLabel(e.target.value)}
|
||||
placeholder="e.g., Company Seal 2025"
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
className="rounded-xl border-2 border-dashed border-indigo-300 hover:border-indigo-400 transition-colors p-4 bg-indigo-50"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{modalPreviewUrl ? (
|
||||
<img
|
||||
src={modalPreviewUrl}
|
||||
alt="Preview"
|
||||
className="h-20 w-20 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-20 w-20 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-xs text-gray-500">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-900">Drag and drop your stamp here</p>
|
||||
<p className="text-xs text-gray-500">or click to browse</p>
|
||||
<div className="mt-2">
|
||||
<label className="inline-block">
|
||||
<input
|
||||
type="file"
|
||||
accept={ACCEPTED_TYPES.join(',')}
|
||||
onChange={onBrowse}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="cursor-pointer text-xs px-3 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 inline-flex items-center gap-1">
|
||||
<svg width="14" height="14" fill="currentColor"><path d="M12 10v2H2v-2H0v4h14v-4h-2zM7 0l4 4H9v5H5V4H3l4-4z"/></svg>
|
||||
Choose file
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalError && <p className="text-xs text-red-600">{modalError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-100 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmUpload}
|
||||
disabled={modalUploading || !modalFile}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60 transition"
|
||||
>
|
||||
{modalUploading ? 'Uploading…' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<DeleteConfirmationModal
|
||||
open={deleteModal.open}
|
||||
title="Delete Company Stamp"
|
||||
description={
|
||||
deleteModal.active
|
||||
? `This stamp (${deleteModal.label || deleteModal.id}) is currently active. Are you sure you want to delete it? This action cannot be undone.`
|
||||
: `Are you sure you want to delete the stamp "${deleteModal.label || deleteModal.id}"? This action cannot be undone.`
|
||||
}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
loading={deleteLoading}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ open: false })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
472
src/app/admin/contract-management/hooks/useContractManagement.ts
Normal file
@ -0,0 +1,472 @@
|
||||
import { useCallback } from 'react';
|
||||
import useAuthStore from '../../../store/authStore';
|
||||
|
||||
export type DocumentTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
contract_type?: 'contract' | 'gdpr' | null | string;
|
||||
lang?: 'en' | 'de' | string;
|
||||
user_type?: 'personal' | 'company' | 'both' | string;
|
||||
state?: 'active' | 'inactive' | string;
|
||||
version?: number;
|
||||
previewUrl?: string | null;
|
||||
fileUrl?: string | null;
|
||||
html?: string | null;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type CompanyStamp = {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
mimeType?: string;
|
||||
base64?: string | null; // normalized base64 or data URI
|
||||
active?: boolean;
|
||||
createdAt?: string;
|
||||
// ...other metadata...
|
||||
};
|
||||
|
||||
type Json = Record<string, any>;
|
||||
|
||||
function isFormData(body: any): body is FormData {
|
||||
return typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
}
|
||||
|
||||
export default function useContractManagement() {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||
const getState = useAuthStore.getState;
|
||||
|
||||
const authorizedFetch = useCallback(
|
||||
async <T = any>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
responseType: 'json' | 'text' | 'blob' = 'json'
|
||||
): Promise<T> => {
|
||||
let token = getState().accessToken;
|
||||
if (!token) {
|
||||
const ok = await getState().refreshAuthToken();
|
||||
if (ok) token = getState().accessToken;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(init.headers as Record<string, string> || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
// Do not set Content-Type for FormData; browser will set proper boundary
|
||||
if (!isFormData(init.body) && init.method && init.method !== 'GET') {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
}
|
||||
|
||||
// Debug (safe)
|
||||
try {
|
||||
console.debug('[CM] fetch ->', {
|
||||
url: `${base}${path}`,
|
||||
method: init.method || 'GET',
|
||||
hasAuth: !!token,
|
||||
tokenPrefix: token ? `${token.substring(0, 12)}...` : null,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// Include cookies + Authorization on all requests
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
try {
|
||||
console.debug('[CM] fetch <-', { path, status: res.status, ok: res.ok, ct: res.headers.get('content-type') });
|
||||
} catch {}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.warn('[CM] fetch error body:', text?.slice(0, 2000));
|
||||
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
|
||||
// Log and return body by responseType
|
||||
if (responseType === 'blob') {
|
||||
const len = res.headers.get('content-length');
|
||||
try {
|
||||
console.debug('[CM] fetch body (blob):', { contentType: res.headers.get('content-type'), contentLength: len ? Number(len) : null });
|
||||
} catch {}
|
||||
return (await res.blob()) as unknown as T;
|
||||
}
|
||||
|
||||
if (responseType === 'text') {
|
||||
const text = await res.text();
|
||||
try {
|
||||
console.debug('[CM] fetch body (text):', text.slice(0, 2000));
|
||||
} catch {}
|
||||
return text as unknown as T;
|
||||
}
|
||||
|
||||
// json (default): read text once, log, then parse
|
||||
const text = await res.text();
|
||||
try {
|
||||
console.debug('[CM] fetch body (json):', text.slice(0, 2000));
|
||||
} catch {}
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
console.warn('[CM] failed to parse JSON, returning empty object');
|
||||
return {} as T;
|
||||
}
|
||||
},
|
||||
[base]
|
||||
);
|
||||
|
||||
// Document templates
|
||||
const listTemplates = useCallback(async (): Promise<DocumentTemplate[]> => {
|
||||
const data = await authorizedFetch<DocumentTemplate[]>('/api/document-templates', { method: 'GET' });
|
||||
return Array.isArray(data) ? data : [];
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const getTemplate = useCallback(async (id: string): Promise<DocumentTemplate> => {
|
||||
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}`, { method: 'GET' });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const previewTemplateHtml = useCallback(async (id: string): Promise<string> => {
|
||||
return authorizedFetch<string>(`/api/document-templates/${id}/preview`, { method: 'GET' }, 'text');
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const openPreviewInNewTab = useCallback(async (id: string) => {
|
||||
const html = await previewTemplateHtml(id);
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
// No revoke here to keep the tab content; browser will clean up eventually
|
||||
}, [previewTemplateHtml]);
|
||||
|
||||
const generatePdf = useCallback(async (id: string, opts?: { preview?: boolean; sanitize?: boolean }): Promise<Blob> => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.preview) params.set('preview', '1');
|
||||
if (opts?.sanitize) params.set('sanitize', '1');
|
||||
const qs = params.toString() ? `?${params.toString()}` : '';
|
||||
return authorizedFetch<Blob>(`/api/document-templates/${id}/generate-pdf${qs}`, { method: 'GET' }, 'blob');
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const downloadPdf = useCallback(async (id: string): Promise<Blob> => {
|
||||
return authorizedFetch<Blob>(`/api/document-templates/${id}/download-pdf`, { method: 'GET' }, 'blob');
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const uploadTemplate = useCallback(async (payload: {
|
||||
file: File | Blob;
|
||||
name: string;
|
||||
type: string;
|
||||
contract_type?: 'contract' | 'gdpr';
|
||||
lang: 'en' | 'de' | string;
|
||||
description?: string;
|
||||
user_type?: 'personal' | 'company' | 'both';
|
||||
}): Promise<DocumentTemplate> => {
|
||||
const fd = new FormData();
|
||||
const file = payload.file instanceof File ? payload.file : new File([payload.file], `${payload.name || 'template'}.html`, { type: 'text/html' });
|
||||
fd.append('file', file);
|
||||
fd.append('name', payload.name);
|
||||
fd.append('type', payload.type);
|
||||
if (payload.type === 'contract' && payload.contract_type) {
|
||||
fd.append('contract_type', payload.contract_type);
|
||||
}
|
||||
fd.append('lang', payload.lang);
|
||||
if (payload.description) fd.append('description', payload.description);
|
||||
fd.append('user_type', (payload.user_type ?? 'both'));
|
||||
|
||||
return authorizedFetch<DocumentTemplate>('/api/document-templates', { method: 'POST', body: fd });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const updateTemplate = useCallback(async (id: string, payload: {
|
||||
file?: File | Blob;
|
||||
name?: string;
|
||||
type?: string;
|
||||
contract_type?: 'contract' | 'gdpr';
|
||||
lang?: 'en' | 'de' | string;
|
||||
description?: string;
|
||||
user_type?: 'personal' | 'company' | 'both';
|
||||
}): Promise<DocumentTemplate> => {
|
||||
const fd = new FormData();
|
||||
if (payload.file) {
|
||||
const f = payload.file instanceof File ? payload.file : new File([payload.file], `${payload.name || 'template'}.html`, { type: 'text/html' });
|
||||
fd.append('file', f);
|
||||
}
|
||||
if (payload.name) fd.append('name', payload.name);
|
||||
if (payload.type) fd.append('type', payload.type);
|
||||
if ((payload.type === 'contract' || payload.contract_type) && payload.contract_type) {
|
||||
fd.append('contract_type', payload.contract_type);
|
||||
}
|
||||
if (payload.lang) fd.append('lang', payload.lang);
|
||||
if (payload.description !== undefined) fd.append('description', payload.description);
|
||||
if (payload.user_type) fd.append('user_type', payload.user_type);
|
||||
|
||||
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}`, { method: 'PUT', body: fd });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
// NEW: revise template (create a new template record + bump version + deactivate previous)
|
||||
const reviseTemplate = useCallback(async (id: string, payload: {
|
||||
file: File | Blob;
|
||||
name?: string;
|
||||
type?: string;
|
||||
contract_type?: 'contract' | 'gdpr';
|
||||
lang?: 'en' | 'de' | string;
|
||||
description?: string;
|
||||
user_type?: 'personal' | 'company' | 'both';
|
||||
state?: 'active' | 'inactive';
|
||||
}): Promise<DocumentTemplate> => {
|
||||
const fd = new FormData();
|
||||
const file = payload.file instanceof File
|
||||
? payload.file
|
||||
: new File([payload.file], `${payload.name || 'template'}.html`, { type: 'text/html' });
|
||||
fd.append('file', file);
|
||||
if (payload.name !== undefined) fd.append('name', payload.name);
|
||||
if (payload.type !== undefined) fd.append('type', payload.type);
|
||||
if (payload.contract_type !== undefined) fd.append('contract_type', payload.contract_type);
|
||||
if (payload.lang !== undefined) fd.append('lang', payload.lang);
|
||||
if (payload.description !== undefined) fd.append('description', payload.description);
|
||||
if (payload.user_type !== undefined) fd.append('user_type', payload.user_type);
|
||||
if (payload.state !== undefined) fd.append('state', payload.state);
|
||||
|
||||
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/revise`, { method: 'POST', body: fd });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const updateTemplateState = useCallback(async (id: string, state: 'active' | 'inactive'): Promise<DocumentTemplate> => {
|
||||
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ state }),
|
||||
});
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const generatePdfWithSignature = useCallback(async (id: string, payload: {
|
||||
signatureImage?: string; signature?: string; signatureData?: string;
|
||||
userData?: Json; user?: Json; context?: Json;
|
||||
currentDate?: string;
|
||||
}): Promise<Blob> => {
|
||||
const body: Json = {};
|
||||
if (payload.signatureImage) body.signatureImage = payload.signatureImage;
|
||||
if (payload.signature) body.signature = payload.signature;
|
||||
if (payload.signatureData) body.signatureData = payload.signatureData;
|
||||
if (payload.userData) body.userData = payload.userData;
|
||||
if (payload.user) body.user = payload.user;
|
||||
if (payload.context) body.context = payload.context;
|
||||
if (payload.currentDate) body.currentDate = payload.currentDate;
|
||||
return authorizedFetch<Blob>(`/api/document-templates/${id}/generate-pdf-with-signature`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}, 'blob');
|
||||
}, [authorizedFetch]);
|
||||
|
||||
// Helper: convert various base64 forms into a clean data URI
|
||||
const toDataUri = useCallback((raw: any, mime?: string | null): string | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
let s = String(raw);
|
||||
if (s.startsWith('data:')) return s; // already a data URI
|
||||
// Remove optional "base64," prefix
|
||||
s = s.replace(/^base64,/, '');
|
||||
// Remove whitespace/newlines
|
||||
s = s.replace(/\s+/g, '');
|
||||
// Convert URL-safe base64 to standard
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Pad to a multiple of 4
|
||||
while (s.length % 4 !== 0) s += '=';
|
||||
const m = mime || 'image/png';
|
||||
return `data:${m};base64,${s}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Helper: unwrap arrays from common API envelope shapes
|
||||
const unwrapList = useCallback((raw: any): any[] => {
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (Array.isArray(raw?.data)) return raw.data;
|
||||
if (Array.isArray(raw?.items)) return raw.items;
|
||||
if (Array.isArray(raw?.results)) return raw.results;
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
// Add image_base64 and other common variants
|
||||
const normalizeStamp = useCallback((s: any, forceActive = false): CompanyStamp => {
|
||||
const mime = s?.mime_type ?? s?.mimeType ?? s?.mimetype ?? s?.type ?? 'image/png';
|
||||
const imgRaw =
|
||||
s?.image ??
|
||||
s?.image_data ??
|
||||
s?.image_base64 ?? // backend key seen in logs
|
||||
s?.imageBase64 ??
|
||||
s?.base64 ??
|
||||
s?.data ??
|
||||
null;
|
||||
|
||||
try {
|
||||
const presentKeys = Object.keys(s || {}).filter(k =>
|
||||
['image', 'image_data', 'image_base64', 'imageBase64', 'base64', 'data', 'mime_type', 'mimeType'].includes(k)
|
||||
);
|
||||
console.debug('[CM] normalizeStamp keys:', presentKeys, 'hasImg:', !!imgRaw);
|
||||
} catch {}
|
||||
|
||||
const dataUri = toDataUri(imgRaw, mime);
|
||||
return {
|
||||
id: s?.id ?? s?._id ?? s?.uuid ?? s?.stamp_id ?? String(Math.random()),
|
||||
label: s?.label ?? null,
|
||||
mimeType: mime,
|
||||
base64: dataUri,
|
||||
active: forceActive ? true : !!(s?.is_active ?? s?.active ?? s?.isActive),
|
||||
createdAt: s?.createdAt ?? s?.created_at,
|
||||
};
|
||||
}, [toDataUri]);
|
||||
|
||||
// New: fetch all stamps and the active one in one request
|
||||
const listStampsAll = useCallback(async (): Promise<{ stamps: CompanyStamp[]; active: CompanyStamp | null; activeId?: string | null; }> => {
|
||||
const raw = await authorizedFetch<any>('/api/company-stamps/all', { method: 'GET' });
|
||||
try {
|
||||
console.debug('[CM] /api/company-stamps/all raw:', {
|
||||
isArray: Array.isArray(raw),
|
||||
topKeys: raw && !Array.isArray(raw) ? Object.keys(raw) : [],
|
||||
dataKeys: raw?.data ? Object.keys(raw.data) : [],
|
||||
});
|
||||
// Log first item keys to confirm field names like image_base64
|
||||
const sample = Array.isArray(raw) ? raw[0] : (raw?.data?.[0] ?? raw?.items?.[0] ?? raw?.stamps?.[0]);
|
||||
if (sample) console.debug('[CM] /api/company-stamps/all sample keys:', Object.keys(sample));
|
||||
} catch {}
|
||||
|
||||
const container = raw?.data ?? raw;
|
||||
const rawList: any[] =
|
||||
Array.isArray(container?.items) ? container.items
|
||||
: Array.isArray(container?.stamps) ? container.stamps
|
||||
: Array.isArray(container?.list) ? container.list
|
||||
: Array.isArray(container) ? container
|
||||
: Array.isArray(raw?.items) ? raw.items
|
||||
: Array.isArray(raw) ? raw
|
||||
: [];
|
||||
const rawActive = container?.active ?? container?.current ?? container?.activeStamp ?? null;
|
||||
|
||||
const stamps = rawList.map((s: any) => normalizeStamp(s));
|
||||
let active = rawActive ? normalizeStamp(rawActive, true) : null;
|
||||
|
||||
// Derive active from list if not provided separately
|
||||
if (!active) {
|
||||
const fromList = stamps.find(s => s.active);
|
||||
if (fromList) active = { ...fromList, active: true };
|
||||
}
|
||||
|
||||
// Mark the active in stamps if present
|
||||
const activeId = active?.id ?? (container?.active_id ?? container?.activeId ?? null);
|
||||
const stampsMarked = activeId
|
||||
? stamps.map((s) => (s.id === activeId ? { ...s, active: true, base64: s.base64 || active?.base64, mimeType: s.mimeType || active?.mimeType } : s))
|
||||
: stamps;
|
||||
|
||||
try {
|
||||
console.debug('[CM] /api/company-stamps/all normalized:', {
|
||||
total: stampsMarked.length,
|
||||
withImg: stampsMarked.filter(s => !!s.base64).length,
|
||||
activeId: activeId || active?.id || null,
|
||||
hasActiveImg: !!active?.base64,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return { stamps: stampsMarked, active, activeId: activeId || active?.id || null };
|
||||
}, [authorizedFetch, normalizeStamp]);
|
||||
|
||||
const listMyCompanyStamps = useCallback(async (): Promise<CompanyStamp[]> => {
|
||||
const { stamps } = await listStampsAll();
|
||||
return stamps;
|
||||
}, [listStampsAll]);
|
||||
|
||||
const getActiveCompanyStamp = useCallback(async (): Promise<CompanyStamp | null> => {
|
||||
const { active } = await listStampsAll();
|
||||
return active ?? null;
|
||||
}, [listStampsAll]);
|
||||
|
||||
// helper: convert File/Blob to base64 string and mime
|
||||
const fileToBase64 = useCallback(
|
||||
(file: File | Blob) =>
|
||||
new Promise<{ base64: string; mime: string }>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || '');
|
||||
const m = result.match(/^data:(.+?);base64,(.*)$/);
|
||||
if (m) {
|
||||
resolve({ mime: m[1], base64: m[2] });
|
||||
} else {
|
||||
resolve({ mime: (file as File).type || 'application/octet-stream', base64: result });
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Upload expects JSON { base64, mime_type/mimeType, label? }
|
||||
const uploadCompanyStamp = useCallback(async (file: File | Blob, label?: string) => {
|
||||
const { base64, mime } = await fileToBase64(file);
|
||||
try {
|
||||
console.debug('[CM] uploadCompanyStamp payload:', { mime, base64Len: base64?.length || 0, hasLabel: !!label });
|
||||
} catch {}
|
||||
return authorizedFetch<CompanyStamp>('/api/company-stamps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
base64,
|
||||
mimeType: mime,
|
||||
mime_type: mime,
|
||||
...(label ? { label } : {}),
|
||||
}),
|
||||
});
|
||||
}, [authorizedFetch, fileToBase64]);
|
||||
|
||||
const activateCompanyStamp = useCallback(async (id: string) => {
|
||||
console.debug('[CM] activateCompanyStamp ->', id);
|
||||
return authorizedFetch<{ success?: boolean }>(`/api/company-stamps/${id}/activate`, { method: 'PATCH' });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const deactivateCompanyStamp = useCallback(async (id: string) => {
|
||||
console.debug('[CM] deactivateCompanyStamp ->', id);
|
||||
return authorizedFetch<{ success?: boolean }>(`/api/company-stamps/${id}/deactivate`, { method: 'PATCH' });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
// Delete a company stamp (strict: no fallback)
|
||||
const deleteCompanyStamp = useCallback(async (id: string) => {
|
||||
console.debug('[CM] deleteCompanyStamp ->', id);
|
||||
return authorizedFetch<{ success?: boolean }>(`/api/company-stamps/${id}`, { method: 'DELETE' });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const downloadBlobFile = useCallback((blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
const listTemplatesPublic = useCallback(async (): Promise<DocumentTemplate[]> => {
|
||||
const data = await authorizedFetch<DocumentTemplate[]>('/api/document-templates/public', { method: 'GET' });
|
||||
return Array.isArray(data) ? data : [];
|
||||
}, [authorizedFetch]);
|
||||
|
||||
return {
|
||||
// templates
|
||||
listTemplates,
|
||||
getTemplate,
|
||||
previewTemplateHtml,
|
||||
openPreviewInNewTab,
|
||||
generatePdf,
|
||||
downloadPdf,
|
||||
uploadTemplate,
|
||||
reviseTemplate,
|
||||
updateTemplate,
|
||||
updateTemplateState,
|
||||
generatePdfWithSignature,
|
||||
listTemplatesPublic,
|
||||
// stamps
|
||||
listStampsAll,
|
||||
listMyCompanyStamps,
|
||||
getActiveCompanyStamp,
|
||||
uploadCompanyStamp,
|
||||
activateCompanyStamp,
|
||||
deactivateCompanyStamp,
|
||||
deleteCompanyStamp,
|
||||
// utils
|
||||
downloadBlobFile,
|
||||
};
|
||||
}
|
||||
134
src/app/admin/contract-management/page.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PageLayout from '../../components/PageLayout';
|
||||
import ContractEditor from './components/contractEditor';
|
||||
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
|
||||
import ContractTemplateList from './components/contractTemplateList';
|
||||
import useAuthStore from '../../store/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const NAV = [
|
||||
{ key: 'stamp', label: 'Company Stamp', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> },
|
||||
{ key: 'templates', label: 'Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg> },
|
||||
{ key: 'editor', label: 'Create Template', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> },
|
||||
];
|
||||
|
||||
export default function ContractManagementPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const [section, setSection] = useState('templates');
|
||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
// Only allow admin
|
||||
const isAdmin =
|
||||
!!user &&
|
||||
(
|
||||
(user as any)?.role === 'admin' ||
|
||||
(user as any)?.userType === 'admin' ||
|
||||
(user as any)?.isAdmin === true ||
|
||||
((user as any)?.roles?.includes?.('admin'))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted && !isAdmin) {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [mounted, isAdmin, router]);
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const bumpRefresh = () => setRefreshKey((k) => k + 1);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
{/* tighter horizontal padding on mobile */}
|
||||
<div className="flex flex-col md:flex-row max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 md:py-8 gap-6 md:gap-8">
|
||||
{/* Sidebar Navigation (mobile = horizontal scroll tabs, desktop = vertical) */}
|
||||
<nav className="md:w-56 w-full md:self-start md:sticky md:top-6">
|
||||
<div className="flex md:flex-col flex-row gap-2 md:gap-3 overflow-x-auto md:overflow-visible -mx-4 px-4 md:mx-0 md:px-0 pb-2 md:pb-0">
|
||||
{NAV.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setSection(item.key)}
|
||||
className={`flex flex-shrink-0 items-center gap-2 px-4 py-2 rounded-lg font-medium transition whitespace-nowrap text-sm md:text-base
|
||||
${section === item.key
|
||||
? 'bg-blue-900 text-blue-50 shadow'
|
||||
: 'bg-white text-blue-900 hover:bg-blue-50 hover:text-blue-900 border border-blue-200'}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 space-y-6 md:space-y-8">
|
||||
{/* sticky only on md+; smaller padding/title on mobile */}
|
||||
<header className="md:sticky md:top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-5 px-4 md:py-10 md:px-8 rounded-2xl shadow-lg flex flex-col gap-3 md:gap-4 mb-2 md:mb-4">
|
||||
<h1 className="text-2xl md:text-4xl font-extrabold text-blue-900 tracking-tight">Contract Management</h1>
|
||||
<p className="text-sm md:text-lg text-blue-700">
|
||||
Manage contract templates, company stamp, and create new templates.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Section Panels (compact padding on mobile) */}
|
||||
{section === 'stamp' && (
|
||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
|
||||
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||||
Company Stamp
|
||||
</h2>
|
||||
<ContractUploadCompanyStamp onUploaded={bumpRefresh} />
|
||||
</section>
|
||||
)}
|
||||
{section === 'templates' && (
|
||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
|
||||
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
Templates
|
||||
</h2>
|
||||
<ContractTemplateList
|
||||
refreshKey={refreshKey}
|
||||
onEdit={(id) => {
|
||||
setEditingTemplateId(id);
|
||||
setSection('editor');
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{section === 'editor' && (
|
||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
|
||||
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
||||
Create Template
|
||||
</h2>
|
||||
<ContractEditor
|
||||
editingTemplateId={editingTemplateId}
|
||||
onCancelEdit={() => {
|
||||
setEditingTemplateId(null);
|
||||
setSection('templates');
|
||||
}}
|
||||
onSaved={(info) => {
|
||||
bumpRefresh();
|
||||
if (info?.action === 'revised') {
|
||||
setEditingTemplateId(null);
|
||||
setSection('templates');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
49
src/app/admin/dev-management/hooks/executeSql.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
import { log } from '../../../utils/logger';
|
||||
|
||||
export type SqlExecutionData = {
|
||||
result?: any;
|
||||
isMulti?: boolean;
|
||||
};
|
||||
|
||||
export type SqlExecutionMeta = {
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
export async function importSqlDump(file: File): Promise<{ ok: boolean; data?: SqlExecutionData; meta?: SqlExecutionMeta; message?: string }>{
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/dev/sql`;
|
||||
|
||||
log('🧪 Dev SQL Import: POST', url);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: form
|
||||
});
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return { ok: false, message: 'Unauthorized. Please log in.' };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, message: body?.error || 'SQL execution failed.' };
|
||||
}
|
||||
|
||||
return { ok: true, data: body?.data, meta: body?.meta };
|
||||
} catch (e: any) {
|
||||
log('❌ Dev SQL: network error', e?.message || e);
|
||||
return { ok: false, message: 'Network error while executing SQL.' };
|
||||
}
|
||||
}
|
||||
212
src/app/admin/dev-management/hooks/exoscaleMaintenance.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
import { log } from '../../../utils/logger';
|
||||
|
||||
export type FolderStructureIssueUser = {
|
||||
userId: number;
|
||||
email: string;
|
||||
userType: string;
|
||||
name?: string | null;
|
||||
contractCategory: string;
|
||||
basePrefix: string;
|
||||
totalObjects: number;
|
||||
hasContractFolder: boolean;
|
||||
hasGdprFolder: boolean;
|
||||
};
|
||||
|
||||
export type LooseFileUser = {
|
||||
userId: number;
|
||||
email: string;
|
||||
userType: string;
|
||||
name?: string | null;
|
||||
contractCategory: string;
|
||||
basePrefix: string;
|
||||
looseObjects: number;
|
||||
sampleKeys?: string[];
|
||||
};
|
||||
|
||||
export type GhostDirectory = {
|
||||
userId: number;
|
||||
contractCategory: string;
|
||||
basePrefix: string;
|
||||
};
|
||||
|
||||
export type FixResult = {
|
||||
userId: number;
|
||||
contractCategory?: string;
|
||||
created?: number;
|
||||
moved: number;
|
||||
skipped: number;
|
||||
errors?: { key: string; destKey?: string; message?: string }[];
|
||||
};
|
||||
|
||||
export type FixMeta = {
|
||||
processedUsers?: number;
|
||||
createdTotal?: number;
|
||||
movedTotal?: number;
|
||||
errorCount?: number;
|
||||
};
|
||||
|
||||
export async function listFolderStructureIssues(): Promise<{ ok: boolean; data?: FolderStructureIssueUser[]; meta?: any; message?: string }> {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/dev/exoscale/folder-structure-issues`;
|
||||
|
||||
log('🧪 Dev Exoscale: GET', url);
|
||||
try {
|
||||
const res = await authFetch(url, { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return { ok: false, message: 'Unauthorized. Please log in.' };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, message: body?.error || 'Failed to load folder structure issues.' };
|
||||
}
|
||||
|
||||
return { ok: true, data: body?.data || [], meta: body?.meta };
|
||||
} catch (e: any) {
|
||||
log('❌ Dev Exoscale: network error', e?.message || e);
|
||||
return { ok: false, message: 'Network error while loading invalid folders.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFolderStructure(userId?: number): Promise<{ ok: boolean; data?: FixResult[]; meta?: FixMeta; message?: string }> {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/dev/exoscale/create-folder-structure`;
|
||||
|
||||
log('🧪 Dev Exoscale: POST', url);
|
||||
try {
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userId ? { userId } : {})
|
||||
});
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return { ok: false, message: 'Unauthorized. Please log in.' };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, message: body?.error || 'Failed to create folder structure.' };
|
||||
}
|
||||
|
||||
return { ok: true, data: body?.data || [], meta: body?.meta };
|
||||
} catch (e: any) {
|
||||
log('❌ Dev Exoscale: network error', e?.message || e);
|
||||
return { ok: false, message: 'Network error while creating folder structure.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLooseFiles(): Promise<{ ok: boolean; data?: LooseFileUser[]; meta?: any; message?: string }> {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/dev/exoscale/loose-files`;
|
||||
|
||||
log('🧪 Dev Exoscale: GET', url);
|
||||
try {
|
||||
const res = await authFetch(url, { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return { ok: false, message: 'Unauthorized. Please log in.' };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, message: body?.error || 'Failed to load loose files.' };
|
||||
}
|
||||
|
||||
return { ok: true, data: body?.data || [], meta: body?.meta };
|
||||
} catch (e: any) {
|
||||
log('❌ Dev Exoscale: network error', e?.message || e);
|
||||
return { ok: false, message: 'Network error while loading loose files.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveLooseFilesToContract(userId?: number): Promise<{ ok: boolean; data?: FixResult[]; meta?: FixMeta; message?: string }> {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/dev/exoscale/move-loose-files`;
|
||||
|
||||
log('🧪 Dev Exoscale: POST', url);
|
||||
try {
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userId ? { userId } : {})
|
||||
});
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return { ok: false, message: 'Unauthorized. Please log in.' };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, message: body?.error || 'Failed to move loose files.' };
|
||||
}
|
||||
|
||||
return { ok: true, data: body?.data || [], meta: body?.meta };
|
||||
} catch (e: any) {
|
||||
log('❌ Dev Exoscale: network error', e?.message || e);
|
||||
return { ok: false, message: 'Network error while moving loose files.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGhostDirectories(): Promise<{ ok: boolean; data?: GhostDirectory[]; meta?: any; message?: string }> {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/dev/exoscale/ghost-directories`;
|
||||
|
||||
log('🧪 Dev Exoscale: GET', url);
|
||||
try {
|
||||
const res = await authFetch(url, { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return { ok: false, message: 'Unauthorized. Please log in.' };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, message: body?.error || 'Failed to load ghost directories.' };
|
||||
}
|
||||
|
||||
return { ok: true, data: body?.data || [], meta: body?.meta };
|
||||
} catch (e: any) {
|
||||
log('❌ Dev Exoscale: network error', e?.message || e);
|
||||
return { ok: false, message: 'Network error while loading ghost directories.' };
|
||||
}
|
||||
}
|
||||
710
src/app/admin/dev-management/page.tsx
Normal file
@ -0,0 +1,710 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Header from '../../components/nav/Header'
|
||||
import Footer from '../../components/Footer'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
import { CommandLineIcon, PlayIcon, TrashIcon, ExclamationTriangleIcon, ArrowUpTrayIcon, WrenchScrewdriverIcon, FolderOpenIcon, ArrowPathIcon } from '@heroicons/react/24/outline'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/executeSql'
|
||||
import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance'
|
||||
|
||||
export default function DevManagementPage() {
|
||||
const router = useRouter()
|
||||
const user = useAuthStore(s => s.user)
|
||||
|
||||
const isAdmin =
|
||||
!!user &&
|
||||
(
|
||||
(user as any)?.role === 'admin' ||
|
||||
(user as any)?.userType === 'admin' ||
|
||||
(user as any)?.isAdmin === true ||
|
||||
((user as any)?.roles?.includes?.('admin')) ||
|
||||
(user as any)?.role === 'super_admin' ||
|
||||
(user as any)?.userType === 'super_admin' ||
|
||||
(user as any)?.isSuperAdmin === true ||
|
||||
((user as any)?.roles?.includes?.('super_admin'))
|
||||
)
|
||||
|
||||
const [authChecked, setAuthChecked] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
if (user === null) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
if (user && !isAdmin) {
|
||||
router.replace('/admin')
|
||||
return
|
||||
}
|
||||
setAuthChecked(true)
|
||||
}, [user, isAdmin, router])
|
||||
|
||||
const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState('')
|
||||
const [result, setResult] = React.useState<SqlExecutionData | null>(null)
|
||||
const [meta, setMeta] = React.useState<SqlExecutionMeta | null>(null)
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState<'sql' | 'structure' | 'loose' | 'ghost'>('sql')
|
||||
const [structureUsers, setStructureUsers] = React.useState<FolderStructureIssueUser[]>([])
|
||||
const [structureMeta, setStructureMeta] = React.useState<{ scannedUsers?: number; invalidCount?: number } | null>(null)
|
||||
const [looseUsers, setLooseUsers] = React.useState<LooseFileUser[]>([])
|
||||
const [looseMeta, setLooseMeta] = React.useState<{ scannedUsers?: number; looseCount?: number } | null>(null)
|
||||
const [ghostDirs, setGhostDirs] = React.useState<GhostDirectory[]>([])
|
||||
const [ghostMeta, setGhostMeta] = React.useState<{ ghostCount?: number } | null>(null)
|
||||
const [exoscaleLoading, setExoscaleLoading] = React.useState(false)
|
||||
const [exoscaleError, setExoscaleError] = React.useState('')
|
||||
const [fixingUserId, setFixingUserId] = React.useState<number | null>(null)
|
||||
const [fixingAll, setFixingAll] = React.useState(false)
|
||||
const [fixResults, setFixResults] = React.useState<Record<number, FixResult>>({})
|
||||
const [structureActionMeta, setStructureActionMeta] = React.useState<{ processedUsers?: number; createdTotal?: number; errorCount?: number } | null>(null)
|
||||
const [structureActionResults, setStructureActionResults] = React.useState<FixResult[]>([])
|
||||
const [looseActionMeta, setLooseActionMeta] = React.useState<{ processedUsers?: number; movedTotal?: number; errorCount?: number } | null>(null)
|
||||
const [looseActionResults, setLooseActionResults] = React.useState<FixResult[]>([])
|
||||
const [structureStatus, setStructureStatus] = React.useState('')
|
||||
const [looseStatus, setLooseStatus] = React.useState('')
|
||||
const [ghostStatus, setGhostStatus] = React.useState('')
|
||||
|
||||
const formatNow = () => new Date().toLocaleString()
|
||||
|
||||
const runImport = async () => {
|
||||
setError('')
|
||||
if (!selectedFile) {
|
||||
setError('Please select a SQL dump file.')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await importSqlDump(selectedFile)
|
||||
if (!res.ok) {
|
||||
setError(res.message || 'Failed to import SQL dump.')
|
||||
return
|
||||
}
|
||||
setResult(res.data || null)
|
||||
setMeta(res.meta || null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearResults = () => {
|
||||
setResult(null)
|
||||
setMeta(null)
|
||||
setError('')
|
||||
}
|
||||
|
||||
const loadStructureIssues = async () => {
|
||||
setExoscaleError('')
|
||||
setExoscaleLoading(true)
|
||||
setStructureStatus('Refreshing list...')
|
||||
try {
|
||||
const res = await listFolderStructureIssues()
|
||||
if (!res.ok) {
|
||||
setExoscaleError(res.message || 'Failed to load folder structure issues.')
|
||||
return
|
||||
}
|
||||
setStructureUsers(res.data || [])
|
||||
setStructureMeta(res.meta || null)
|
||||
setStructureStatus(`Last refresh: ${formatNow()}`)
|
||||
} finally {
|
||||
setExoscaleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadLooseFiles = async () => {
|
||||
setExoscaleError('')
|
||||
setExoscaleLoading(true)
|
||||
setLooseStatus('Refreshing list...')
|
||||
try {
|
||||
const res = await listLooseFiles()
|
||||
if (!res.ok) {
|
||||
setExoscaleError(res.message || 'Failed to load loose files.')
|
||||
return
|
||||
}
|
||||
setLooseUsers(res.data || [])
|
||||
setLooseMeta(res.meta || null)
|
||||
setLooseStatus(`Last refresh: ${formatNow()}`)
|
||||
} finally {
|
||||
setExoscaleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadGhostDirectories = async () => {
|
||||
setExoscaleError('')
|
||||
setExoscaleLoading(true)
|
||||
setGhostStatus('Refreshing list...')
|
||||
try {
|
||||
const res = await listGhostDirectories()
|
||||
if (!res.ok) {
|
||||
setExoscaleError(res.message || 'Failed to load ghost directories.')
|
||||
return
|
||||
}
|
||||
setGhostDirs(res.data || [])
|
||||
setGhostMeta(res.meta || null)
|
||||
setGhostStatus(`Last refresh: ${formatNow()}`)
|
||||
} finally {
|
||||
setExoscaleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runCreateStructure = async (userId?: number) => {
|
||||
setExoscaleError('')
|
||||
if (userId) {
|
||||
setFixingUserId(userId)
|
||||
setStructureStatus(`Creating folders for #${userId}...`)
|
||||
} else {
|
||||
setFixingAll(true)
|
||||
setStructureStatus('Creating folders for each user...')
|
||||
setStructureActionResults([])
|
||||
setStructureActionMeta({ processedUsers: 0, createdTotal: 0, errorCount: 0 })
|
||||
}
|
||||
|
||||
try {
|
||||
const targets = userId ? [{ userId }] : structureUsers.map(u => ({ userId: u.userId }))
|
||||
for (const target of targets) {
|
||||
const res = await createFolderStructure(target.userId)
|
||||
if (!res.ok) {
|
||||
setExoscaleError(res.message || 'Failed to create folder structure.')
|
||||
break
|
||||
}
|
||||
const batch = res.data || []
|
||||
setStructureActionResults(prev => [...prev, ...batch])
|
||||
setStructureActionMeta(prev => ({
|
||||
processedUsers: (prev?.processedUsers || 0) + (res.meta?.processedUsers || 0),
|
||||
createdTotal: (prev?.createdTotal || 0) + (res.meta?.createdTotal || 0),
|
||||
errorCount: (prev?.errorCount || 0) + (res.meta?.errorCount || 0)
|
||||
}))
|
||||
setFixResults(prev => {
|
||||
const next = { ...prev }
|
||||
for (const item of batch) {
|
||||
next[item.userId] = item
|
||||
}
|
||||
return next
|
||||
})
|
||||
setStructureStatus(`Last create: ${formatNow()} (processed #${target.userId})`)
|
||||
}
|
||||
await loadStructureIssues()
|
||||
} finally {
|
||||
setFixingUserId(null)
|
||||
setFixingAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runMoveLooseFiles = async (userId?: number) => {
|
||||
setExoscaleError('')
|
||||
if (userId) {
|
||||
setFixingUserId(userId)
|
||||
setLooseStatus(`Moving loose files for #${userId}...`)
|
||||
} else {
|
||||
setFixingAll(true)
|
||||
setLooseStatus('Moving loose files for each user...')
|
||||
setLooseActionResults([])
|
||||
setLooseActionMeta({ processedUsers: 0, movedTotal: 0, errorCount: 0 })
|
||||
}
|
||||
|
||||
try {
|
||||
const targets = userId ? [{ userId }] : looseUsers.map(u => ({ userId: u.userId }))
|
||||
for (const target of targets) {
|
||||
const res = await moveLooseFilesToContract(target.userId)
|
||||
if (!res.ok) {
|
||||
setExoscaleError(res.message || 'Failed to move loose files.')
|
||||
break
|
||||
}
|
||||
const batch = res.data || []
|
||||
setLooseActionResults(prev => [...prev, ...batch])
|
||||
setLooseActionMeta(prev => ({
|
||||
processedUsers: (prev?.processedUsers || 0) + (res.meta?.processedUsers || 0),
|
||||
movedTotal: (prev?.movedTotal || 0) + (res.meta?.movedTotal || 0),
|
||||
errorCount: (prev?.errorCount || 0) + (res.meta?.errorCount || 0)
|
||||
}))
|
||||
setFixResults(prev => {
|
||||
const next = { ...prev }
|
||||
for (const item of batch) {
|
||||
next[item.userId] = item
|
||||
}
|
||||
return next
|
||||
})
|
||||
setLooseStatus(`Last move: ${formatNow()} (processed #${target.userId})`)
|
||||
}
|
||||
await loadLooseFiles()
|
||||
} finally {
|
||||
setFixingUserId(null)
|
||||
setFixingAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeTab === 'structure') loadStructureIssues()
|
||||
if (activeTab === 'loose') loadLooseFiles()
|
||||
if (activeTab === 'ghost') loadGhostDirectories()
|
||||
}, [activeTab])
|
||||
|
||||
const onImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setSelectedFile(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
if (!authChecked) return null
|
||||
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 via-white to-blue-50">
|
||||
<Header />
|
||||
{/* tighter padding on mobile */}
|
||||
<main className="flex-1 py-6 md:py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<header className="bg-white/90 backdrop-blur border border-blue-100 py-6 md:py-8 px-4 sm:px-6 rounded-2xl shadow-lg flex flex-col gap-4">
|
||||
{/* stack on mobile */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="h-11 w-11 sm:h-12 sm:w-12 rounded-xl bg-blue-100 border border-blue-200 flex items-center justify-center flex-shrink-0">
|
||||
<CommandLineIcon className="h-6 w-6 text-blue-700" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-blue-900">Dev Management</h1>
|
||||
<p className="text-sm sm:text-base text-blue-700">
|
||||
Import SQL dump files to run database migrations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold">Use with caution</div>
|
||||
<div>SQL dumps run immediately and can modify production data.</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="inline-flex w-full sm:w-auto rounded-xl border border-blue-100 bg-white/90 p-1 shadow-sm">
|
||||
<button
|
||||
onClick={() => setActiveTab('sql')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||
activeTab === 'sql' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||
}`}
|
||||
>
|
||||
<CommandLineIcon className="h-4 w-4" /> SQL Import
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('structure')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||
activeTab === 'structure' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||
}`}
|
||||
>
|
||||
<WrenchScrewdriverIcon className="h-4 w-4" /> Folder Structure
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('loose')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||
activeTab === 'loose' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||
}`}
|
||||
>
|
||||
<FolderOpenIcon className="h-4 w-4" /> Loose Files
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ghost')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||
activeTab === 'ghost' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||
}`}
|
||||
>
|
||||
<FolderOpenIcon className="h-4 w-4" /> Ghost Directories
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'sql' && (
|
||||
<>
|
||||
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<h2 className="text-lg font-semibold text-blue-900">SQL Dump Import</h2>
|
||||
|
||||
{/* actions: stack on mobile, full width */}
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<ArrowUpTrayIcon className="h-4 w-4" /> Import SQL
|
||||
</button>
|
||||
<button
|
||||
onClick={clearResults}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" /> Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={runImport}
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
||||
>
|
||||
<PlayIcon className="h-4 w-4" /> {loading ? 'Importing...' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-4 sm:px-6 py-8 sm:py-10 text-center">
|
||||
<div className="text-sm text-gray-600">Select a .sql dump file using Import SQL.</div>
|
||||
<div className="mt-2 text-xs text-gray-500">Only SQL dump files are supported.</div>
|
||||
{selectedFile && (
|
||||
<div className="mt-4 text-sm text-blue-900 font-semibold break-words">
|
||||
Selected: {selectedFile.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".sql,text/plain"
|
||||
className="hidden"
|
||||
onChange={onImportFile}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-3">Result Summary</h3>
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<span>Result Sets</span>
|
||||
<span className="font-semibold text-blue-900">
|
||||
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Multi-statement</span>
|
||||
<span className="font-semibold text-blue-900">{result?.isMulti ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Duration</span>
|
||||
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-xs text-gray-500">
|
||||
Multi-statement SQL and dump files are supported. Use with caution.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-blue-900">Import Results</h2>
|
||||
</div>
|
||||
|
||||
{!result && (
|
||||
<div className="text-sm text-gray-500">No results yet. Import a SQL dump to see output.</div>
|
||||
)}
|
||||
|
||||
{result?.result && (
|
||||
<pre className="mt-2 rounded-lg bg-slate-50 border border-gray-200 p-3 md:p-4 text-xs text-gray-800 overflow-auto">
|
||||
{JSON.stringify(result.result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'structure' && (
|
||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-blue-900">Exoscale Folder Structure</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Ensures both contract and gdpr folders exist for each user.
|
||||
</p>
|
||||
{structureStatus && (
|
||||
<div className="text-xs text-slate-500">{structureStatus}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<button
|
||||
onClick={loadStructureIssues}
|
||||
disabled={exoscaleLoading}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runCreateStructure()}
|
||||
disabled={fixingAll || exoscaleLoading || structureUsers.length === 0}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
||||
>
|
||||
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingAll ? 'Creating...' : 'Create All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-3 text-xs text-gray-600">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
|
||||
<FolderOpenIcon className="h-4 w-4 text-slate-500" />
|
||||
Scanned: {structureMeta?.scannedUsers ?? '-'}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
|
||||
Missing: {structureMeta?.invalidCount ?? structureUsers.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{exoscaleError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{exoscaleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exoscaleLoading ? (
|
||||
<div className="text-sm text-gray-500">Loading folder issues...</div>
|
||||
) : structureUsers.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No missing folders found. Run Refresh to scan again.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
||||
{structureUsers.map(user => {
|
||||
const fix = fixResults[user.userId]
|
||||
const displayName = user.name || user.email
|
||||
const missing = [!user.hasContractFolder ? 'contract' : null, !user.hasGdprFolder ? 'gdpr' : null].filter(Boolean).join(', ')
|
||||
return (
|
||||
<div key={user.userId} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-blue-900 truncate">{displayName}</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
#{user.userId} • {user.email} • {user.userType} • {user.contractCategory}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">Missing: <span className="font-semibold text-blue-900">{missing || 'none'}</span></div>
|
||||
{fix && (
|
||||
<div className="mt-2 text-xs text-emerald-700">
|
||||
Created {fix.created || 0}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => runCreateStructure(user.userId)}
|
||||
disabled={fixingUserId === user.userId || fixingAll}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingUserId === user.userId ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(structureActionMeta || structureActionResults.length > 0) && (
|
||||
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">Last Folder Structure Action</div>
|
||||
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
|
||||
<span>Processed: {structureActionMeta?.processedUsers ?? '-'}</span>
|
||||
<span>Created: {structureActionMeta?.createdTotal ?? '-'}</span>
|
||||
<span>Errors: {structureActionMeta?.errorCount ?? '-'}</span>
|
||||
</div>
|
||||
{structureActionResults.length > 0 && (
|
||||
<div className="mt-3 space-y-2 text-xs text-gray-700">
|
||||
{structureActionResults.map(item => (
|
||||
<div key={item.userId} className="flex flex-wrap gap-2">
|
||||
<span className="font-semibold text-blue-900">#{item.userId}</span>
|
||||
{item.contractCategory && (
|
||||
<span className="text-gray-500">{item.contractCategory}</span>
|
||||
)}
|
||||
<span>Created: {item.created ?? 0}</span>
|
||||
{item.errors && item.errors.length > 0 && (
|
||||
<span className="text-red-700">Errors: {item.errors.length}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'loose' && (
|
||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-blue-900">Loose Files</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Shows files directly under the user folder that are not in contract or gdpr.
|
||||
</p>
|
||||
{looseStatus && (
|
||||
<div className="text-xs text-slate-500">{looseStatus}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<button
|
||||
onClick={loadLooseFiles}
|
||||
disabled={exoscaleLoading}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runMoveLooseFiles()}
|
||||
disabled={fixingAll || exoscaleLoading || looseUsers.length === 0}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
||||
>
|
||||
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingAll ? 'Moving...' : 'Move All to Contract'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-3 text-xs text-gray-600">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
|
||||
<FolderOpenIcon className="h-4 w-4 text-slate-500" />
|
||||
Scanned: {looseMeta?.scannedUsers ?? '-'}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
|
||||
Loose: {looseMeta?.looseCount ?? looseUsers.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{exoscaleError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{exoscaleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exoscaleLoading ? (
|
||||
<div className="text-sm text-gray-500">Loading loose files...</div>
|
||||
) : looseUsers.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No loose files found. Run Refresh to scan again.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
||||
{looseUsers.map(user => {
|
||||
const fix = fixResults[user.userId]
|
||||
const displayName = user.name || user.email
|
||||
return (
|
||||
<div key={user.userId} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-blue-900 truncate">{displayName}</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
#{user.userId} • {user.email} • {user.userType} • {user.contractCategory}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
Loose files: <span className="font-semibold text-blue-900">{user.looseObjects}</span>
|
||||
</div>
|
||||
{user.sampleKeys && user.sampleKeys.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-gray-400 break-all">
|
||||
Sample: {user.sampleKeys.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{fix && (
|
||||
<div className="mt-2 text-xs text-emerald-700">
|
||||
Moved {fix.moved}, skipped {fix.skipped}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => runMoveLooseFiles(user.userId)}
|
||||
disabled={fixingUserId === user.userId || fixingAll}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingUserId === user.userId ? 'Moving...' : 'Move'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(looseActionMeta || looseActionResults.length > 0) && (
|
||||
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">Last Loose Files Action</div>
|
||||
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
|
||||
<span>Processed: {looseActionMeta?.processedUsers ?? '-'}</span>
|
||||
<span>Moved: {looseActionMeta?.movedTotal ?? '-'}</span>
|
||||
<span>Errors: {looseActionMeta?.errorCount ?? '-'}</span>
|
||||
</div>
|
||||
{looseActionResults.length > 0 && (
|
||||
<div className="mt-3 space-y-2 text-xs text-gray-700">
|
||||
{looseActionResults.map(item => (
|
||||
<div key={item.userId} className="flex flex-wrap gap-2">
|
||||
<span className="font-semibold text-blue-900">#{item.userId}</span>
|
||||
{item.contractCategory && (
|
||||
<span className="text-gray-500">{item.contractCategory}</span>
|
||||
)}
|
||||
<span>Moved: {item.moved}</span>
|
||||
<span>Skipped: {item.skipped}</span>
|
||||
{item.errors && item.errors.length > 0 && (
|
||||
<span className="text-red-700">Errors: {item.errors.length}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'ghost' && (
|
||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-blue-900">Ghost Directories</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Exoscale directories that do not have a matching user in the database.
|
||||
</p>
|
||||
{ghostStatus && (
|
||||
<div className="text-xs text-slate-500">{ghostStatus}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadGhostDirectories}
|
||||
disabled={exoscaleLoading}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-xs text-gray-600">
|
||||
Ghost directories: {ghostMeta?.ghostCount ?? ghostDirs.length}
|
||||
</div>
|
||||
|
||||
{exoscaleError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{exoscaleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exoscaleLoading ? (
|
||||
<div className="text-sm text-gray-500">Loading ghost directories...</div>
|
||||
) : ghostDirs.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No ghost directories found. Run Refresh to scan again.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
||||
{ghostDirs.map(dir => (
|
||||
<div key={`${dir.contractCategory}-${dir.userId}`} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-blue-900 truncate">#{dir.userId}</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{dir.contractCategory} • {dir.basePrefix}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
}
|
||||
93
src/app/admin/finance-management/hooks/getInvoices.ts
Normal file
@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import useAuthStore from '../../../store/authStore';
|
||||
|
||||
export type AdminInvoice = {
|
||||
id: string | number;
|
||||
invoice_number?: string | null;
|
||||
user_id?: string | number | null;
|
||||
buyer_name?: string | null;
|
||||
buyer_email?: string | null;
|
||||
buyer_street?: string | null;
|
||||
buyer_postal_code?: string | null;
|
||||
buyer_city?: string | null;
|
||||
buyer_country?: string | null;
|
||||
currency?: string | null;
|
||||
total_net?: number | null;
|
||||
total_tax?: number | null;
|
||||
total_gross?: number | null;
|
||||
vat_rate?: number | null;
|
||||
status?: string;
|
||||
issued_at?: string | null;
|
||||
due_at?: string | null;
|
||||
pdf_storage_key?: string | null;
|
||||
context?: any | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export function useAdminInvoices(params?: { status?: string; limit?: number; offset?: number }) {
|
||||
const accessToken = useAuthStore(s => s.accessToken);
|
||||
const [invoices, setInvoices] = useState<AdminInvoice[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const inFlight = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchInvoices = useCallback(async () => {
|
||||
setError('');
|
||||
// Abort previous
|
||||
inFlight.current?.abort();
|
||||
const controller = new AbortController();
|
||||
inFlight.current = controller;
|
||||
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.status) qp.set('status', params.status);
|
||||
qp.set('limit', String(params?.limit ?? 200));
|
||||
qp.set('offset', String(params?.offset ?? 0));
|
||||
const url = `${base}/api/admin/invoices${qp.toString() ? `?${qp.toString()}` : ''}`;
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok || body?.success === false) {
|
||||
setInvoices([]);
|
||||
setError(body?.message || `Failed to load invoices (${res.status})`);
|
||||
return;
|
||||
}
|
||||
const list: AdminInvoice[] = Array.isArray(body?.data) ? body.data : [];
|
||||
// sort fallback (issued_at DESC then created_at DESC)
|
||||
list.sort((a, b) => {
|
||||
const ad = new Date(a.issued_at ?? a.created_at ?? 0).getTime();
|
||||
const bd = new Date(b.issued_at ?? b.created_at ?? 0).getTime();
|
||||
return bd - ad;
|
||||
});
|
||||
setInvoices(list);
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return;
|
||||
setError(e?.message || 'Network error');
|
||||
setInvoices([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (inFlight.current === controller) inFlight.current = null;
|
||||
}
|
||||
}, [accessToken, params?.status, params?.limit, params?.offset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) fetchInvoices();
|
||||
return () => inFlight.current?.abort();
|
||||
}, [accessToken, fetchInvoices]);
|
||||
|
||||
return { invoices, loading, error, reload: fetchInvoices };
|
||||
}
|
||||
56
src/app/admin/finance-management/hooks/getTaxes.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
|
||||
export type VatRate = {
|
||||
country_code: string
|
||||
country_name: string
|
||||
standard_rate?: number | null
|
||||
reduced_rate_1?: number | null
|
||||
reduced_rate_2?: number | null
|
||||
super_reduced_rate?: number | null
|
||||
parking_rate?: number | null
|
||||
effective_year?: number | null
|
||||
}
|
||||
|
||||
export function useVatRates() {
|
||||
const [rates, setRates] = useState<VatRate[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const reload = () => setRefreshKey(k => k + 1)
|
||||
|
||||
useEffect(() => {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/tax/vat-rates`
|
||||
const token = useAuthStore.getState().accessToken
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
authFetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(async (res) => {
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
if (!res.ok || !ct.includes('application/json')) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`Request failed: ${res.status} ${txt.slice(0, 160)}`)
|
||||
}
|
||||
const json = await res.json()
|
||||
const arr: VatRate[] = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []
|
||||
setRates(arr)
|
||||
})
|
||||
.catch((e: any) => {
|
||||
setError(e?.message || 'Failed to load VAT rates')
|
||||
setRates([])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [refreshKey])
|
||||
|
||||
return { rates, loading, error, reload }
|
||||
}
|
||||
246
src/app/admin/finance-management/page.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import PageLayout from '../../components/PageLayout'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useVatRates } from './hooks/getTaxes'
|
||||
import { useAdminInvoices } from './hooks/getInvoices'
|
||||
|
||||
export default function FinanceManagementPage() {
|
||||
const router = useRouter()
|
||||
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
||||
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
||||
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' })
|
||||
|
||||
// NEW: fetch invoices from backend
|
||||
const {
|
||||
invoices,
|
||||
loading: invLoading,
|
||||
error: invError,
|
||||
reload,
|
||||
} = useAdminInvoices({
|
||||
status: billFilter.status !== 'all' ? billFilter.status : undefined,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
// NEW: totals from backend invoices
|
||||
const totals = useMemo(() => {
|
||||
const now = new Date()
|
||||
const inRange = (d: Date) => {
|
||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (timeframe === '7d') return diff <= 7
|
||||
if (timeframe === '30d') return diff <= 30
|
||||
if (timeframe === '90d') return diff <= 90
|
||||
return true
|
||||
}
|
||||
const range = invoices.filter(inv => {
|
||||
const dStr = inv.issued_at ?? inv.created_at
|
||||
if (!dStr) return false
|
||||
const d = new Date(dStr)
|
||||
return inRange(d)
|
||||
})
|
||||
const totalAll = invoices.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
||||
const totalRange = range.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
||||
return { totalAll, totalRange }
|
||||
}, [invoices, timeframe])
|
||||
|
||||
// NEW: filtered rows for table
|
||||
const filteredBills = useMemo(() => {
|
||||
const q = billFilter.query.trim().toLowerCase()
|
||||
const from = billFilter.from ? new Date(billFilter.from) : null
|
||||
const to = billFilter.to ? new Date(billFilter.to) : null
|
||||
|
||||
return invoices.filter(inv => {
|
||||
const byQuery =
|
||||
!q ||
|
||||
String(inv.invoice_number ?? inv.id).toLowerCase().includes(q) ||
|
||||
String(inv.buyer_name ?? '').toLowerCase().includes(q)
|
||||
const issued = inv.issued_at ? new Date(inv.issued_at) : (inv.created_at ? new Date(inv.created_at) : null)
|
||||
const byFrom = from ? (issued ? issued >= from : false) : true
|
||||
const byTo = to ? (issued ? issued <= to : false) : true
|
||||
return byQuery && byFrom && byTo
|
||||
})
|
||||
}, [invoices, billFilter])
|
||||
|
||||
const exportBills = (format: 'csv' | 'pdf') => {
|
||||
console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
|
||||
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
<header className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-extrabold text-blue-900">Finance Management</h1>
|
||||
<p className="text-sm text-blue-700">Overview of taxes, revenue, and invoices.</p>
|
||||
</header>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
||||
<div className="text-xs text-gray-500 mb-1">Total revenue (all time)</div>
|
||||
<div className="text-2xl font-semibold text-[#1C2B4A]">€{totals.totalAll.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
||||
<div className="text-xs text-gray-500 mb-1">Revenue (range)</div>
|
||||
<div className="text-2xl font-semibold text-[#1C2B4A]">€{totals.totalRange.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
||||
<div className="text-xs text-gray-500 mb-1">Invoices (range)</div>
|
||||
<div className="text-2xl font-semibold text-[#1C2B4A]">{filteredBills.length}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
||||
<div className="text-xs text-gray-500 mb-1">Timeframe</div>
|
||||
<select
|
||||
value={timeframe}
|
||||
onChange={e => setTimeframe(e.target.value as any)}
|
||||
className="mt-2 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
<option value="ytd">YTD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VAT summary */}
|
||||
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[#1C2B4A]">Manage VAT rates</h2>
|
||||
<p className="text-xs text-gray-600">Live data from backend; edit on a separate page.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/admin/finance-management/vat-edit')}
|
||||
className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90"
|
||||
>
|
||||
Edit VAT
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
{vatLoading && 'Loading VAT rates...'}
|
||||
{vatError && <span className="text-red-600">{vatError}</span>}
|
||||
{!vatLoading && !vatError && (
|
||||
<>Active countries: {rates.length} • Examples: {rates.slice(0, 5).map(r => r.country_code).join(', ')}</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Bills list & filters */}
|
||||
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-[#1C2B4A]">Invoices</h2>
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<button onClick={() => exportBills('csv')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export CSV</button>
|
||||
<button onClick={() => exportBills('pdf')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export PDF</button>
|
||||
<button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
||||
<input
|
||||
placeholder="Search (invoice no., customer)"
|
||||
value={billFilter.query}
|
||||
onChange={e => setBillFilter(f => ({ ...f, query: e.target.value }))}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
/>
|
||||
<select
|
||||
value={billFilter.status}
|
||||
onChange={e => setBillFilter(f => ({ ...f, status: e.target.value }))}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Status: All</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="issued">Issued</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={billFilter.from}
|
||||
onChange={e => setBillFilter(f => ({ ...f, from: e.target.value }))}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={billFilter.to}
|
||||
onChange={e => setBillFilter(f => ({ ...f, to: e.target.value }))}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{invError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3">
|
||||
{invError}
|
||||
</div>
|
||||
)}
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-blue-50 text-left text-blue-900">
|
||||
<th className="px-3 py-2 font-semibold">Invoice</th>
|
||||
<th className="px-3 py-2 font-semibold">Customer</th>
|
||||
<th className="px-3 py-2 font-semibold">Issued</th>
|
||||
<th className="px-3 py-2 font-semibold">Amount</th>
|
||||
<th className="px-3 py-2 font-semibold">Status</th>
|
||||
<th className="px-3 py-2 font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{invLoading ? (
|
||||
<>
|
||||
<tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
|
||||
<tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
|
||||
</>
|
||||
) : filteredBills.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-4 text-center text-gray-500">
|
||||
Keine Rechnungen gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredBills.map(inv => (
|
||||
<tr key={inv.id} className="border-b last:border-0">
|
||||
<td className="px-3 py-2">{inv.invoice_number ?? inv.id}</td>
|
||||
<td className="px-3 py-2">{inv.buyer_name ?? '—'}</td>
|
||||
<td className="px-3 py-2">{inv.issued_at ? new Date(inv.issued_at).toLocaleDateString() : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
€{Number(inv.total_gross ?? 0).toFixed(2)}{' '}
|
||||
<span className="text-xs text-gray-500">{inv.currency ?? 'EUR'}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
inv.status === 'paid'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: inv.status === 'issued'
|
||||
? 'bg-indigo-100 text-indigo-700'
|
||||
: inv.status === 'draft'
|
||||
? 'bg-gray-100 text-gray-700'
|
||||
: inv.status === 'overdue'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}
|
||||
>
|
||||
{inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 space-x-2">
|
||||
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">View</button>
|
||||
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
import { VatRate } from '../../hooks/getTaxes'
|
||||
|
||||
const toCsvValue = (v: unknown) => {
|
||||
if (v === null || v === undefined) return '""'
|
||||
const s = String(v).replace(/"/g, '""')
|
||||
return `"${s}"`
|
||||
}
|
||||
|
||||
const fmt = (v?: number | null) =>
|
||||
v === null || v === undefined || Number.isNaN(Number(v)) ? 'NULL' : Number(v).toFixed(3)
|
||||
|
||||
// Header format: Country,"Super-Reduced Rate (%)","Reduced Rate (%)","Parking Rate (%)","Standard Rate (%)"
|
||||
export function exportVatCsv(rates: VatRate[]) {
|
||||
const headers = [
|
||||
'Country',
|
||||
'Super-Reduced Rate (%)',
|
||||
'Reduced Rate (%)',
|
||||
'Parking Rate (%)',
|
||||
'Standard Rate (%)',
|
||||
]
|
||||
const rows = rates.map(r => [
|
||||
r.country_name,
|
||||
r.super_reduced_rate ?? '',
|
||||
r.reduced_rate_1 ?? '',
|
||||
r.parking_rate ?? '',
|
||||
r.standard_rate ?? '',
|
||||
].map(toCsvValue).join(','))
|
||||
const csv = [headers.map(toCsvValue).join(','), ...rows].join('\r\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `vat-rates_${new Date().toISOString().slice(0,10)}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function exportVatPdf(rates: VatRate[]) {
|
||||
const lines = [
|
||||
'VAT Rates',
|
||||
`Generated: ${new Date().toLocaleString()}`,
|
||||
'',
|
||||
'Country | Super-Reduced | Reduced | Parking | Standard',
|
||||
'-----------------------------------------------------',
|
||||
...rates.map(r =>
|
||||
`${r.country_name} (${r.country_code}) SR:${fmt(r.super_reduced_rate)} R:${fmt(r.reduced_rate_1)} P:${fmt(r.parking_rate)} Std:${fmt(r.standard_rate)}`
|
||||
),
|
||||
]
|
||||
const textContent = lines.join('\n')
|
||||
|
||||
const pdfText = textContent.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
|
||||
const contentStream = `BT /F1 10 Tf 50 780 Td (${pdfText.replace(/\n/g, ') Tj\n0 -14 Td (')}) Tj ET`
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const streamBytes = encoder.encode(contentStream)
|
||||
const len = streamBytes.length
|
||||
|
||||
const header = [
|
||||
'%PDF-1.4',
|
||||
'1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj',
|
||||
'2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj',
|
||||
'3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj',
|
||||
`4 0 obj << /Length ${len} >> stream`,
|
||||
].join('\n')
|
||||
|
||||
const footer = [
|
||||
'endstream endobj',
|
||||
'5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj',
|
||||
'xref',
|
||||
'0 6',
|
||||
'0000000000 65535 f ',
|
||||
'0000000010 00000 n ',
|
||||
'0000000060 00000 n ',
|
||||
'0000000115 00000 n ',
|
||||
'0000000256 00000 n ',
|
||||
'0000000400 00000 n ',
|
||||
'trailer << /Size 6 /Root 1 0 R >>',
|
||||
'startxref',
|
||||
'480',
|
||||
'%%EOF',
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([header, '\n', streamBytes, '\n', footer], { type: 'application/pdf' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `vat-rates_${new Date().toISOString().slice(0,10)}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { authFetch } from '../../../../utils/authFetch'
|
||||
import useAuthStore from '../../../../store/authStore'
|
||||
|
||||
export type ImportSummary = {
|
||||
created?: number
|
||||
updated?: number
|
||||
skipped?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function importVatCsv(file: File): Promise<{ ok: boolean; summary?: ImportSummary; message?: string }> {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/tax/vat-rates/import`
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const user = useAuthStore.getState().user
|
||||
const userId =
|
||||
(user as any)?.id ??
|
||||
(user as any)?._id ??
|
||||
(user as any)?.userId ??
|
||||
(user as any)?.uid
|
||||
|
||||
if (userId != null) {
|
||||
form.append('userId', String(userId))
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'include',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
if (!res.ok || !ct.includes('application/json')) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`Import failed: ${res.status} ${txt.slice(0, 160)}`)
|
||||
}
|
||||
const json = await res.json()
|
||||
return { ok: true, summary: json?.data || json, message: json?.message }
|
||||
} catch (e: any) {
|
||||
return { ok: false, message: e?.message || 'Import failed' }
|
||||
}
|
||||
}
|
||||
158
src/app/admin/finance-management/vat-edit/page.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import PageLayout from '../../../components/PageLayout'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useVatRates } from '../hooks/getTaxes'
|
||||
import { importVatCsv } from './hooks/TaxImporter'
|
||||
import { exportVatCsv, exportVatPdf } from './hooks/TaxExporter'
|
||||
|
||||
export default function VatEditPage() {
|
||||
const router = useRouter()
|
||||
const { rates, loading, error, reload } = useVatRates()
|
||||
const [filter, setFilter] = useState('')
|
||||
const [importResult, setImportResult] = useState<string | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const onImport = async (file?: File | null) => {
|
||||
if (!file) return
|
||||
setImportResult(null)
|
||||
setImporting(true)
|
||||
const res = await importVatCsv(file)
|
||||
if (res.ok) {
|
||||
setImportResult(res.summary ? JSON.stringify(res.summary) : res.message || 'Import successful')
|
||||
await reload()
|
||||
} else {
|
||||
setImportResult(res.message || 'Import failed')
|
||||
}
|
||||
setImporting(false)
|
||||
}
|
||||
|
||||
const filtered = rates.filter(v =>
|
||||
v.country_name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
v.country_code.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||
const pageData = filtered.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
<div className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-blue-900">Edit VAT rates</h1>
|
||||
<p className="text-sm text-blue-700">Import, export, and review (dummy data).</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/admin/finance-management')}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-blue-900 hover:bg-gray-50"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 space-y-3">
|
||||
<div className="flex flex-wrap gap-2 text-sm items-center">
|
||||
<label className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={e => onImport(e.target.files?.[0] || null)}
|
||||
disabled={importing}
|
||||
/>
|
||||
{importing ? 'Importing...' : 'Import CSV'}
|
||||
</label>
|
||||
<button
|
||||
onClick={() => exportVatCsv(rates)}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportVatPdf(rates)}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
|
||||
>
|
||||
Export PDF
|
||||
</button>
|
||||
{importResult && <span className="text-xs text-blue-900 break-all">{importResult}</span>}
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
{error && <div className="mb-3 text-red-600">{error}</div>}
|
||||
<input
|
||||
value={filter}
|
||||
onChange={e => { setFilter(e.target.value); setPage(1); }}
|
||||
placeholder="Filter by country or code"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 mb-3 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-blue-50 text-left text-blue-900">
|
||||
<th className="px-3 py-2 font-semibold">Country</th>
|
||||
<th className="px-3 py-2 font-semibold">Code</th>
|
||||
<th className="px-3 py-2 font-semibold">Standard</th>
|
||||
<th className="px-3 py-2 font-semibold">Reduced</th>
|
||||
<th className="px-3 py-2 font-semibold">Super reduced</th>
|
||||
<th className="px-3 py-2 font-semibold">Parking</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading && (
|
||||
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">Loading VAT rates…</td></tr>
|
||||
)}
|
||||
{!loading && pageData.map(v => (
|
||||
<tr key={v.country_code} className="border-b last:border-0">
|
||||
<td className="px-3 py-2">{v.country_name}</td>
|
||||
<td className="px-3 py-2">{v.country_code}</td>
|
||||
<td className="px-3 py-2">{v.standard_rate ?? '—'}</td>
|
||||
<td className="px-3 py-2">{v.reduced_rate_1 ?? '—'}</td>
|
||||
<td className="px-3 py-2">{v.super_reduced_rate ?? '—'}</td>
|
||||
<td className="px-3 py-2">{v.parking_rate ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && !error && pageData.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">No entries found.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mt-4 text-sm text-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Rows per page:</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||
className="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
>
|
||||
{[10, 20, 50, 100].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 rounded border border-gray-300 bg-white disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>Page {page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 rounded border border-gray-300 bg-white disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
96
src/app/admin/layout.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useAuthStore from '../store/authStore'
|
||||
|
||||
function isUserAdmin(user: any): boolean {
|
||||
if (!user) return false
|
||||
if (user.user && typeof user.user === 'object') {
|
||||
return isUserAdmin(user.user)
|
||||
}
|
||||
const role = user.role ?? user.userType ?? user.user_type
|
||||
if (role === 'admin' || role === 'super_admin') return true
|
||||
if (user.isAdmin === true || user.isSuperAdmin === true) return true
|
||||
if (Array.isArray(user.roles) && (user.roles.includes('admin') || user.roles.includes('super_admin'))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const isAuthReady = useAuthStore(s => s.isAuthReady)
|
||||
const refreshAuthToken = useAuthStore(s => s.refreshAuthToken)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
const isAdmin = useMemo(() => isUserAdmin(user), [user])
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const guard = async () => {
|
||||
if (!mounted || !isAuthReady) return
|
||||
|
||||
console.log('🔐 AdminLayout guard:start', {
|
||||
mounted,
|
||||
isAuthReady,
|
||||
hasUser: !!user,
|
||||
userRole: (user && (user.role ?? user.userType ?? user.user_type)) || null
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
try {
|
||||
console.log('🔐 AdminLayout: no user, attempting refresh')
|
||||
await refreshAuthToken?.()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const currentUser = useAuthStore.getState().user
|
||||
const ok = isUserAdmin(currentUser)
|
||||
|
||||
console.log('🔐 AdminLayout guard:resolved', {
|
||||
hasUser: !!currentUser,
|
||||
userRole: currentUser && (currentUser.role ?? currentUser.userType ?? currentUser.user_type),
|
||||
isAdmin: ok
|
||||
})
|
||||
|
||||
if (!currentUser) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
router.replace('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
// allowed
|
||||
}
|
||||
}
|
||||
|
||||
guard()
|
||||
return () => { cancelled = true }
|
||||
}, [mounted, isAuthReady, user, refreshAuthToken, router])
|
||||
|
||||
if (!mounted || !isAuthReady) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||||
<p className="text-blue-900">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@ -0,0 +1,527 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MagnifyingGlassIcon, XMarkIcon, BuildingOffice2Icon, UserIcon } from '@heroicons/react/24/outline'
|
||||
import { getUserCandidates } from '../hooks/search-candidate'
|
||||
import { addUserToMatrix } from '../hooks/addUsertoMatrix'
|
||||
import type { MatrixUser, UserType } from '../hooks/getStats'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
matrixName: string
|
||||
rootUserId?: number
|
||||
matrixId?: string | number
|
||||
topNodeEmail?: string
|
||||
existingUsers: MatrixUser[]
|
||||
onAdd: (u: { id: number; name: string; email: string; type: UserType }) => void
|
||||
policyMaxDepth?: number | null // NEW
|
||||
}
|
||||
|
||||
export default function SearchModal({
|
||||
open,
|
||||
onClose,
|
||||
matrixName,
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail,
|
||||
existingUsers,
|
||||
onAdd,
|
||||
policyMaxDepth // NEW
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [items, setItems] = useState<Array<{ userId: number; name: string; email: string; userType: UserType }>>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [limit] = useState(20)
|
||||
|
||||
const [selected, setSelected] = useState<{ userId: number; name: string; email: string; userType: UserType } | null>(null) // NEW
|
||||
const [advanced, setAdvanced] = useState(false) // NEW
|
||||
const [parentId, setParentId] = useState<number | undefined>(undefined) // NEW
|
||||
const [forceFallback, setForceFallback] = useState<boolean>(true) // NEW
|
||||
const [adding, setAdding] = useState(false) // NEW
|
||||
const [addError, setAddError] = useState<string>('') // NEW
|
||||
const [addSuccess, setAddSuccess] = useState<string>('') // NEW
|
||||
const [hasSearched, setHasSearched] = useState(false) // NEW
|
||||
const [closing, setClosing] = useState(false) // NEW: animated closing state
|
||||
|
||||
const formRef = useRef<HTMLFormElement | null>(null)
|
||||
const reqIdRef = useRef(0) // request guard to avoid applying stale results
|
||||
|
||||
const doSearch = useCallback(async () => {
|
||||
setError('')
|
||||
// Preserve list during refresh to avoid jumpiness
|
||||
const shouldPreserve = hasSearched && items.length > 0
|
||||
if (!shouldPreserve) {
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
}
|
||||
const qTrim = query.trim()
|
||||
if (qTrim.length < 3) {
|
||||
console.warn('[SearchModal] Skip search: need >=3 chars')
|
||||
setHasSearched(false)
|
||||
return
|
||||
}
|
||||
setHasSearched(true)
|
||||
|
||||
const myReqId = ++reqIdRef.current
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await getUserCandidates({
|
||||
q: qTrim,
|
||||
type: typeFilter === 'all' ? undefined : typeFilter,
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail,
|
||||
limit,
|
||||
offset: 0
|
||||
})
|
||||
// Ignore stale responses
|
||||
if (myReqId !== reqIdRef.current) return
|
||||
|
||||
const existingIds = new Set(existingUsers.map(u => String(u.id)))
|
||||
const filtered = (data.items || []).filter(i => !existingIds.has(String(i.userId)))
|
||||
setItems(filtered)
|
||||
setTotal(data.total || 0)
|
||||
console.info('[SearchModal] Search success', {
|
||||
q: data.q,
|
||||
returned: filtered.length,
|
||||
original: data.items.length,
|
||||
total: data.total,
|
||||
combo: (data as any)?._debug?.combo
|
||||
})
|
||||
if (filtered.length === 0 && data.total > 0) {
|
||||
console.warn('[SearchModal] All backend results filtered out as duplicates')
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (myReqId !== reqIdRef.current) return
|
||||
console.error('[SearchModal] Search error', e)
|
||||
setError(e?.message || 'Search failed')
|
||||
} finally {
|
||||
if (myReqId === reqIdRef.current) setLoading(false)
|
||||
}
|
||||
}, [query, typeFilter, rootUserId, matrixId, topNodeEmail, limit, existingUsers, hasSearched, items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
setTypeFilter('all')
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
setHasSearched(false)
|
||||
reqIdRef.current = 0 // reset guard
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = prev }
|
||||
}, [open])
|
||||
|
||||
// Auto-prune current results when parent existingUsers changes (keeps modal open)
|
||||
useEffect(() => {
|
||||
if (!open || items.length === 0) return
|
||||
const existingIds = new Set(existingUsers.map(u => String(u.id)))
|
||||
const cleaned = items.filter(i => !existingIds.has(String(i.userId)))
|
||||
if (cleaned.length !== items.length) {
|
||||
console.info('[SearchModal] Pruned results after parent update', { before: items.length, after: cleaned.length })
|
||||
setItems(cleaned)
|
||||
}
|
||||
}, [existingUsers, items, open])
|
||||
|
||||
// Track a revision to force remount of parent dropdown when existingUsers changes
|
||||
const [parentsRevision, setParentsRevision] = useState(0) // NEW
|
||||
|
||||
// Compute children counts per parent (uses parentUserId on existingUsers)
|
||||
const parentUsage = useMemo(() => {
|
||||
const map = new Map<number, number>()
|
||||
existingUsers.forEach(u => {
|
||||
if (u.parentUserId != null) {
|
||||
map.set(u.parentUserId, (map.get(u.parentUserId) || 0) + 1)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [existingUsers])
|
||||
|
||||
const potentialParents = useMemo(() => {
|
||||
// All users up to depth 5 can be parents (capacity 5)
|
||||
return existingUsers
|
||||
.filter(u => u.level < 6)
|
||||
.sort((a, b) => a.level - b.level || a.id - b.id)
|
||||
}, [existingUsers])
|
||||
|
||||
const selectedParent = useMemo(() => {
|
||||
if (!parentId) return null
|
||||
return existingUsers.find(u => u.id === parentId) || null
|
||||
}, [parentId, existingUsers])
|
||||
|
||||
// NEW: when existingUsers changes, refresh dropdown and clear invalid/now-full parent selection
|
||||
useEffect(() => {
|
||||
setParentsRevision(r => r + 1)
|
||||
if (!selectedParent) return
|
||||
const used = parentUsage.get(selectedParent.id) || 0
|
||||
const isRoot = (selectedParent.level ?? 0) === 0
|
||||
const isFull = !isRoot && used >= 5
|
||||
const stillExists = !!existingUsers.find(u => u.id === selectedParent.id)
|
||||
if (!stillExists || isFull) {
|
||||
setParentId(undefined)
|
||||
}
|
||||
}, [existingUsers, parentUsage, selectedParent])
|
||||
|
||||
const remainingLevels = useMemo(() => {
|
||||
if (!selectedParent) return null
|
||||
if (!policyMaxDepth || policyMaxDepth <= 0) return Infinity
|
||||
return Math.max(0, policyMaxDepth - Number(selectedParent.level ?? 0))
|
||||
}, [selectedParent, policyMaxDepth])
|
||||
|
||||
const addDisabledReason = useMemo(() => {
|
||||
if (!selectedParent) return ''
|
||||
if (!policyMaxDepth || policyMaxDepth <= 0) return ''
|
||||
if (Number(selectedParent.level ?? 0) >= policyMaxDepth) {
|
||||
return `Parent at max depth (${policyMaxDepth}).`
|
||||
}
|
||||
return ''
|
||||
}, [selectedParent, policyMaxDepth])
|
||||
|
||||
// Helper: is root selected
|
||||
const isRootSelected = useMemo(() => {
|
||||
if (!selectedParent) return false
|
||||
return (selectedParent.level ?? 0) === 0
|
||||
}, [selectedParent])
|
||||
|
||||
const closeWithAnimation = useCallback(() => {
|
||||
// guard: if already closing, ignore
|
||||
if (closing) return
|
||||
setClosing(true)
|
||||
// allow CSS transitions to play
|
||||
setTimeout(() => {
|
||||
setClosing(false)
|
||||
onClose()
|
||||
}, 200) // keep brief for responsiveness
|
||||
}, [closing, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
// reset closing flag when reopened
|
||||
if (open) setClosing(false)
|
||||
}, [open])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selected) return
|
||||
setAddError('')
|
||||
setAddSuccess('')
|
||||
setAdding(true)
|
||||
try {
|
||||
const data = await addUserToMatrix({
|
||||
childUserId: selected.userId,
|
||||
parentUserId: advanced ? parentId : undefined,
|
||||
forceParentFallback: forceFallback,
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail
|
||||
})
|
||||
console.info('[SearchModal] addUserToMatrix success', data)
|
||||
setAddSuccess(`Added at position ${data.position} under parent ${data.parentUserId}`)
|
||||
onAdd({ id: selected.userId, name: selected.name, email: selected.email, type: selected.userType })
|
||||
// NEW: animated close instead of abrupt onClose
|
||||
closeWithAnimation()
|
||||
return
|
||||
// setSelected(null)
|
||||
// setParentId(undefined)
|
||||
// Soft refresh: keep list visible; doSearch won't clear items now
|
||||
// setTimeout(() => { void doSearch() }, 200)
|
||||
} catch (e: any) {
|
||||
console.error('[SearchModal] addUserToMatrix error', e)
|
||||
setAddError(e?.message || 'Add failed')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const modal = (
|
||||
<div className="fixed inset-0 z-[10000]">
|
||||
{/* Backdrop: animate opacity */}
|
||||
<div
|
||||
className={`absolute inset-0 backdrop-blur-sm transition-opacity duration-200 ${closing ? 'opacity-0' : 'opacity-100'} bg-black/60`}
|
||||
onClick={closeWithAnimation} // CHANGED: use animated close
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
|
||||
<div
|
||||
className={`w-full max-w-full sm:max-w-xl md:max-w-3xl lg:max-w-4xl rounded-2xl overflow-hidden bg-[#0F1F3A] shadow-2xl ring-1 ring-black/40 flex flex-col
|
||||
transition-all duration-200 ${closing ? 'opacity-0 scale-95 translate-y-1' : 'opacity-100 scale-100 translate-y-0'}`}
|
||||
style={{ maxHeight: '90vh' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="relative px-6 py-5 border-b border-blue-900/40 bg-gradient-to-r from-[#142b52] via-[#13365f] to-[#154270]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Add users to “{matrixName}”
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeWithAnimation} // CHANGED: animated close
|
||||
className="p-1.5 rounded-md text-blue-200 transition
|
||||
hover:bg-white/15 hover:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-white/60 focus:ring-offset-2 focus:ring-offset-[#13365f]
|
||||
active:scale-95"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-blue-200">
|
||||
Search by name or email. Minimum 3 characters. Existing matrix members are hidden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
void doSearch()
|
||||
}}
|
||||
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-blue-900/40 bg-[#112645]"
|
||||
>
|
||||
{/* Query */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search name or email…"
|
||||
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 placeholder-blue-300 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Type */}
|
||||
<div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value as any)}
|
||||
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || query.trim().length < 3}
|
||||
className="flex-1 rounded-md bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
||||
>
|
||||
{loading ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setQuery(''); setItems([]); setTotal(0); setError(''); setHasSearched(false); }}
|
||||
className="rounded-md border border-blue-800 bg-[#173456] px-3 py-2 text-sm text-blue-100 hover:bg-blue-800/40 transition"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{/* Total */}
|
||||
<div className="text-sm text-blue-200 self-center">
|
||||
Total: <span className="font-semibold text-white">{total}</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Results + selection area (scrollable) */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* Results section */}
|
||||
<div className="relative">
|
||||
{error && (
|
||||
<div className="text-sm text-red-400 mb-4">{error}</div>
|
||||
)}
|
||||
{!error && query.trim().length < 3 && (
|
||||
<div className="py-12 text-sm text-blue-300 text-center">
|
||||
Enter at least 3 characters and click Search.
|
||||
</div>
|
||||
)}
|
||||
{!error && query.trim().length >= 3 && !hasSearched && !loading && (
|
||||
<div className="py-12 text-sm text-blue-300 text-center">
|
||||
Ready to search. Click the Search button to fetch candidates.
|
||||
</div>
|
||||
)}
|
||||
{/* Skeleton only for first-time load (when no items yet) */}
|
||||
{!error && query.trim().length >= 3 && loading && items.length === 0 && (
|
||||
<ul className="space-y-0 divide-y divide-blue-900/40 border border-blue-900/40 rounded-md bg-[#132c4e]">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<li key={i} className="animate-pulse px-4 py-3">
|
||||
<div className="h-3.5 w-36 bg-blue-800/40 rounded" />
|
||||
<div className="mt-2 h-3 w-56 bg-blue-800/30 rounded" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{!error && hasSearched && !loading && query.trim().length >= 3 && items.length === 0 && (
|
||||
<div className="py-12 text-sm text-blue-300 text-center">
|
||||
No users match your filters.
|
||||
</div>
|
||||
)}
|
||||
{!error && hasSearched && items.length > 0 && (
|
||||
<ul className="divide-y divide-blue-900/40 border border-blue-900/40 rounded-lg bg-[#132c4e]">
|
||||
{items.map(u => (
|
||||
<li
|
||||
key={u.userId}
|
||||
className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-blue-800/40 transition"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{u.userType === 'company'
|
||||
? <BuildingOffice2Icon className="h-4 w-4 text-indigo-400" />
|
||||
: <UserIcon className="h-4 w-4 text-blue-300" />}
|
||||
<span className="text-sm font-medium text-blue-100 truncate max-w-[160px]">{u.name}</span>
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
|
||||
u.userType === 'company'
|
||||
? 'bg-indigo-700/40 text-indigo-200'
|
||||
: 'bg-blue-700/40 text-blue-200'
|
||||
}`}>
|
||||
{u.userType === 'company' ? 'Company' : 'Personal'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-blue-300 break-all">{u.email}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[SearchModal] Select candidate', { id: u.userId })
|
||||
setSelected(u)
|
||||
setAddError('')
|
||||
setAddSuccess('')
|
||||
}}
|
||||
className="shrink-0 inline-flex items-center rounded-md bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
||||
>
|
||||
{selected?.userId === u.userId ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Soft-loading overlay over existing list to avoid jumpiness */}
|
||||
{loading && items.length > 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[#0F1F3A]/30">
|
||||
<span className="h-5 w-5 rounded-full border-2 border-blue-400 border-b-transparent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected candidate details (conditional) */}
|
||||
{selected && (
|
||||
<div className="border border-blue-900/40 rounded-lg p-4 bg-[#132c4e] space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-blue-100 font-medium">
|
||||
Candidate: {selected.name} <span className="text-blue-300">({selected.email})</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSelected(null); setParentId(undefined); }}
|
||||
className="text-xs text-blue-300 hover:text-white transition"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-blue-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={advanced}
|
||||
onChange={e => setAdvanced(e.target.checked)}
|
||||
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
|
||||
/>
|
||||
Advanced: choose parent manually
|
||||
</label>
|
||||
|
||||
{advanced && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
key={parentsRevision}
|
||||
value={parentId ?? ''}
|
||||
onChange={e => setParentId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full rounded-md bg-[#173456] border border-blue-800 text-xs text-blue-100 px-2 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
title={addDisabledReason || undefined}
|
||||
>
|
||||
<option value="">(Auto referral / root)</option>
|
||||
{potentialParents.map(p => {
|
||||
const used = parentUsage.get(p.id) || 0
|
||||
const isRoot = (p.level ?? 0) === 0
|
||||
const full = (!isRoot && used >= 5) || (!!policyMaxDepth && policyMaxDepth > 0 && p.level >= policyMaxDepth) // CHANGED
|
||||
const rem = !policyMaxDepth || policyMaxDepth <= 0 ? '∞' : Math.max(0, policyMaxDepth - p.level)
|
||||
return (
|
||||
<option key={p.id} value={p.id} disabled={full} title={full ? 'Parent full or at policy depth' : undefined}>
|
||||
{p.name} • L{p.level} • Slots {isRoot ? `${used} (root ∞)` : `${used}/5`} • Rem levels: {rem}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{/* CHANGED: clarify root unlimited and rogue behavior */}
|
||||
<p className="text-[11px] text-blue-300">
|
||||
{isRootSelected
|
||||
? 'Root has unlimited capacity; placing under root does not mark the user as rogue.'
|
||||
: (!policyMaxDepth || policyMaxDepth <= 0)
|
||||
? 'Unlimited policy: no remaining-level cap for subtree.'
|
||||
: `Remaining levels under chosen parent = Max(${policyMaxDepth}) - parent level.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-blue-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceFallback}
|
||||
onChange={e => setForceFallback(e.target.checked)}
|
||||
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
|
||||
/>
|
||||
Fallback to root if referral parent not in matrix
|
||||
</label>
|
||||
<p className="text-[11px] text-blue-300">
|
||||
If the referrer is outside the matrix, the user is placed under root; enabling fallback may mark the user as rogue.
|
||||
</p>
|
||||
|
||||
{addError && <div className="text-xs text-red-400">{addError}</div>}
|
||||
{addSuccess && <div className="text-xs text-green-400">{addSuccess}</div>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={adding || (!!addDisabledReason && !!advanced)} // NEW
|
||||
title={addDisabledReason || undefined} // NEW
|
||||
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 text-xs font-medium shadow-sm transition"
|
||||
>
|
||||
{adding ? 'Adding…' : 'Add to Matrix'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer (hidden when a candidate is selected) */}
|
||||
{!selected && (
|
||||
<div className="px-6 py-3 border-t border-blue-900/40 flex items-center justify-end bg-[#112645]">
|
||||
<button
|
||||
onClick={closeWithAnimation} // CHANGED: animated close
|
||||
className="text-sm rounded-md px-4 py-2 font-medium
|
||||
bg-white/10 text-blue-200 backdrop-blur
|
||||
hover:bg-indigo-500/20 hover:text-white hover:shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-300 focus:ring-offset-2 focus:ring-offset-[#112645]
|
||||
active:scale-95 transition"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(modal, document.body)
|
||||
}
|
||||
101
src/app/admin/matrix-management/detail/hooks/addUsertoMatrix.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { authFetch } from '../../../../utils/authFetch'
|
||||
|
||||
export type AddUserToMatrixParams = {
|
||||
childUserId: number
|
||||
parentUserId?: number
|
||||
forceParentFallback?: boolean
|
||||
rootUserId?: number
|
||||
matrixId?: string | number
|
||||
topNodeEmail?: string
|
||||
}
|
||||
|
||||
export type AddUserToMatrixResponse = {
|
||||
success: boolean
|
||||
data?: {
|
||||
rootUserId: number
|
||||
parentUserId: number
|
||||
childUserId: number
|
||||
position: number
|
||||
remainingFreeSlots: number
|
||||
usersPreview?: Array<{
|
||||
userId: number
|
||||
level?: number
|
||||
name?: string
|
||||
email?: string
|
||||
userType?: string
|
||||
parentUserId?: number | null
|
||||
}>
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function addUserToMatrix(params: AddUserToMatrixParams) {
|
||||
const {
|
||||
childUserId,
|
||||
parentUserId,
|
||||
forceParentFallback = false,
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail
|
||||
} = params
|
||||
|
||||
if (!childUserId) throw new Error('childUserId required')
|
||||
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
if (!base) console.warn('[addUserToMatrix] NEXT_PUBLIC_API_BASE_URL missing')
|
||||
|
||||
// Choose exactly one identifier
|
||||
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0
|
||||
const hasMatrix = !!matrixId
|
||||
const hasEmail = !!topNodeEmail
|
||||
if (!hasRoot && !hasMatrix && !hasEmail) {
|
||||
throw new Error('One of rootUserId, matrixId or topNodeEmail is required')
|
||||
}
|
||||
|
||||
const body: any = {
|
||||
childUserId,
|
||||
forceParentFallback: !!forceParentFallback
|
||||
}
|
||||
if (parentUserId) body.parentUserId = parentUserId
|
||||
if (hasRoot) body.rootUserId = rootUserId
|
||||
else if (hasMatrix) body.matrixId = matrixId
|
||||
else body.topNodeEmail = topNodeEmail
|
||||
|
||||
const url = `${base}/api/admin/matrix/add-user`
|
||||
console.info('[addUserToMatrix] POST', { url, body })
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
const raw = await res.text()
|
||||
let json: AddUserToMatrixResponse | null = null
|
||||
try {
|
||||
json = ct.includes('application/json') ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
json = null
|
||||
}
|
||||
|
||||
console.debug('[addUserToMatrix] Response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
hasJson: !!json,
|
||||
bodyPreview: raw.slice(0, 300)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = json?.message || `Request failed: ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (!json?.success) {
|
||||
throw new Error(json?.message || 'Backend returned non-success')
|
||||
}
|
||||
return json.data!
|
||||
}
|
||||
211
src/app/admin/matrix-management/detail/hooks/getStats.ts
Normal file
@ -0,0 +1,211 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { authFetch } from '../../../../utils/authFetch'
|
||||
|
||||
export type UserType = 'personal' | 'company'
|
||||
|
||||
export type MatrixUser = {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
type: UserType
|
||||
level: number
|
||||
parentUserId?: number | null // NEW
|
||||
position?: number | null // NEW
|
||||
}
|
||||
|
||||
type ApiUser = {
|
||||
userId: number
|
||||
level?: number | null
|
||||
depth?: number | null
|
||||
name?: string | null
|
||||
displayName?: string | null
|
||||
email: string
|
||||
userType: string
|
||||
role: string
|
||||
createdAt: string
|
||||
parentUserId: number | null
|
||||
position: number | null
|
||||
}
|
||||
|
||||
type ApiResponse = {
|
||||
success: boolean
|
||||
data?: {
|
||||
rootUserId: number
|
||||
maxDepth: number
|
||||
limit: number
|
||||
offset: number
|
||||
includeRoot: boolean
|
||||
users: ApiUser[]
|
||||
}
|
||||
error?: any
|
||||
}
|
||||
|
||||
export type UseMatrixUsersParams = {
|
||||
depth?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
includeRoot?: boolean
|
||||
matrixId?: string | number
|
||||
topNodeEmail?: string
|
||||
}
|
||||
|
||||
export function useMatrixUsers(
|
||||
rootUserId: number | undefined,
|
||||
params: UseMatrixUsersParams = {}
|
||||
) {
|
||||
const { depth = 5, limit = 100, offset = 0, includeRoot = true, matrixId, topNodeEmail } = params
|
||||
const [users, setUsers] = useState<MatrixUser[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const [serverMaxDepth, setServerMaxDepth] = useState<number | null>(null) // NEW
|
||||
|
||||
// Include new identifiers in diagnostics
|
||||
const builtParams = useMemo(() => {
|
||||
const p = { rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot }
|
||||
console.debug('[useMatrixUsers] Params built', p)
|
||||
return p
|
||||
}, [rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot])
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
console.info('[useMatrixUsers] refetch() called')
|
||||
setTick(t => t + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
console.info('[useMatrixUsers] Hook mounted')
|
||||
return () => {
|
||||
console.info('[useMatrixUsers] Hook unmounted, aborting any inflight request')
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Require at least one acceptable identifier
|
||||
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0
|
||||
const hasMatrix = !!matrixId
|
||||
const hasEmail = !!topNodeEmail
|
||||
if (!hasRoot && !hasMatrix && !hasEmail) {
|
||||
console.error('[useMatrixUsers] Missing identifier. Provide one of: rootUserId, matrixId, topNodeEmail.', { rootUserId, matrixId, topNodeEmail })
|
||||
setUsers([])
|
||||
setError(new Error('One of rootUserId, matrixId or topNodeEmail is required'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
if (!base) {
|
||||
console.warn('[useMatrixUsers] NEXT_PUBLIC_API_BASE_URL is not set. Falling back to same-origin (may fail in dev).')
|
||||
}
|
||||
|
||||
// Choose exactly ONE identifier to avoid backend confusion
|
||||
const qs = new URLSearchParams()
|
||||
let chosenKey: 'rootUserId' | 'matrixId' | 'topNodeEmail'
|
||||
let chosenValue: string | number
|
||||
|
||||
if (hasRoot) {
|
||||
qs.set('rootUserId', String(rootUserId))
|
||||
chosenKey = 'rootUserId'
|
||||
chosenValue = rootUserId!
|
||||
} else if (hasMatrix) {
|
||||
qs.set('matrixId', String(matrixId))
|
||||
chosenKey = 'matrixId'
|
||||
chosenValue = matrixId as any
|
||||
} else {
|
||||
qs.set('topNodeEmail', String(topNodeEmail))
|
||||
chosenKey = 'topNodeEmail'
|
||||
chosenValue = topNodeEmail as any
|
||||
}
|
||||
|
||||
qs.set('depth', String(depth))
|
||||
qs.set('limit', String(limit))
|
||||
qs.set('offset', String(offset))
|
||||
qs.set('includeRoot', String(includeRoot))
|
||||
|
||||
const url = `${base}/api/admin/matrix/users?${qs.toString()}`
|
||||
console.info('[useMatrixUsers] Fetch start (via authFetch)', {
|
||||
url,
|
||||
method: 'GET',
|
||||
identifiers: { rootUserId, matrixId, topNodeEmail, chosen: { key: chosenKey, value: chosenValue } },
|
||||
params: { depth, limit, offset, includeRoot }
|
||||
})
|
||||
console.log('[useMatrixUsers] REQUEST GET', url)
|
||||
|
||||
const t0 = performance.now()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
authFetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal as any
|
||||
})
|
||||
.then(async r => {
|
||||
const t1 = performance.now()
|
||||
const ct = r.headers.get('content-type') || ''
|
||||
console.debug('[useMatrixUsers] Response received', { status: r.status, durationMs: Math.round(t1 - t0), contentType: ct })
|
||||
|
||||
if (!r.ok || !ct.includes('application/json')) {
|
||||
const text = await r.text().catch(() => '')
|
||||
console.error('[useMatrixUsers] Non-OK or non-JSON response', { status: r.status, bodyPreview: text.slice(0, 500) })
|
||||
throw new Error(`Request failed: ${r.status}`)
|
||||
}
|
||||
|
||||
const json: ApiResponse = await r.json()
|
||||
if (!json?.success || !json?.data) {
|
||||
console.warn('[useMatrixUsers] Non-success response', json)
|
||||
throw new Error('Backend returned non-success for /admin/matrix/users')
|
||||
}
|
||||
|
||||
const { data } = json
|
||||
console.debug('[useMatrixUsers] Meta', {
|
||||
rootUserId: data.rootUserId,
|
||||
maxDepth: data.maxDepth,
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
includeRoot: data.includeRoot,
|
||||
usersCount: data.users?.length ?? 0
|
||||
})
|
||||
setServerMaxDepth(typeof data.maxDepth === 'number' ? data.maxDepth : null) // NEW
|
||||
const mapped: MatrixUser[] = (data.users || []).map(u => {
|
||||
const rawLevel = (typeof u.level === 'number' ? u.level : (typeof u.depth === 'number' ? u.depth : undefined))
|
||||
const level = (typeof rawLevel === 'number' && rawLevel >= 0) ? rawLevel : 0
|
||||
if (rawLevel === undefined || rawLevel === null) {
|
||||
console.warn('[useMatrixUsers] Coerced missing level/depth to 0', { userId: u.userId })
|
||||
}
|
||||
const name = (u.name?.trim()) || (u.displayName?.trim()) || u.email
|
||||
return {
|
||||
id: u.userId,
|
||||
name,
|
||||
email: u.email,
|
||||
type: u.userType === 'company' ? 'company' : 'personal',
|
||||
level,
|
||||
parentUserId: u.parentUserId ?? null,
|
||||
position: u.position ?? null // NEW
|
||||
}
|
||||
})
|
||||
mapped.sort((a, b) => a.level - b.level || a.id - b.id)
|
||||
setUsers(mapped)
|
||||
console.info('[useMatrixUsers] Users mapped', { count: mapped.length, sample: mapped.slice(0, 3) })
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[useMatrixUsers] Fetch error', err)
|
||||
setError(err)
|
||||
setUsers([])
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot, tick])
|
||||
|
||||
const meta = { rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot, serverMaxDepth } // NEW
|
||||
return { users, loading, error, meta, refetch, serverMaxDepth } // NEW
|
||||
}
|
||||
224
src/app/admin/matrix-management/detail/hooks/search-candidate.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { authFetch } from '../../../../utils/authFetch';
|
||||
|
||||
export type CandidateItem = {
|
||||
userId: number;
|
||||
email: string;
|
||||
userType: 'personal' | 'company';
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UserCandidatesResponse = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
q: string | null;
|
||||
type: 'all' | 'personal' | 'company';
|
||||
rootUserId: number | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
items: CandidateItem[];
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type UserCandidatesData = {
|
||||
q: string | null;
|
||||
type: 'all' | 'personal' | 'company';
|
||||
rootUserId: number | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
items: CandidateItem[];
|
||||
_debug?: {
|
||||
endpoint: string;
|
||||
query: Record<string, string>;
|
||||
combo: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetUserCandidatesParams = {
|
||||
q: string;
|
||||
type?: 'all' | 'personal' | 'company';
|
||||
rootUserId?: number;
|
||||
matrixId?: string | number;
|
||||
topNodeEmail?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function getUserCandidates(params: GetUserCandidatesParams): Promise<UserCandidatesData> {
|
||||
const {
|
||||
q,
|
||||
type = 'all',
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail,
|
||||
limit = 20,
|
||||
offset = 0
|
||||
} = params;
|
||||
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
|
||||
if (!base) {
|
||||
console.warn('[getUserCandidates] NEXT_PUBLIC_API_BASE_URL not set. Falling back to same-origin.');
|
||||
}
|
||||
|
||||
const qTrimmed = q.trim();
|
||||
console.info('[getUserCandidates] Building candidate request', {
|
||||
base,
|
||||
q: qTrimmed,
|
||||
typeSent: type !== 'all' ? type : undefined,
|
||||
identifiers: { rootUserId, matrixId, topNodeEmail },
|
||||
pagination: { limit, offset }
|
||||
});
|
||||
|
||||
// Build identifier combinations: all -> root-only -> matrix-only -> email-only
|
||||
const combos: Array<{ label: string; apply: (qs: URLSearchParams) => void }> = [];
|
||||
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0;
|
||||
const hasMatrix = !!matrixId;
|
||||
const hasEmail = !!topNodeEmail;
|
||||
|
||||
if (hasRoot || hasMatrix || hasEmail) {
|
||||
combos.push({
|
||||
label: 'all-identifiers',
|
||||
apply: (qs) => {
|
||||
if (hasRoot) qs.set('rootUserId', String(rootUserId));
|
||||
if (hasMatrix) qs.set('matrixId', String(matrixId));
|
||||
if (hasEmail) qs.set('topNodeEmail', String(topNodeEmail));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (hasRoot) combos.push({ label: 'root-only', apply: (qs) => { qs.set('rootUserId', String(rootUserId)); } });
|
||||
if (hasMatrix) combos.push({ label: 'matrix-only', apply: (qs) => { qs.set('matrixId', String(matrixId)); } });
|
||||
if (hasEmail) combos.push({ label: 'email-only', apply: (qs) => { qs.set('topNodeEmail', String(topNodeEmail)); } });
|
||||
if (combos.length === 0) combos.push({ label: 'no-identifiers', apply: () => {} });
|
||||
|
||||
const endpointVariants = [
|
||||
(qs: string) => `${base}/api/admin/matrix/users/candidates?${qs}`,
|
||||
(qs: string) => `${base}/api/admin/matrix/user-candidates?${qs}`
|
||||
];
|
||||
console.debug('[getUserCandidates] Endpoint variants', endpointVariants.map(f => f('...')));
|
||||
|
||||
let lastError: any = null;
|
||||
let lastZeroData: UserCandidatesData | null = null;
|
||||
|
||||
// Try each identifier combo against both endpoint variants
|
||||
for (const combo of combos) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('q', qTrimmed);
|
||||
qs.set('limit', String(limit));
|
||||
qs.set('offset', String(offset));
|
||||
if (type !== 'all') qs.set('type', type);
|
||||
combo.apply(qs);
|
||||
|
||||
const qsObj = Object.fromEntries(qs.entries());
|
||||
console.debug('[getUserCandidates] Final query params', { combo: combo.label, qs: qsObj });
|
||||
|
||||
for (let i = 0; i < endpointVariants.length; i++) {
|
||||
const url = endpointVariants[i](qs.toString());
|
||||
const fetchOpts = { method: 'GET', headers: { Accept: 'application/json' } as const };
|
||||
console.info('[getUserCandidates] REQUEST GET', {
|
||||
url,
|
||||
attempt: i + 1,
|
||||
combo: combo.label,
|
||||
identifiers: { rootUserId, matrixId, topNodeEmail },
|
||||
params: { q: qTrimmed, type: type !== 'all' ? type : undefined, limit, offset },
|
||||
fetchOpts
|
||||
});
|
||||
|
||||
const t0 = performance.now();
|
||||
const res = await authFetch(url, fetchOpts);
|
||||
const t1 = performance.now();
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
console.debug('[getUserCandidates] Response meta', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
durationMs: Math.round(t1 - t0),
|
||||
contentType: ct
|
||||
});
|
||||
|
||||
// Preview raw body (first 300 chars)
|
||||
let rawPreview = '';
|
||||
try {
|
||||
rawPreview = await res.clone().text();
|
||||
} catch {}
|
||||
if (rawPreview) {
|
||||
console.trace('[getUserCandidates] Raw body preview (trimmed)', rawPreview.slice(0, 300));
|
||||
}
|
||||
|
||||
if (res.status === 404 && i < endpointVariants.length - 1) {
|
||||
try {
|
||||
const preview = ct.includes('application/json') ? await res.json() : await res.text();
|
||||
console.warn('[getUserCandidates] 404 on endpoint variant, trying fallback', {
|
||||
tried: url,
|
||||
combo: combo.label,
|
||||
preview: typeof preview === 'string' ? preview.slice(0, 200) : preview
|
||||
});
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ct.includes('application/json')) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error('[getUserCandidates] Non-JSON response', { status: res.status, bodyPreview: text.slice(0, 500) });
|
||||
lastError = new Error(`Request failed: ${res.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const json: UserCandidatesResponse = await res.json().catch(() => ({ success: false, message: 'Invalid JSON' } as any));
|
||||
console.debug('[getUserCandidates] Parsed JSON', {
|
||||
success: json?.success,
|
||||
message: json?.message,
|
||||
dataMeta: json?.data && {
|
||||
q: json.data.q,
|
||||
type: json.data.type,
|
||||
total: json.data.total,
|
||||
itemsCount: json.data.items?.length
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok || !json?.success) {
|
||||
console.error('[getUserCandidates] Backend reported failure', {
|
||||
status: res.status,
|
||||
successFlag: json?.success,
|
||||
message: json?.message
|
||||
});
|
||||
lastError = new Error(json?.message || `Request failed: ${res.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const dataWithDebug: UserCandidatesData = {
|
||||
...json.data!,
|
||||
_debug: { endpoint: url, query: qsObj, combo: combo.label }
|
||||
};
|
||||
|
||||
if ((dataWithDebug.total || 0) > 0) {
|
||||
console.info('[getUserCandidates] Success (non-empty)', {
|
||||
total: dataWithDebug.total,
|
||||
itemsCount: dataWithDebug.items.length,
|
||||
combo: combo.label,
|
||||
endpoint: url
|
||||
});
|
||||
return dataWithDebug;
|
||||
}
|
||||
|
||||
// Keep last zero result but continue trying other combos/endpoints
|
||||
lastZeroData = dataWithDebug;
|
||||
console.info('[getUserCandidates] Success (empty)', { combo: combo.label, endpoint: url });
|
||||
}
|
||||
|
||||
if (lastError) break; // stop on hard error
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
console.error('[getUserCandidates] Exhausted endpoint variants with error', { lastError: lastError?.message });
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Return the last empty response (with _debug info) if everything was empty
|
||||
if (lastZeroData) {
|
||||
console.warn('[getUserCandidates] All combos returned empty results', { lastCombo: lastZeroData._debug });
|
||||
return lastZeroData;
|
||||
}
|
||||
|
||||
throw new Error('Request failed');
|
||||
}
|
||||
560
src/app/admin/matrix-management/detail/page.tsx
Normal file
@ -0,0 +1,560 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useState, Suspense } from 'react' // CHANGED: add Suspense
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import PageLayout from '../../../components/PageLayout'
|
||||
import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline'
|
||||
import { useMatrixUsers, MatrixUser } from './hooks/getStats'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
import { getMatrixStats } from '../hooks/getMatrixStats'
|
||||
import SearchModal from './components/searchModal'
|
||||
|
||||
const DEFAULT_FETCH_DEPTH = 50 // provisional large depth to approximate unlimited
|
||||
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
||||
|
||||
function MatrixDetailPageInner() {
|
||||
const sp = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const matrixId = sp.get('id') || 'm-1'
|
||||
const matrixName = sp.get('name') || 'Unnamed Matrix'
|
||||
const topNodeEmail = sp.get('top') || 'top@example.com'
|
||||
const rootUserIdParam = sp.get('rootUserId')
|
||||
const rootUserId = rootUserIdParam ? Number(rootUserIdParam) : undefined
|
||||
console.info('[MatrixDetailPage] Params', { matrixId, matrixName, topNodeEmail, rootUserId })
|
||||
|
||||
// Resolve rootUserId when missing by looking it up via stats
|
||||
const accessToken = useAuthStore(s => s.accessToken)
|
||||
const [resolvedRootUserId, setResolvedRootUserId] = useState<number | undefined>(rootUserId)
|
||||
// NEW: track policy (DB) max depth (null => unlimited)
|
||||
const [policyMaxDepth, setPolicyMaxDepth] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function resolveRoot() {
|
||||
if (rootUserId && rootUserId > 0) {
|
||||
console.info('[MatrixDetailPage] Using rootUserId from URL', { rootUserId })
|
||||
setResolvedRootUserId(rootUserId)
|
||||
return
|
||||
}
|
||||
if (!accessToken) {
|
||||
console.warn('[MatrixDetailPage] No accessToken; cannot resolve rootUserId from stats')
|
||||
setResolvedRootUserId(undefined)
|
||||
return
|
||||
}
|
||||
if (!matrixId) {
|
||||
console.warn('[MatrixDetailPage] No matrixId; cannot resolve rootUserId from stats')
|
||||
setResolvedRootUserId(undefined)
|
||||
return
|
||||
}
|
||||
console.info('[MatrixDetailPage] Resolving rootUserId via stats for matrixId', { matrixId })
|
||||
const res = await getMatrixStats({ token: accessToken, baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL })
|
||||
console.debug('[MatrixDetailPage] getMatrixStats result', res)
|
||||
if (!res.ok) {
|
||||
console.error('[MatrixDetailPage] getMatrixStats failed', { status: res.status, message: res.message })
|
||||
setResolvedRootUserId(undefined)
|
||||
return
|
||||
}
|
||||
const body = res.body || {}
|
||||
const matrices = (body?.data?.matrices ?? body?.matrices ?? []) as any[]
|
||||
console.debug('[MatrixDetailPage] Stats matrices overview', {
|
||||
count: matrices.length,
|
||||
ids: matrices.map((m: any) => m?.id ?? m?.matrixId),
|
||||
matrixIds: matrices.map((m: any) => m?.matrixId ?? m?.id),
|
||||
rootUserIds: matrices.map((m: any) => m?.rootUserId ?? m?.root_user_id),
|
||||
emails: matrices.map((m: any) => m?.topNodeEmail ?? m?.email)
|
||||
})
|
||||
const found = matrices.find((m: any) =>
|
||||
String(m?.id) === String(matrixId) || String(m?.matrixId) === String(matrixId)
|
||||
)
|
||||
// NEW: extract policy maxDepth (may be null)
|
||||
const pmRaw = found?.maxDepth ?? found?.max_depth ?? null
|
||||
if (!cancelled) {
|
||||
setPolicyMaxDepth(pmRaw == null ? null : Number(pmRaw))
|
||||
}
|
||||
const ru = Number(found?.rootUserId ?? found?.root_user_id)
|
||||
if (ru > 0 && !cancelled) {
|
||||
console.info('[MatrixDetailPage] Resolved rootUserId from stats', { matrixId, rootUserId: ru })
|
||||
setResolvedRootUserId(ru)
|
||||
} else {
|
||||
console.warn('[MatrixDetailPage] Could not resolve rootUserId from stats', { matrixId, found })
|
||||
setResolvedRootUserId(undefined)
|
||||
}
|
||||
}
|
||||
resolveRoot()
|
||||
return () => { cancelled = true }
|
||||
}, [matrixId, rootUserId, accessToken])
|
||||
|
||||
// Backend users (changed depth from 5 to DEFAULT_FETCH_DEPTH)
|
||||
const { users: fetchedUsers, loading: usersLoading, error: usersError, meta, refetch, serverMaxDepth } = useMatrixUsers(resolvedRootUserId, {
|
||||
depth: DEFAULT_FETCH_DEPTH,
|
||||
includeRoot: true,
|
||||
limit: 2000,
|
||||
offset: 0,
|
||||
matrixId,
|
||||
topNodeEmail
|
||||
})
|
||||
|
||||
// Prepare for backend fetches
|
||||
const [users, setUsers] = useState<MatrixUser[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
console.info('[MatrixDetailPage] useMatrixUsers state', {
|
||||
loading: usersLoading,
|
||||
error: !!usersError,
|
||||
fetchedCount: fetchedUsers.length,
|
||||
meta
|
||||
})
|
||||
setUsers(fetchedUsers)
|
||||
}, [fetchedUsers, usersLoading, usersError, meta])
|
||||
|
||||
// Modal state
|
||||
const [open, setOpen] = useState(false)
|
||||
// ADD: global search state (was removed)
|
||||
const [globalSearch, setGlobalSearch] = useState('')
|
||||
|
||||
// Refresh overlay state
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// Collapsed state for each level
|
||||
const [collapsedLevels, setCollapsedLevels] = useState<{ [level: number]: boolean }>({
|
||||
0: true, 1: true, 2: true, 3: true, 4: true, 5: true
|
||||
})
|
||||
|
||||
// Per-level search
|
||||
const [levelSearch, setLevelSearch] = useState<{ [level: number]: string }>({
|
||||
0: '', 1: '', 2: '', 3: '', 4: '', 5: ''
|
||||
})
|
||||
|
||||
// Counts per level and next available level logic
|
||||
const byLevel = useMemo(() => {
|
||||
const map = new Map<number, MatrixUser[]>()
|
||||
users.forEach(u => {
|
||||
if (!u.name) {
|
||||
console.warn('[MatrixDetailPage] User missing name, fallback email used', { id: u.id, email: u.email })
|
||||
}
|
||||
const lvl = (typeof u.level === 'number' && u.level >= 0) ? u.level : 0
|
||||
const arr = map.get(lvl) || []
|
||||
arr.push({ ...u, level: lvl })
|
||||
map.set(lvl, arr)
|
||||
})
|
||||
console.debug('[MatrixDetailPage] byLevel computed', { levels: Array.from(map.keys()), total: users.length })
|
||||
return map
|
||||
}, [users])
|
||||
|
||||
const nextAvailableLevel = () => {
|
||||
let lvl = 1
|
||||
while (true) {
|
||||
const current = byLevel.get(lvl)?.length || 0
|
||||
if (current < LEVEL_CAP(lvl)) return lvl
|
||||
lvl += 1
|
||||
if (lvl > 8) return lvl // safety ceiling in demo
|
||||
}
|
||||
}
|
||||
|
||||
const addToMatrix = (u: Omit<MatrixUser, 'level'>) => {
|
||||
const level = nextAvailableLevel()
|
||||
console.info('[MatrixDetailPage] addToMatrix', { userId: u.id, nextLevel: level })
|
||||
setUsers(prev => [...prev, { ...u, level }])
|
||||
}
|
||||
|
||||
// Simple chip for user
|
||||
const UserChip = ({ u }: { u: MatrixUser }) => (
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-gray-50 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||||
{u.type === 'company' ? <BuildingOffice2Icon className="h-4 w-4 text-indigo-600" /> : <UserIcon className="h-4 w-4 text-blue-600" />}
|
||||
<span className="font-medium truncate max-w-[140px]">{u.name}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Global search (already present) + node collapse state
|
||||
const [collapsedNodes, setCollapsedNodes] = useState<Record<number, boolean>>({})
|
||||
const toggleNode = (id: number) => setCollapsedNodes(p => ({ ...p, [id]: !p[id] }))
|
||||
|
||||
// Build children adjacency map
|
||||
const childrenMap = useMemo(() => {
|
||||
const m = new Map<number, MatrixUser[]>()
|
||||
users.forEach(u => {
|
||||
if (u.parentUserId != null) {
|
||||
const arr = m.get(u.parentUserId) || []
|
||||
arr.push(u)
|
||||
m.set(u.parentUserId, arr)
|
||||
}
|
||||
})
|
||||
// sort children by optional position then id
|
||||
m.forEach(arr => arr.sort((a,b) => {
|
||||
const pa = (a as any).position ?? 0
|
||||
const pb = (b as any).position ?? 0
|
||||
return pa - pb || a.id - b.id
|
||||
}))
|
||||
return m
|
||||
}, [users])
|
||||
|
||||
// Root node
|
||||
const rootNode = useMemo(
|
||||
() => users.find(u => u.level === 0 || u.id === resolvedRootUserId),
|
||||
[users, resolvedRootUserId]
|
||||
)
|
||||
|
||||
const rootChildren = useMemo(() => rootNode ? (childrenMap.get(rootNode.id) || []) : [], [rootNode, childrenMap])
|
||||
const rootChildrenCount = useMemo(() => rootChildren.length, [rootChildren])
|
||||
const displayedRootSlots = useMemo(() =>
|
||||
rootChildren.filter(c => {
|
||||
const pos = Number((c as any).position ?? -1)
|
||||
return pos >= 1 && pos <= 5
|
||||
}).length
|
||||
, [rootChildren])
|
||||
|
||||
// Rogue count if flags exist
|
||||
const rogueCount = useMemo(
|
||||
() => users.filter(u => (u as any).rogueUser || (u as any).rogue_user || (u as any).rogue).length,
|
||||
[users]
|
||||
)
|
||||
|
||||
// Filter match helper
|
||||
const searchLower = globalSearch.trim().toLowerCase()
|
||||
const matchesSearch = (u: MatrixUser) =>
|
||||
!searchLower ||
|
||||
u.name.toLowerCase().includes(searchLower) ||
|
||||
u.email.toLowerCase().includes(searchLower)
|
||||
|
||||
// Determine which nodes should be visible when searching:
|
||||
// Show all ancestors of matching nodes so the path is visible.
|
||||
const visibleIds = useMemo(() => {
|
||||
if (!searchLower) return new Set(users.map(u => u.id))
|
||||
const matchIds = new Set<number>()
|
||||
const parentMap = new Map<number, number | undefined>()
|
||||
users.forEach(u => parentMap.set(u.id, u.parentUserId == null ? undefined : u.parentUserId))
|
||||
users.forEach(u => {
|
||||
if (matchesSearch(u)) {
|
||||
let cur: number | undefined = u.id
|
||||
while (cur != null) {
|
||||
if (!matchIds.has(cur)) matchIds.add(cur)
|
||||
cur = parentMap.get(cur)
|
||||
}
|
||||
}
|
||||
})
|
||||
return matchIds
|
||||
}, [users, searchLower])
|
||||
|
||||
// Auto-expand ancestors for search results
|
||||
useEffect(() => {
|
||||
if (!searchLower) return
|
||||
// Expand all visible nodes containing matches
|
||||
setCollapsedNodes(prev => {
|
||||
const next = { ...prev }
|
||||
visibleIds.forEach(id => { next[id] = false })
|
||||
return next
|
||||
})
|
||||
}, [searchLower, visibleIds])
|
||||
|
||||
// Tree renderer (inside component to access scope)
|
||||
const renderNode = (node: MatrixUser, depth: number) => {
|
||||
const children = childrenMap.get(node.id) || []
|
||||
const hasChildren = children.length > 0
|
||||
const collapsed = collapsedNodes[node.id]
|
||||
const highlight = matchesSearch(node) && searchLower.length > 0
|
||||
if (!visibleIds.has(node.id)) return null
|
||||
const isRoot = node.level === 0
|
||||
const pos = node.position ?? null
|
||||
return (
|
||||
<li key={node.id} className="relative">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1 text-xs ${
|
||||
highlight ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => toggleNode(node.id)}
|
||||
className="h-4 w-4 rounded border border-gray-300 text-[10px] flex items-center justify-center bg-gray-50 hover:bg-gray-100"
|
||||
aria-label={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{collapsed ? '+' : '–'}
|
||||
</button>
|
||||
)}
|
||||
{!hasChildren && <span className="h-4 w-4" />}
|
||||
{node.type === 'company'
|
||||
? <BuildingOffice2Icon className="h-4 w-4 text-indigo-600" />
|
||||
: <UserIcon className="h-4 w-4 text-blue-600" />}
|
||||
<span className="font-medium truncate max-w-[160px]">{node.name}</span>
|
||||
<span className="text-[10px] px-1 rounded bg-gray-100 text-gray-600">L{node.level}</span>
|
||||
{(node as any).rogueUser || (node as any).rogue_user || (node as any).rogue ? (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800">Rogue</span>
|
||||
) : null}
|
||||
{pos != null && (
|
||||
<span className="text-[10px] px-1 rounded bg-gray-100 text-gray-600">
|
||||
pos {pos}
|
||||
</span>
|
||||
)}
|
||||
{isRoot && (
|
||||
<span className="ml-auto text-[10px] text-gray-500">
|
||||
Unlimited; positions numbered sequentially
|
||||
</span>
|
||||
)}
|
||||
{!isRoot && hasChildren && (
|
||||
<span className="ml-auto text-[10px] text-gray-500">
|
||||
{children.length}/5 (slots 1–5)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && !collapsed && (
|
||||
<ul className="ml-6 mt-1 flex flex-col gap-1">
|
||||
{children.map(c => renderNode(c, depth + 1))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// CSV export (now all users fetched)
|
||||
const exportCsv = () => {
|
||||
const rows = [['id','name','email','type','level','parentUserId','rogue']]
|
||||
users.forEach(u => rows.push([
|
||||
u.id,
|
||||
u.name,
|
||||
u.email,
|
||||
u.type,
|
||||
u.level,
|
||||
u.parentUserId ?? '',
|
||||
((u as any).rogueUser || (u as any).rogue_user || (u as any).rogue) ? 'true' : 'false'
|
||||
] as any))
|
||||
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = `matrix-${matrixId}-unlimited.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
|
||||
// When modal closes, refetch backend to sync page data
|
||||
const handleModalClose = () => {
|
||||
setOpen(false)
|
||||
setRefreshing(true)
|
||||
refetch() // triggers hook reload
|
||||
}
|
||||
|
||||
// Stop spinner when hook finishes loading
|
||||
useEffect(() => {
|
||||
if (!usersLoading && refreshing) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [usersLoading, refreshing])
|
||||
|
||||
// REMOVE old isUnlimited derivation using serverMaxDepth; REPLACE with policy-based
|
||||
// const isUnlimited = !serverMaxDepth || serverMaxDepth <= 0;
|
||||
const isUnlimited = policyMaxDepth == null || policyMaxDepth <= 0 // NEW
|
||||
|
||||
const policyDepth = (policyMaxDepth && policyMaxDepth > 0) ? policyMaxDepth : null
|
||||
const perLevelCounts = useMemo(() => {
|
||||
const m = new Map<number, number>()
|
||||
users.forEach(u => {
|
||||
if (u.level != null && u.level >= 0) {
|
||||
m.set(u.level, (m.get(u.level) || 0) + 1)
|
||||
}
|
||||
})
|
||||
return m
|
||||
}, [users])
|
||||
const totalNonRoot = useMemo(() => users.filter(u => (u.level ?? 0) > 0).length, [users])
|
||||
const fillMetrics = useMemo(() => {
|
||||
if (!policyDepth) return { label: 'N/A (unlimited policy)', highestFull: 'N/A' }
|
||||
let capacitySum = 0
|
||||
let highestFullLevel: number | null = null
|
||||
for (let k = 1; k <= policyDepth; k++) {
|
||||
const cap = Math.pow(5, k)
|
||||
capacitySum += cap
|
||||
const lvlCount = perLevelCounts.get(k) || 0
|
||||
if (lvlCount >= cap) highestFullLevel = k
|
||||
}
|
||||
if (capacitySum === 0) return { label: 'N/A', highestFull: 'N/A' }
|
||||
const pct = Math.round((totalNonRoot / capacitySum) * 100 * 100) / 100
|
||||
return { label: `${pct}%`, highestFull: highestFullLevel == null ? 'None' : `L${highestFullLevel}` }
|
||||
}, [policyDepth, perLevelCounts, totalNonRoot])
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* Smooth refresh overlay */}
|
||||
{refreshing && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/50 backdrop-blur-sm transition-opacity">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-white shadow-md border border-gray-200 px-4 py-3">
|
||||
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
||||
<span className="text-sm text-gray-700">Refreshing…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen w-full">
|
||||
<div className="mx-auto max-w-6xl px-2 sm:px-6 py-8">
|
||||
{/* Header card */}
|
||||
<header className="mb-8 rounded-2xl border border-gray-100 bg-white shadow-lg px-8 py-8 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => router.push('/admin/matrix-management')}
|
||||
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back to matrices
|
||||
</button>
|
||||
<h1 className="text-3xl font-extrabold text-blue-900">{matrixName}</h1>
|
||||
<p className="text-base text-blue-700">
|
||||
Top node: <span className="font-semibold text-blue-900">{topNodeEmail}</span>
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||||
Root: unlimited immediate children (sequential positions)
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||||
Non-root: 5 children (positions 1–5)
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-3 py-1 text-xs text-blue-900">
|
||||
Policy depth (DB): {isUnlimited ? 'Unlimited' : policyMaxDepth}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-purple-50 border border-purple-200 px-3 py-1 text-xs text-purple-900">
|
||||
Fetch depth (client slice): {DEFAULT_FETCH_DEPTH}
|
||||
</span>
|
||||
{serverMaxDepth != null && (
|
||||
<span className="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-3 py-1 text-xs text-amber-800">
|
||||
Server-reported max depth: {serverMaxDepth}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||||
Root children: {rootChildrenCount} (unlimited)
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||||
Displayed slots under root (positions 1–5): {displayedRootSlots}/5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setOpen(true) }}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Add users to matrix
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Banner for unlimited */}
|
||||
{isUnlimited && (
|
||||
<div className="mb-4 rounded-md px-4 py-2 text-xs text-blue-900 bg-blue-50 border border-blue-200">
|
||||
Unlimited matrix: depth grows without a configured cap. Display limited by fetch slice ({DEFAULT_FETCH_DEPTH} levels requested).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticky controls (CHANGED depth display) */}
|
||||
<div className="sticky top-0 z-10 bg-white/90 backdrop-blur px-6 py-4 border-b border-blue-100 flex flex-wrap items-center gap-4 rounded-xl mb-6 shadow">
|
||||
<div className="relative w-64">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
|
||||
<input
|
||||
value={globalSearch}
|
||||
onChange={e => setGlobalSearch(e.target.value)}
|
||||
placeholder="Global search..."
|
||||
className="pl-8 pr-2 py-2 rounded-lg border border-gray-200 text-xs focus:ring-1 focus:ring-blue-900 focus:border-transparent w-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => exportCsv()}
|
||||
className="text-xs text-blue-900 hover:text-blue-700 underline"
|
||||
>
|
||||
Export CSV (all fetched)
|
||||
</button>
|
||||
<div className="ml-auto text-[11px] text-gray-600">
|
||||
Policy depth: {isUnlimited ? 'Unlimited' : policyMaxDepth}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Small stats (CHANGED wording) */}
|
||||
<div className="mb-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Total users fetched</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{users.length}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Rogue users</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{rogueCount}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Structure</div>
|
||||
<div className="text-xl font-semibold text-blue-900">5‑ary Tree</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Policy Max Depth</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Fill %</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{fillMetrics.label}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||
<div className="text-xs text-gray-500 mb-1">Highest full level</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{fillMetrics.highestFull}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unlimited hierarchical tree (replaces dynamic levels + grouped level list) */}
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
||||
<div className="px-8 py-6 border-b border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-blue-900">Matrix Tree (Unlimited Depth)</h2>
|
||||
<p className="text-xs text-blue-700">Each node can hold up to 5 direct children. Depth unbounded.</p>
|
||||
</div>
|
||||
<div className="px-8 py-6">
|
||||
{!rootNode && (
|
||||
<div className="text-xs text-gray-500 italic">Root not yet loaded.</div>
|
||||
)}
|
||||
{rootNode && (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{renderNode(rootNode, 0)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vacancies placeholder */}
|
||||
<div className="rounded-2xl bg-white border border-dashed border-blue-200 shadow-sm p-6 mb-8">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">Vacancies</h3>
|
||||
<p className="text-sm text-blue-700">
|
||||
Pending backend wiring to MatrixController.listVacancies. This section will surface empty slots and allow reassignment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add Users Modal */}
|
||||
<SearchModal
|
||||
open={open}
|
||||
onClose={handleModalClose}
|
||||
matrixName={matrixName}
|
||||
rootUserId={resolvedRootUserId}
|
||||
matrixId={matrixId}
|
||||
topNodeEmail={topNodeEmail}
|
||||
existingUsers={users}
|
||||
policyMaxDepth={policyMaxDepth}
|
||||
onAdd={(u) => { addToMatrix(u) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// CHANGED: default export wraps inner component in Suspense
|
||||
export default function MatrixDetailPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<PageLayout>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#8D6B1D] mx-auto mb-3" />
|
||||
<p className="text-[#4A4A4A]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
}
|
||||
>
|
||||
<MatrixDetailPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
45
src/app/admin/matrix-management/hooks/changeMatrixState.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
|
||||
export type MatrixStateData = {
|
||||
matrixInstanceId: string | number
|
||||
wasActive: boolean
|
||||
isActive: boolean
|
||||
status: 'deactivated' | 'already_inactive' | 'activated' | 'already_active'
|
||||
}
|
||||
|
||||
type MatrixStateResponse = {
|
||||
success: boolean
|
||||
data?: MatrixStateData
|
||||
message?: string
|
||||
}
|
||||
|
||||
const baseUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
|
||||
async function patch(endpoint: string) {
|
||||
const url = `${baseUrl}${endpoint}`
|
||||
const res = await authFetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include'
|
||||
})
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
const raw = await res.text()
|
||||
const json: MatrixStateResponse | null = ct.includes('application/json') ? JSON.parse(raw) : null
|
||||
|
||||
if (!res.ok || !json?.success) {
|
||||
const msg = json?.message || `Request failed: ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return json.data!
|
||||
}
|
||||
|
||||
export async function deactivateMatrix(id: string | number) {
|
||||
if (!id && id !== 0) throw new Error('matrix id required')
|
||||
return patch(`/api/admin/matrix/${id}/deactivate`)
|
||||
}
|
||||
|
||||
export async function activateMatrix(id: string | number) {
|
||||
if (!id && id !== 0) throw new Error('matrix id required')
|
||||
// Assuming symmetrical endpoint; backend may expose this path.
|
||||
return patch(`/api/admin/matrix/${id}/activate`)
|
||||
}
|
||||
45
src/app/admin/matrix-management/hooks/createMatrix.ts
Normal file
@ -0,0 +1,45 @@
|
||||
export type CreateMatrixResult = {
|
||||
ok: boolean
|
||||
status: number
|
||||
body?: any
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function createMatrix(params: {
|
||||
token: string
|
||||
name: string
|
||||
email: string
|
||||
force?: boolean
|
||||
baseUrl?: string
|
||||
}): Promise<CreateMatrixResult> {
|
||||
const { token, name, email, force = false, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params
|
||||
if (!token) return { ok: false, status: 401, message: 'Missing token' }
|
||||
|
||||
const url = new URL(`${baseUrl}/api/matrix/create`)
|
||||
url.searchParams.set('name', name)
|
||||
url.searchParams.set('email', email)
|
||||
if (force) url.searchParams.set('force', 'true')
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
let body: any = null
|
||||
try { body = await res.json() } catch {}
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
body,
|
||||
message: body?.message || `Create matrix failed (${res.status})`
|
||||
}
|
||||
}
|
||||
return { ok: true, status: res.status, body }
|
||||
} catch (err) {
|
||||
return { ok: false, status: 0, message: 'Network error' }
|
||||
}
|
||||
}
|
||||
35
src/app/admin/matrix-management/hooks/getMatrixStats.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export type GetMatrixStatsResult = {
|
||||
ok: boolean
|
||||
status: number
|
||||
body?: any
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function getMatrixStats(params: {
|
||||
token: string
|
||||
baseUrl?: string
|
||||
}): Promise<GetMatrixStatsResult> {
|
||||
const { token, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params
|
||||
if (!token) return { ok: false, status: 401, message: 'Missing token' }
|
||||
|
||||
const base = (baseUrl || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/matrix/stats`
|
||||
console.info('[getMatrixStats] REQUEST GET', url)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
})
|
||||
let body: any = null
|
||||
try { body = await res.json() } catch {}
|
||||
console.debug('[getMatrixStats] Response', { status: res.status, hasBody: !!body, keys: body ? Object.keys(body) : [] })
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status, body, message: body?.message || `Fetch stats failed (${res.status})` }
|
||||
}
|
||||
return { ok: true, status: res.status, body }
|
||||
} catch (e) {
|
||||
console.error('[getMatrixStats] Network error', e)
|
||||
return { ok: false, status: 0, message: 'Network error' }
|
||||
}
|
||||
}
|
||||
531
src/app/admin/matrix-management/page.tsx
Normal file
@ -0,0 +1,531 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState, useEffect } from 'react'
|
||||
import {
|
||||
ChartBarIcon,
|
||||
CheckCircleIcon,
|
||||
UsersIcon,
|
||||
PlusIcon,
|
||||
EnvelopeIcon,
|
||||
CalendarDaysIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import PageLayout from '../../components/PageLayout'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
import { createMatrix } from './hooks/createMatrix'
|
||||
import { getMatrixStats } from './hooks/getMatrixStats'
|
||||
import { deactivateMatrix, activateMatrix } from './hooks/changeMatrixState' // NEW
|
||||
|
||||
type Matrix = {
|
||||
id: string
|
||||
name: string
|
||||
status: 'active' | 'inactive'
|
||||
usersCount: number
|
||||
createdAt: string
|
||||
topNodeEmail: string
|
||||
rootUserId: number // added
|
||||
policyMaxDepth?: number | null // NEW
|
||||
}
|
||||
|
||||
export default function MatrixManagementPage() {
|
||||
const router = useRouter()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const token = useAuthStore(s => s.accessToken)
|
||||
const isAdmin =
|
||||
!!user &&
|
||||
(
|
||||
(user as any)?.role === 'admin' ||
|
||||
(user as any)?.userType === 'admin' ||
|
||||
(user as any)?.isAdmin === true ||
|
||||
((user as any)?.roles?.includes?.('admin'))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (user === null) {
|
||||
router.push('/login')
|
||||
} else if (user && !isAdmin) {
|
||||
router.push('/')
|
||||
}
|
||||
}, [user, isAdmin, router])
|
||||
|
||||
const [matrices, setMatrices] = useState<Matrix[]>([])
|
||||
const [stats, setStats] = useState({ total: 0, active: 0, totalUsers: 0 })
|
||||
const [statsLoading, setStatsLoading] = useState(false)
|
||||
const [statsError, setStatsError] = useState<string>('')
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createEmail, setCreateEmail] = useState('')
|
||||
const [formError, setFormError] = useState<string>('')
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [forcePrompt, setForcePrompt] = useState<{ name: string; email: string } | null>(null)
|
||||
const [createSuccess, setCreateSuccess] = useState<{ name: string; email: string } | null>(null)
|
||||
|
||||
const [policyFilter, setPolicyFilter] = useState<'all'|'unlimited'|'five'>('all') // CHANGED
|
||||
const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc')
|
||||
const [sortByPolicy, setSortByPolicy] = useState<'none'|'asc'|'desc'>('none') // NEW
|
||||
const [mutatingId, setMutatingId] = useState<string | null>(null) // NEW
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!token) return
|
||||
setStatsLoading(true)
|
||||
setStatsError('')
|
||||
try {
|
||||
const res = await getMatrixStats({ token })
|
||||
console.log('📊 MatrixManagement: GET /matrix/stats ->', res.status, res.body)
|
||||
if (res.ok) {
|
||||
const payload = res.body?.data || res.body || {}
|
||||
const apiMatrices: any[] = Array.isArray(payload.matrices) ? payload.matrices : []
|
||||
const mapped: Matrix[] = apiMatrices.map((m: any, idx: number) => {
|
||||
const isActive = !!m?.isActive
|
||||
const createdAt = m?.createdAt || m?.ego_activated_at || m?.activatedAt || new Date().toISOString()
|
||||
const topNodeEmail = m?.topNodeEmail || m?.masterTopUserEmail || m?.email || ''
|
||||
const rootUserId = Number(m?.rootUserId ?? m?.root_user_id ?? 0)
|
||||
const matrixId = m?.matrixId ?? m?.id // prefer matrixId for routing
|
||||
const maxDepth = (m?.maxDepth ?? m?.policyMaxDepth) // backend optional
|
||||
return {
|
||||
id: String(matrixId ?? `m-${idx}`),
|
||||
name: String(m?.name ?? 'Unnamed Matrix'),
|
||||
status: isActive ? 'active' : 'inactive',
|
||||
usersCount: Number(m?.usersCount ?? 0),
|
||||
createdAt: String(createdAt),
|
||||
topNodeEmail: String(topNodeEmail),
|
||||
rootUserId,
|
||||
policyMaxDepth: typeof maxDepth === 'number' ? maxDepth : null // NEW
|
||||
}
|
||||
})
|
||||
setMatrices(mapped)
|
||||
const activeMatrices = Number(payload.activeMatrices ?? mapped.filter(m => m.status === 'active').length)
|
||||
const totalMatrices = Number(payload.totalMatrices ?? mapped.length)
|
||||
const totalUsersSubscribed = Number(payload.totalUsersSubscribed ?? 0)
|
||||
setStats({ total: totalMatrices, active: activeMatrices, totalUsers: totalUsersSubscribed })
|
||||
console.log('✅ MatrixManagement: mapped stats:', { total: totalMatrices, active: activeMatrices, totalUsers: totalUsersSubscribed })
|
||||
console.log('✅ MatrixManagement: mapped matrices sample:', mapped.slice(0, 3))
|
||||
} else {
|
||||
setStatsError(res.message || 'Failed to load matrix stats.')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ MatrixManagement: stats load error', e)
|
||||
setStatsError('Network error while loading matrix stats.')
|
||||
} finally {
|
||||
setStatsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [token])
|
||||
|
||||
const resetForm = () => {
|
||||
setCreateName('')
|
||||
setCreateEmail('')
|
||||
setFormError('')
|
||||
setForcePrompt(null)
|
||||
setCreateSuccess(null)
|
||||
}
|
||||
|
||||
const validateEmail = (email: string) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const name = createName.trim()
|
||||
const email = createEmail.trim()
|
||||
setFormError('')
|
||||
setCreateSuccess(null)
|
||||
setForcePrompt(null)
|
||||
|
||||
if (!name) {
|
||||
setFormError('Please provide a matrix name.')
|
||||
return
|
||||
}
|
||||
if (!email || !validateEmail(email)) {
|
||||
setFormError('Please provide a valid top-node email.')
|
||||
return
|
||||
}
|
||||
if (!token) {
|
||||
setFormError('Not authenticated. Please log in again.')
|
||||
return
|
||||
}
|
||||
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const res = await createMatrix({ token, name, email })
|
||||
console.log('🧱 MatrixManagement: create result ->', res.status, res.body)
|
||||
if (res.ok && res.body?.success) {
|
||||
const createdName = res.body?.data?.name || name
|
||||
const createdEmail = res.body?.data?.masterTopUserEmail || email
|
||||
setCreateSuccess({ name: createdName, email: createdEmail })
|
||||
await loadStats()
|
||||
setCreateName('')
|
||||
setCreateEmail('')
|
||||
} else if (res.status === 409) {
|
||||
setForcePrompt({ name, email })
|
||||
} else {
|
||||
setFormError(res.message || 'Failed to create matrix.')
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError('Network error while creating the matrix.')
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmForce = async () => {
|
||||
if (!forcePrompt || !token) return
|
||||
setFormError('')
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const res = await createMatrix({ token, name: forcePrompt.name, email: forcePrompt.email, force: true })
|
||||
console.log('🧱 MatrixManagement: force-create result ->', res.status, res.body)
|
||||
if (res.ok && res.body?.success) {
|
||||
const createdName = res.body?.data?.name || forcePrompt.name
|
||||
const createdEmail = res.body?.data?.masterTopUserEmail || forcePrompt.email
|
||||
setCreateSuccess({ name: createdName, email: createdEmail })
|
||||
setForcePrompt(null)
|
||||
setCreateName('')
|
||||
setCreateEmail('')
|
||||
await loadStats()
|
||||
} else {
|
||||
setFormError(res.message || 'Failed to create matrix (force).')
|
||||
}
|
||||
} catch {
|
||||
setFormError('Network error while forcing the matrix creation.')
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = async (id: string) => {
|
||||
try {
|
||||
const target = matrices.find(m => m.id === id)
|
||||
if (!target) return
|
||||
setStatsError('')
|
||||
setMutatingId(id)
|
||||
if (target.status === 'active') {
|
||||
await deactivateMatrix(id)
|
||||
} else {
|
||||
await activateMatrix(id)
|
||||
}
|
||||
await loadStats()
|
||||
} catch (e: any) {
|
||||
setStatsError(e?.message || 'Failed to change matrix state.')
|
||||
} finally {
|
||||
setMutatingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// derived list with filter/sort (always apply selected filter)
|
||||
const matricesView = useMemo(() => {
|
||||
let list = [...matrices]
|
||||
list = list.filter(m => {
|
||||
const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0
|
||||
if (policyFilter === 'all') return true
|
||||
return policyFilter === 'unlimited' ? unlimited : (!unlimited && m.policyMaxDepth === 5)
|
||||
})
|
||||
list.sort((a,b) => {
|
||||
if (sortByPolicy !== 'none') {
|
||||
const pa = (!a.policyMaxDepth || a.policyMaxDepth <= 0) ? Infinity : a.policyMaxDepth
|
||||
const pb = (!b.policyMaxDepth || b.policyMaxDepth <= 0) ? Infinity : b.policyMaxDepth
|
||||
const diff = sortByPolicy === 'asc' ? pa - pb : pb - pa
|
||||
if (diff !== 0) return diff
|
||||
}
|
||||
return sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount)
|
||||
})
|
||||
return list
|
||||
}, [matrices, policyFilter, sortByUsers, sortByPolicy])
|
||||
|
||||
const StatCard = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: any
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}) => (
|
||||
<div className="relative overflow-hidden rounded-lg bg-white px-4 pb-6 pt-5 shadow-sm border border-gray-200 sm:px-6 sm:pt-6">
|
||||
<dt>
|
||||
<div className={`absolute rounded-md ${color} p-3`}>
|
||||
<Icon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="ml-16 truncate text-sm font-medium text-gray-500">{label}</p>
|
||||
</dt>
|
||||
<dd className="ml-16 mt-2 text-2xl font-semibold text-gray-900">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
|
||||
const StatusBadge = ({ status }: { status: Matrix['status'] }) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`mr-1.5 h-1.5 w-1.5 rounded-full ${
|
||||
status === 'active' ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen w-full">
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header + Create */}
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Matrix Management</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Manage matrices, see stats, and create new ones.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Create Matrix
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||
<div className="flex items-center gap-2 text-xs text-blue-900">
|
||||
<span className="font-semibold">Policy filter:</span>
|
||||
<button onClick={() => setPolicyFilter('all')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='all'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>All</button>
|
||||
<button onClick={() => setPolicyFilter('unlimited')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='unlimited'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Unlimited</button>
|
||||
<button onClick={() => setPolicyFilter('five')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='five'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Depth 5</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-blue-900">
|
||||
<span className="font-semibold">Sort:</span>
|
||||
<select value={sortByPolicy} onChange={e => setSortByPolicy(e.target.value as any)} className="border rounded px-2 py-1">
|
||||
<option value="none">None</option>
|
||||
<option value="asc">Policy ↑</option>
|
||||
<option value="desc">Policy ↓</option>
|
||||
</select>
|
||||
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="border rounded px-2 py-1">
|
||||
<option value="desc">Users ↓</option>
|
||||
<option value="asc">Users ↑</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner for stats */}
|
||||
{statsError && (
|
||||
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{statsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-8">
|
||||
<StatCard icon={CheckCircleIcon} label="Active Matrices" value={stats.active} color="bg-green-500" />
|
||||
<StatCard icon={ChartBarIcon} label="Total Matrices" value={stats.total} color="bg-blue-900" />
|
||||
<StatCard icon={UsersIcon} label="Total Users Subscribed" value={stats.totalUsers} color="bg-amber-600" />
|
||||
</div>
|
||||
|
||||
{/* Matrix cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
{statsLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden">
|
||||
<div className="p-6 animate-pulse space-y-4">
|
||||
<div className="h-5 w-1/2 bg-gray-200 rounded" />
|
||||
<div className="h-4 w-1/3 bg-gray-200 rounded" />
|
||||
<div className="h-4 w-2/3 bg-gray-200 rounded" />
|
||||
<div className="h-9 w-full bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : matricesView.length === 0 ? (
|
||||
<div className="text-sm text-gray-600">No matrices found.</div>
|
||||
) : (
|
||||
matricesView.map(m => (
|
||||
<article key={m.id} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden flex flex-col">
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-xl font-semibold text-blue-900">{m.name}</h3>
|
||||
<StatusBadge status={m.status} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 border ${m.status==='inactive'?'border-gray-200 bg-gray-50 text-gray-500':'border-blue-200 bg-blue-50 text-blue-900'}`}>
|
||||
Policy: {(!m.policyMaxDepth || m.policyMaxDepth <= 0) ? 'Unlimited' : m.policyMaxDepth}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 border ${m.status==='inactive'?'border-gray-200 bg-gray-50 text-gray-500':'border-gray-200 bg-gray-100 text-gray-800'}`}>
|
||||
Root: unlimited immediate children (sequential), non-root: 5 children (positions 1–5)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm text-gray-700">
|
||||
<div className="flex items-center gap-2" title="Users count respects each matrix’s max depth policy.">
|
||||
<UsersIcon className="h-5 w-5 text-gray-500" />
|
||||
<span className="font-medium">{m.usersCount}</span>
|
||||
<span className="text-gray-500">users</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDaysIcon className="h-5 w-5 text-gray-500" />
|
||||
<span className="text-gray-600">
|
||||
{new Date(m.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvelopeIcon className="h-5 w-5 text-gray-500" />
|
||||
<span className="text-gray-700 truncate">{m.topNodeEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => toggleStatus(m.id)}
|
||||
disabled={mutatingId === m.id}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium border shadow transition
|
||||
${m.status === 'active'
|
||||
? 'border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-60'
|
||||
: 'border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-60'}`}
|
||||
>
|
||||
{mutatingId === m.id
|
||||
? (m.status === 'active' ? 'Deactivating…' : 'Activating…')
|
||||
: (m.status === 'active' ? 'Deactivate' : 'Activate')}
|
||||
</button>
|
||||
<span className="text-[11px] text-gray-500">
|
||||
State change will affect add/remove operations.
|
||||
</span>
|
||||
<button
|
||||
className="text-sm font-medium text-blue-900 hover:text-blue-700"
|
||||
onClick={() => {
|
||||
const defA = Number(localStorage.getItem(`matrixDepthA:${m.id}`) ?? 0)
|
||||
const defB = Number(localStorage.getItem(`matrixDepthB:${m.id}`) ?? 5)
|
||||
const params = new URLSearchParams({
|
||||
id: String(m.id),
|
||||
name: m.name,
|
||||
top: m.topNodeEmail,
|
||||
rootUserId: String(m.rootUserId),
|
||||
a: String(Number.isFinite(defA) ? defA : 0),
|
||||
b: String(Number.isFinite(defB) ? defB : 5)
|
||||
})
|
||||
router.push(`/admin/matrix-management/detail?${params.toString()}`)
|
||||
}}
|
||||
>
|
||||
View details →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Matrix Modal */}
|
||||
{createOpen && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => { setCreateOpen(false); resetForm() }} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
|
||||
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||
<h4 className="text-lg font-semibold text-blue-900">Create Matrix</h4>
|
||||
<button
|
||||
onClick={() => { setCreateOpen(false); resetForm() }}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreate} className="p-6 space-y-5">
|
||||
{/* Success banner */}
|
||||
{createSuccess && (
|
||||
<div className="rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||
Matrix created successfully.
|
||||
<div className="mt-1 text-green-800">
|
||||
<span className="font-semibold">Name:</span> {createSuccess.name}{' '}
|
||||
<span className="font-semibold ml-3">Top node:</span> {createSuccess.email}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 409 force prompt */}
|
||||
{forcePrompt && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
A matrix configuration already exists for this selection.
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmForce}
|
||||
disabled={createLoading}
|
||||
className="rounded-lg bg-amber-600 hover:bg-amber-500 text-white px-4 py-2 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
Replace (force)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForcePrompt(null)}
|
||||
disabled={createLoading}
|
||||
className="rounded-lg border border-amber-300 px-4 py-2 text-xs font-medium text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form fields */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Matrix Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createName}
|
||||
onChange={e => setCreateName(e.target.value)}
|
||||
disabled={createLoading}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
||||
placeholder="e.g., Platinum Matrix"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Top-node Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={createEmail}
|
||||
onChange={e => setCreateEmail(e.target.value)}
|
||||
disabled={createLoading}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
||||
placeholder="owner@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCreateOpen(false); resetForm() }}
|
||||
disabled={createLoading}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createLoading}
|
||||
className="rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow disabled:opacity-50 inline-flex items-center gap-2"
|
||||
>
|
||||
{createLoading && <span className="h-4 w-4 rounded-full border-2 border-white border-b-transparent animate-spin" />}
|
||||
{createLoading ? 'Creating...' : 'Create Matrix'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
27
src/app/admin/news-management/hooks/addNews.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
export async function addNews(payload: { title: string; summary?: string; content?: string; slug: string; category?: string; isActive: boolean; publishedAt?: string | null; imageFile?: File }) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const form = new FormData()
|
||||
form.append('title', payload.title)
|
||||
if (payload.summary) form.append('summary', payload.summary)
|
||||
if (payload.content) form.append('content', payload.content)
|
||||
form.append('slug', payload.slug)
|
||||
if (payload.category) form.append('category', payload.category)
|
||||
form.append('isActive', String(payload.isActive))
|
||||
if (payload.publishedAt) form.append('publishedAt', payload.publishedAt)
|
||||
if (payload.imageFile) form.append('image', payload.imageFile)
|
||||
|
||||
const url = `${BASE_URL}/api/admin/news`
|
||||
const res = await authFetch(url, { method: 'POST', body: form, headers: { Accept: 'application/json' } })
|
||||
let body: any = null
|
||||
try { body = await res.clone().json() } catch {}
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug('[addNews] status:', res.status)
|
||||
console.debug('[addNews] body preview:', body ? JSON.stringify(body).slice(0,500) : '<no body>')
|
||||
}
|
||||
if (res.status === 401) throw new Error('Unauthorized. Please log in.')
|
||||
if (res.status === 403) throw new Error('Forbidden. Admin access required.')
|
||||
if (!res.ok) throw new Error(body?.error || body?.message || `Failed to create news (${res.status})`)
|
||||
return body || res.json()
|
||||
}
|
||||
9
src/app/admin/news-management/hooks/deleteNews.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
export async function deleteNews(id: number) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const url = `${BASE_URL}/api/admin/news/${id}`
|
||||
const res = await authFetch(url, { method: 'DELETE', headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) throw new Error('Failed to delete news')
|
||||
return res.json()
|
||||
}
|
||||
58
src/app/admin/news-management/hooks/getNews.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
export type AdminNewsItem = {
|
||||
id: number
|
||||
title: string
|
||||
summary?: string
|
||||
content?: string
|
||||
slug: string
|
||||
category?: string
|
||||
imageUrl?: string
|
||||
isActive: boolean
|
||||
publishedAt?: string | null
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export function useAdminNews() {
|
||||
const [items, setItems] = React.useState<AdminNewsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const url = `${BASE_URL}/api/admin/news`
|
||||
const res = await authFetch(url, { headers: { Accept: 'application/json' } })
|
||||
let json: any = null
|
||||
try { json = await res.clone().json() } catch {}
|
||||
if (res.status === 401) throw new Error('Unauthorized. Please log in.')
|
||||
if (res.status === 403) throw new Error('Forbidden. Admin access required.')
|
||||
if (!res.ok) throw new Error(json?.error || json?.message || 'Failed to fetch admin news')
|
||||
const data = (json.data || []).map((r: any) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
content: r.content,
|
||||
slug: r.slug,
|
||||
category: r.category,
|
||||
imageUrl: r.imageUrl,
|
||||
isActive: !!r.is_active,
|
||||
publishedAt: r.published_at || null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
setItems(data)
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
return { items, loading, error, refresh }
|
||||
}
|
||||
24
src/app/admin/news-management/hooks/updateNews.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
export async function updateNews(id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const form = new FormData()
|
||||
if (payload.title) form.append('title', payload.title)
|
||||
if (payload.summary) form.append('summary', payload.summary)
|
||||
if (payload.content) form.append('content', payload.content)
|
||||
if (payload.slug) form.append('slug', payload.slug)
|
||||
if (payload.category) form.append('category', payload.category)
|
||||
if (payload.isActive !== undefined) form.append('isActive', String(payload.isActive))
|
||||
if (payload.publishedAt !== undefined && payload.publishedAt !== null) form.append('publishedAt', payload.publishedAt)
|
||||
if (payload.removeImage) form.append('removeImage', 'true')
|
||||
if (payload.imageFile) form.append('image', payload.imageFile)
|
||||
|
||||
const url = `${BASE_URL}/api/admin/news/${id}`
|
||||
const res = await authFetch(url, { method: 'PATCH', body: form, headers: { Accept: 'application/json' } })
|
||||
let body: any = null
|
||||
try { body = await res.clone().json() } catch {}
|
||||
if (res.status === 401) throw new Error('Unauthorized. Please log in.')
|
||||
if (res.status === 403) throw new Error('Forbidden. Admin access required.')
|
||||
if (!res.ok) throw new Error(body?.error || body?.message || `Failed to update news (${res.status})`)
|
||||
return body || res.json()
|
||||
}
|
||||
293
src/app/admin/news-management/page.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Header from '../../components/nav/Header'
|
||||
import Footer from '../../components/Footer'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
import { PlusIcon, PencilIcon, TrashIcon, PhotoIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import AffiliateCropModal from '../affiliate-management/components/AffiliateCropModal'
|
||||
import { useAdminNews } from './hooks/getNews'
|
||||
import { addNews } from './hooks/addNews'
|
||||
import { updateNews } from './hooks/updateNews'
|
||||
import { deleteNews } from './hooks/deleteNews'
|
||||
|
||||
export default function NewsManagementPage() {
|
||||
const { items, loading, error, refresh } = useAdminNews()
|
||||
const [showCreate, setShowCreate] = React.useState(false)
|
||||
const [selected, setSelected] = React.useState<any | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<any | null>(null)
|
||||
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<Header />
|
||||
<main className="bg-white min-h-screen pb-20">
|
||||
<div className="mx-auto max-w-7xl px-6 pt-8 pb-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-blue-900">News Manager</h1>
|
||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-900 text-white rounded-lg hover:bg-blue-800">
|
||||
<PlusIcon className="h-5 w-5" /> Add News
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-4 text-red-600">{error}</div>}
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-8">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="rounded-2xl border border-gray-200 overflow-hidden bg-white shadow-sm">
|
||||
<div className="aspect-[3/2] bg-gray-100">
|
||||
{item.imageUrl ? (
|
||||
<img src={item.imageUrl} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<PhotoIcon className="h-12 w-12 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-blue-900 truncate">{item.title}</h3>
|
||||
<span className={`text-xs px-2 py-1 rounded ${item.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>{item.isActive ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
{item.summary && <p className="mt-2 text-sm text-gray-700 line-clamp-2">{item.summary}</p>}
|
||||
<div className="mt-3 space-y-1 text-xs text-gray-500">
|
||||
{item.publishedAt && <div>Published: {new Date(item.publishedAt).toLocaleDateString('de-DE')}</div>}
|
||||
{item.createdAt && <div>Created: {new Date(item.createdAt).toLocaleDateString('de-DE')}</div>}
|
||||
{item.updatedAt && <div>Updated: {new Date(item.updatedAt).toLocaleDateString('de-DE')}</div>}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button onClick={() => setSelected(item)} className="px-3 py-1.5 text-sm bg-blue-50 text-blue-900 rounded hover:bg-blue-100">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(item)} className="px-3 py-1.5 text-sm bg-red-50 text-red-700 rounded hover:bg-red-100">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{showCreate && (
|
||||
<CreateNewsModal onClose={() => setShowCreate(false)} onCreate={async (payload) => { await addNews(payload); setShowCreate(false); await refresh() }} />
|
||||
)}
|
||||
{selected && (
|
||||
<EditNewsModal item={selected} onClose={() => setSelected(null)} onUpdate={async (id, payload) => { await updateNews(id, payload); setSelected(null); await refresh() }} />
|
||||
)}
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/30" onClick={() => setDeleteTarget(null)} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
|
||||
<div className="px-6 pt-6">
|
||||
<h3 className="text-lg font-semibold text-blue-900">Delete news?</h3>
|
||||
<p className="mt-2 text-sm text-gray-700">You are about to delete "{deleteTarget.title}". This action cannot be undone.</p>
|
||||
</div>
|
||||
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
||||
<button
|
||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow"
|
||||
onClick={async () => { await deleteNews(deleteTarget.id); setDeleteTarget(null); await refresh(); }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate: (payload: { title: string; summary?: string; content?: string; slug: string; category?: string; isActive: boolean; publishedAt?: string | null; imageFile?: File }) => void }) {
|
||||
const [title, setTitle] = React.useState('')
|
||||
const [summary, setSummary] = React.useState('')
|
||||
const [content, setContent] = React.useState('')
|
||||
const [slug, setSlug] = React.useState('')
|
||||
const [category, setCategory] = React.useState('')
|
||||
const [isActive, setIsActive] = React.useState(true)
|
||||
const [publishedAt, setPublishedAt] = React.useState<string>('')
|
||||
const [imageFile, setImageFile] = React.useState<File | undefined>(undefined)
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null)
|
||||
const [showCrop, setShowCrop] = React.useState(false)
|
||||
const [rawUrl, setRawUrl] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => () => { if (previewUrl) URL.revokeObjectURL(previewUrl); if (rawUrl) URL.revokeObjectURL(rawUrl) }, [previewUrl, rawUrl])
|
||||
|
||||
const openFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
const allowed = ['image/jpeg','image/png','image/webp']
|
||||
if (!allowed.includes(f.type)) { alert('Invalid image type'); e.target.value=''; return }
|
||||
if (f.size > 5*1024*1024) { alert('Max 5MB'); e.target.value=''; return }
|
||||
const url = URL.createObjectURL(f)
|
||||
setRawUrl(url)
|
||||
setShowCrop(true)
|
||||
e.target.value=''
|
||||
}
|
||||
|
||||
const onCropComplete = (blob: Blob) => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
if (rawUrl) URL.revokeObjectURL(rawUrl)
|
||||
const file = new File([blob], 'news-image.jpg', { type: 'image/jpeg' })
|
||||
setImageFile(file)
|
||||
setPreviewUrl(URL.createObjectURL(blob))
|
||||
setRawUrl(null)
|
||||
}
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onCreate({ title, summary: summary || undefined, content: content || undefined, slug, category: category || undefined, isActive, publishedAt: publishedAt || undefined, imageFile })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AffiliateCropModal isOpen={showCrop} imageSrc={rawUrl || ''} onClose={() => { setShowCrop(false); if (rawUrl) URL.revokeObjectURL(rawUrl); setRawUrl(null) }} onCropComplete={onCropComplete} />
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-blue-900">Add News</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="p-6 space-y-4">
|
||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
||||
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
||||
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
|
||||
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('news-image-upload') as HTMLInputElement)?.click()}>
|
||||
{!previewUrl ? (
|
||||
<div className="text-center w-full px-6 py-10">
|
||||
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-4 text-sm font-medium text-gray-700">Click to upload</div>
|
||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP up to 5MB</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
||||
<img src={previewUrl} alt="Preview" className="max-h-[180px] max-w-full object-contain" />
|
||||
<button type="button" onClick={() => { if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(null); setImageFile(undefined) }} className="absolute top-2 right-2 bg-red-50 hover:bg-red-100 text-red-700 px-3 py-1.5 rounded-lg text-sm">Remove</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input id="news-image-upload" type="file" accept="image/*" className="hidden" onChange={openFile} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="isActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">Active</label>
|
||||
<input type="datetime-local" value={publishedAt} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm text-gray-900" />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
|
||||
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Add News</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () => void; onUpdate: (id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) => void }) {
|
||||
const [title, setTitle] = React.useState(item.title)
|
||||
const [summary, setSummary] = React.useState(item.summary || '')
|
||||
const [content, setContent] = React.useState(item.content || '')
|
||||
const [slug, setSlug] = React.useState(item.slug)
|
||||
const [category, setCategory] = React.useState(item.category || '')
|
||||
const [isActive, setIsActive] = React.useState(item.isActive)
|
||||
const [publishedAt, setPublishedAt] = React.useState<string>(item.publishedAt || '')
|
||||
const [imageFile, setImageFile] = React.useState<File | undefined>(undefined)
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null)
|
||||
const [currentUrl, setCurrentUrl] = React.useState<string | undefined>(item.imageUrl)
|
||||
const [removeImage, setRemoveImage] = React.useState(false)
|
||||
const [showCrop, setShowCrop] = React.useState(false)
|
||||
const [rawUrl, setRawUrl] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => () => { if (previewUrl) URL.revokeObjectURL(previewUrl); if (rawUrl) URL.revokeObjectURL(rawUrl) }, [previewUrl, rawUrl])
|
||||
|
||||
const openFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
const allowed = ['image/jpeg','image/png','image/webp']
|
||||
if (!allowed.includes(f.type)) { alert('Invalid image type'); e.target.value=''; return }
|
||||
if (f.size > 5*1024*1024) { alert('Max 5MB'); e.target.value=''; return }
|
||||
const url = URL.createObjectURL(f)
|
||||
setRawUrl(url)
|
||||
setShowCrop(true)
|
||||
e.target.value=''
|
||||
}
|
||||
|
||||
const onCropComplete = (blob: Blob) => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
if (rawUrl) URL.revokeObjectURL(rawUrl)
|
||||
const file = new File([blob], 'news-image.jpg', { type: 'image/jpeg' })
|
||||
setImageFile(file)
|
||||
setRemoveImage(false)
|
||||
setPreviewUrl(URL.createObjectURL(blob))
|
||||
setRawUrl(null)
|
||||
}
|
||||
|
||||
const displayUrl = removeImage ? null : (previewUrl || currentUrl)
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onUpdate(item.id, { title, summary: summary || undefined, content: content || undefined, slug, category: category || undefined, isActive, publishedAt, imageFile, removeImage: removeImage && !imageFile })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AffiliateCropModal isOpen={showCrop} imageSrc={rawUrl || ''} onClose={() => { setShowCrop(false); if (rawUrl) URL.revokeObjectURL(rawUrl); setRawUrl(null) }} onCropComplete={onCropComplete} />
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-blue-900">Edit News</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="p-6 space-y-4">
|
||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
||||
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
||||
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
|
||||
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('edit-news-image-upload') as HTMLInputElement)?.click()}>
|
||||
{!displayUrl ? (
|
||||
<div className="text-center w-full px-6 py-10">
|
||||
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-4 text-sm font-medium text-gray-700">Click to upload</div>
|
||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP up to 5MB</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
||||
<img src={displayUrl} alt="Preview" className="max-h-[180px] max-w-full object-contain" />
|
||||
<button type="button" onClick={() => { if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(null); setImageFile(undefined); setCurrentUrl(undefined); setRemoveImage(true) }} className="absolute top-2 right-2 bg-red-50 hover:bg-red-100 text-red-700 px-3 py-1.5 rounded-lg text-sm">Remove</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input id="edit-news-image-upload" type="file" accept="image/*" className="hidden" onChange={openFile} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="editIsActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
|
||||
<label htmlFor="editIsActive" className="text-sm font-medium text-gray-700">Active</label>
|
||||
<input type="datetime-local" value={publishedAt || ''} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm text-gray-900" />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
|
||||
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
485
src/app/admin/page.tsx
Normal file
@ -0,0 +1,485 @@
|
||||
'use client'
|
||||
|
||||
import PageLayout from '../components/PageLayout'
|
||||
import Waves from '../components/background/waves'
|
||||
import BlueBlurryBackground from '../components/background/blueblurry' // NEW
|
||||
import {
|
||||
UsersIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CpuChipIcon,
|
||||
ServerStackIcon,
|
||||
ArrowRightIcon,
|
||||
Squares2X2Icon,
|
||||
BanknotesIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
CommandLineIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers'
|
||||
import useAuthStore from '../store/authStore'
|
||||
|
||||
// env-based feature flags
|
||||
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
|
||||
const DISPLAY_ABONEMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMMENTS !== 'false'
|
||||
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
|
||||
const DISPLAY_DEV_MANAGEMENT = process.env.NEXT_PUBLIC_DISPLAY_DEV_MANAGEMENT !== 'false'
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const router = useRouter()
|
||||
const { userStats, isAdmin } = useAdminUsers()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const isAdminOrSuper =
|
||||
!!user &&
|
||||
(
|
||||
(user as any)?.role === 'admin' ||
|
||||
(user as any)?.userType === 'admin' ||
|
||||
(user as any)?.isAdmin === true ||
|
||||
((user as any)?.roles?.includes?.('admin')) ||
|
||||
(user as any)?.role === 'super_admin' ||
|
||||
(user as any)?.userType === 'super_admin' ||
|
||||
(user as any)?.isSuperAdmin === true ||
|
||||
((user as any)?.roles?.includes?.('super_admin'))
|
||||
)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
// Handle client-side mounting
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)')
|
||||
const apply = () => setIsMobile(mq.matches)
|
||||
apply()
|
||||
mq.addEventListener?.('change', apply)
|
||||
window.addEventListener('resize', apply, { passive: true })
|
||||
return () => {
|
||||
mq.removeEventListener?.('change', apply)
|
||||
window.removeEventListener('resize', apply)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fallback for loading/no data
|
||||
const displayStats = userStats || {
|
||||
totalUsers: 0,
|
||||
adminUsers: 0,
|
||||
verificationPending: 0,
|
||||
activeUsers: 0,
|
||||
personalUsers: 0,
|
||||
companyUsers: 0
|
||||
}
|
||||
|
||||
const permissionStats = useMemo(() => ({
|
||||
permissions: 1 // TODO: fetch permission definitions
|
||||
}), [])
|
||||
|
||||
const serverStats = useMemo(() => ({
|
||||
status: 'Online',
|
||||
uptime: '4 days, 8 hours',
|
||||
cpu: '0%',
|
||||
memory: '0.1 / 7.8',
|
||||
recentErrors: [] as { id: string; ts: string; msg: string }[]
|
||||
}), [])
|
||||
|
||||
// Show loading during SSR/initial client render
|
||||
if (!isClient) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||||
<p className="text-blue-900">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Access check (only after client-side hydration)
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
|
||||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="relative z-10 min-h-screen flex flex-col">
|
||||
<main className="flex-1 max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8 w-full">
|
||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Admin Dashboard</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">
|
||||
Manage all administrative features, user management, permissions, and global settings.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Warning banner */}
|
||||
<div className="rounded-2xl border border-red-300 bg-red-50 text-red-700 px-8 py-6 flex gap-3 items-start text-base mb-8 shadow">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
|
||||
<div className="leading-relaxed">
|
||||
<p className="font-semibold mb-0.5">
|
||||
Warning: Settings and actions below this point can have consequences for the entire system!
|
||||
</p>
|
||||
<p className="text-red-600/80 hidden sm:block">
|
||||
Manage all administrative features, user management, permissions, and global settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<div className="mb-8 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6">
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Total Users</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{displayStats.totalUsers}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Admins</div>
|
||||
<div className="text-xl font-semibold text-indigo-700">{displayStats.adminUsers}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Active</div>
|
||||
<div className="text-xl font-semibold text-green-700">{displayStats.activeUsers}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Pending Verification</div>
|
||||
<div className="text-xl font-semibold text-amber-700">{displayStats.verificationPending}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Personal</div>
|
||||
<div className="text-xl font-semibold text-blue-700">{displayStats.personalUsers}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Company</div>
|
||||
<div className="text-xl font-semibold text-purple-700">{displayStats.companyUsers}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management Shortcuts Card */}
|
||||
<div className="mb-8">
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
||||
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-blue-900">Management Shortcuts</h2>
|
||||
<p className="text-sm text-blue-700 mt-0.5">
|
||||
Quick access to common admin modules.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Matrix Management */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!DISPLAY_MATRIX}
|
||||
onClick={DISPLAY_MATRIX ? () => router.push('/admin/matrix-management') : undefined}
|
||||
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
|
||||
DISPLAY_MATRIX
|
||||
? 'border border-blue-200 bg-blue-50 hover:bg-blue-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
|
||||
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
|
||||
DISPLAY_MATRIX
|
||||
? 'bg-blue-100 border-blue-200 group-hover:animate-pulse'
|
||||
: 'bg-gray-100 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Squares2X2Icon className={`h-6 w-6 ${DISPLAY_MATRIX ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
</span>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-semibold text-blue-900">Matrix Management</div>
|
||||
<div className="text-xs text-blue-700">Configure matrices and users</div>
|
||||
{!DISPLAY_MATRIX && (
|
||||
<p className="mt-1 text-xs text-gray-500 italic">
|
||||
This module is currently disabled in the system configuration.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon
|
||||
className={`h-5 w-5 ${
|
||||
DISPLAY_MATRIX ? 'text-blue-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Coffee Subscription Management */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!DISPLAY_ABONEMENTS}
|
||||
onClick={DISPLAY_ABONEMENTS ? () => router.push('/admin/subscriptions') : undefined}
|
||||
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
|
||||
DISPLAY_ABONEMENTS
|
||||
? 'border border-amber-200 bg-amber-50 hover:bg-amber-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
|
||||
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
|
||||
DISPLAY_ABONEMENTS
|
||||
? 'bg-amber-100 border-amber-200 group-hover:animate-pulse'
|
||||
: 'bg-gray-100 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<BanknotesIcon className={`h-6 w-6 ${DISPLAY_ABONEMENTS ? 'text-amber-600' : 'text-gray-400'}`} />
|
||||
</span>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-semibold text-amber-900">Coffee Subscription Management</div>
|
||||
<div className="text-xs text-amber-700">Plans, billing and renewals</div>
|
||||
{!DISPLAY_ABONEMENTS && (
|
||||
<p className="mt-1 text-xs text-gray-500 italic">
|
||||
This module is currently disabled in the system configuration.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon
|
||||
className={`h-5 w-5 ${
|
||||
DISPLAY_ABONEMENTS
|
||||
? 'text-amber-600 opacity-70 group-hover:opacity-100'
|
||||
: 'text-gray-400 opacity-60'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Contract Management (unchanged) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/contract-management')}
|
||||
className="group w-full flex items-center justify-between rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-indigo-100 border border-indigo-200 group-hover:animate-pulse">
|
||||
<ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" />
|
||||
</span>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-semibold text-indigo-900">Contract Management</div>
|
||||
<div className="text-xs text-indigo-700">Templates, approvals, status</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
{/* User Management (unchanged) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/user-management')}
|
||||
className="group w-full flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 hover:bg-blue-50 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-blue-100 border border-blue-200 group-hover:animate-pulse">
|
||||
<UsersIcon className="h-6 w-6 text-blue-600" />
|
||||
</span>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-semibold text-blue-900">User Management</div>
|
||||
<div className="text-xs text-blue-700">Browse, search, and manage all users</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
{/* News Management */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!DISPLAY_NEWS}
|
||||
onClick={DISPLAY_NEWS ? () => router.push('/admin/news-management') : undefined}
|
||||
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
|
||||
DISPLAY_NEWS
|
||||
? 'border border-green-200 bg-green-50 hover:bg-green-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
|
||||
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
|
||||
DISPLAY_NEWS
|
||||
? 'bg-green-100 border-green-200 group-hover:animate-pulse'
|
||||
: 'bg-gray-100 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<ClipboardDocumentListIcon className={`h-6 w-6 ${DISPLAY_NEWS ? 'text-green-600' : 'text-gray-400'}`} />
|
||||
</span>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-semibold text-green-900">News Management</div>
|
||||
<div className="text-xs text-green-700">Create and manage news articles</div>
|
||||
{!DISPLAY_NEWS && (
|
||||
<p className="mt-1 text-xs text-gray-500 italic">
|
||||
This module is currently disabled in the system configuration.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon
|
||||
className={`h-5 w-5 ${
|
||||
DISPLAY_NEWS ? 'text-green-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dev Management */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!DISPLAY_DEV_MANAGEMENT || !isAdminOrSuper}
|
||||
onClick={DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? () => router.push('/admin/dev-management') : undefined}
|
||||
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
|
||||
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper
|
||||
? 'border border-slate-200 bg-slate-50 hover:bg-slate-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
|
||||
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
|
||||
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper
|
||||
? 'bg-slate-100 border-slate-200 group-hover:animate-pulse'
|
||||
: 'bg-gray-100 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<CommandLineIcon className={`h-6 w-6 ${DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600' : 'text-gray-400'}`} />
|
||||
</span>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-semibold text-slate-900">Dev Management</div>
|
||||
<div className="text-xs text-slate-700">Run SQL queries and dev tools</div>
|
||||
{!DISPLAY_DEV_MANAGEMENT && (
|
||||
<p className="mt-1 text-xs text-gray-500 italic">
|
||||
This module is currently disabled in the system configuration.
|
||||
</p>
|
||||
)}
|
||||
{DISPLAY_DEV_MANAGEMENT && !isAdminOrSuper && (
|
||||
<p className="mt-1 text-xs text-gray-500 italic">
|
||||
Admin access required.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon
|
||||
className={`h-5 w-5 ${
|
||||
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Status & Logs */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gray-100">
|
||||
<ServerStackIcon className="h-7 w-7 text-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Server Status & Logs
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
System health, resource usage & recent error insights.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Metrics */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${serverStats.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||
<p className="text-base">
|
||||
<span className="font-semibold">Server Status:</span>{' '}
|
||||
<span className={serverStats.status === 'Online' ? 'text-emerald-600 font-medium' : 'text-red-600 font-medium'}>
|
||||
{serverStats.status === 'Online' ? 'Server Online' : 'Offline'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm space-y-1 text-gray-600">
|
||||
<p><span className="font-medium text-gray-700">Uptime:</span> {serverStats.uptime}</p>
|
||||
<p><span className="font-medium text-gray-700">CPU Usage:</span> {serverStats.cpu}</p>
|
||||
<p><span className="font-medium text-gray-700">Memory Usage:</span> {serverStats.memory} GB</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<CpuChipIcon className="h-4 w-4" />
|
||||
<span>Autoscaled environment (mock)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="hidden lg:block border-l border-gray-200" />
|
||||
|
||||
{/* Logs */}
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-base font-semibold text-gray-800 mb-3">
|
||||
Recent Error Logs
|
||||
</h3>
|
||||
{serverStats.recentErrors.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
No recent logs.
|
||||
</p>
|
||||
)}
|
||||
{/* Placeholder for future logs list */}
|
||||
{/* TODO: Replace with mapped log entries */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-gray-50 hover:bg-gray-100 text-gray-700 text-sm font-medium px-4 py-3 transition"
|
||||
// TODO: navigate to logs / monitoring page
|
||||
onClick={() => {}}
|
||||
>
|
||||
View Full Logs
|
||||
<ArrowRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{isMobile ? (
|
||||
<BlueBlurryBackground>{content}</BlueBlurryBackground>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
||||
style={{ backgroundImage: 'none', background: 'none' }}
|
||||
>
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
backgroundColor="rgba(245, 245, 240, 1)"
|
||||
waveSpeedX={0.02}
|
||||
waveSpeedY={0.01}
|
||||
waveAmpX={40}
|
||||
waveAmpY={20}
|
||||
friction={0.9}
|
||||
tension={0.01}
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
/>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
158
src/app/admin/pool-management/components/createNewPoolModal.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onCreate: (data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other' }) => void | Promise<void>
|
||||
creating: boolean
|
||||
error?: string
|
||||
success?: string
|
||||
clearMessages: () => void
|
||||
}
|
||||
|
||||
export default function CreateNewPoolModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreate,
|
||||
creating,
|
||||
error,
|
||||
success,
|
||||
clearMessages
|
||||
}: Props) {
|
||||
const [poolName, setPoolName] = React.useState('')
|
||||
const [description, setDescription] = React.useState('')
|
||||
const [price, setPrice] = React.useState('0.00')
|
||||
const [poolType, setPoolType] = React.useState<'coffee' | 'other'>('other')
|
||||
|
||||
const isDisabled = creating || !!success
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPoolName('')
|
||||
setDescription('')
|
||||
setPrice('0.00')
|
||||
setPoolType('other')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg mx-4 rounded-2xl bg-white shadow-xl border border-blue-100 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-blue-900">Create New Pool</h2>
|
||||
<button
|
||||
onClick={() => { clearMessages(); onClose(); }}
|
||||
className="text-gray-500 hover:text-gray-700 transition text-sm"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
onCreate({ pool_name: poolName, description, price: parseFloat(price) || 0, pool_type: poolType })
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Name</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
placeholder="e.g., VIP Members"
|
||||
value={poolName}
|
||||
onChange={e => setPoolName(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Description</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
rows={3}
|
||||
placeholder="Short description of the pool"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Price (per capsule)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
placeholder="0.00"
|
||||
value={price}
|
||||
onChange={e => setPrice(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Type</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
value={poolType}
|
||||
onChange={e => setPoolType(e.target.value as 'coffee' | 'other')}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<option value="other">Other</option>
|
||||
<option value="coffee">Coffee</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
|
||||
>
|
||||
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
|
||||
{creating ? 'Creating...' : 'Create Pool'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition"
|
||||
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); clearMessages(); }}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800 transition"
|
||||
onClick={() => { clearMessages(); onClose(); }}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
src/app/admin/pool-management/hooks/addPool.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
|
||||
export type AddPoolPayload = {
|
||||
pool_name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
pool_type: 'coffee' | 'other';
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export async function addPool(payload: AddPoolPayload) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/pools`;
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
const ok = res.status === 201 || res.ok;
|
||||
const message =
|
||||
body?.message ||
|
||||
(res.status === 409
|
||||
? 'Pool name already exists.'
|
||||
: res.status === 400
|
||||
? 'Invalid request. Check pool data.'
|
||||
: res.status === 401
|
||||
? 'Unauthorized.'
|
||||
: res.status === 403
|
||||
? 'Forbidden.'
|
||||
: res.status === 500
|
||||
? 'Internal server error.'
|
||||
: !ok
|
||||
? `Request failed (${res.status}).`
|
||||
: '');
|
||||
|
||||
return {
|
||||
ok,
|
||||
status: res.status,
|
||||
body,
|
||||
message,
|
||||
};
|
||||
}
|
||||
49
src/app/admin/pool-management/hooks/archivePool.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
|
||||
async function setPoolActiveStatus(
|
||||
id: string | number,
|
||||
is_active: boolean
|
||||
) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/pools/${id}/active`;
|
||||
const res = await authFetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_active }),
|
||||
});
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
const ok = res.ok;
|
||||
const message =
|
||||
body?.message ||
|
||||
(res.status === 404
|
||||
? 'Pool not found.'
|
||||
: res.status === 400
|
||||
? 'Invalid request.'
|
||||
: res.status === 403
|
||||
? 'Forbidden.'
|
||||
: res.status === 500
|
||||
? 'Server error.'
|
||||
: !ok
|
||||
? `Request failed (${res.status}).`
|
||||
: '');
|
||||
|
||||
return { ok, status: res.status, body, message };
|
||||
}
|
||||
|
||||
export async function setPoolInactive(id: string | number) {
|
||||
return setPoolActiveStatus(id, false);
|
||||
}
|
||||
|
||||
export async function setPoolActive(id: string | number) {
|
||||
return setPoolActiveStatus(id, true);
|
||||
}
|
||||
113
src/app/admin/pool-management/hooks/getlist.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
import { log } from '../../../utils/logger';
|
||||
|
||||
export type AdminPool = {
|
||||
id: string;
|
||||
pool_name: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
pool_type?: 'coffee' | 'other';
|
||||
is_active?: boolean;
|
||||
membersCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function useAdminPools() {
|
||||
const [pools, setPools] = useState<AdminPool[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const url = `${BASE_URL}/api/admin/pools`; // reverted to /api/admin/pools
|
||||
log("🌐 Pools: GET", url);
|
||||
try {
|
||||
const headers = { Accept: 'application/json' };
|
||||
log("📤 Pools: Request headers:", headers);
|
||||
|
||||
const res = await authFetch(url, { headers });
|
||||
log("📡 Pools: Response status:", res.status);
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
const preview = JSON.stringify(body).slice(0, 600);
|
||||
log("📦 Pools: Response body preview:", preview);
|
||||
} catch {
|
||||
log("📦 Pools: Response body is not JSON or failed to parse");
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
if (!cancelled) setError('Unauthorized. Please log in.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
if (!cancelled) setError('Forbidden. Admin access required.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setError('Failed to load pools.');
|
||||
return;
|
||||
}
|
||||
|
||||
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
|
||||
log("🔧 Pools: Mapping items count:", apiItems.length);
|
||||
|
||||
const mapped: AdminPool[] = apiItems.map(item => ({
|
||||
id: String(item.id),
|
||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||
description: String(item.description ?? ''),
|
||||
price: Number(item.price ?? 0),
|
||||
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||
is_active: Boolean(item.is_active),
|
||||
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
||||
createdAt: String(item.created_at ?? new Date().toISOString()),
|
||||
}));
|
||||
log("✅ Pools: Mapped sample:", mapped.slice(0, 3));
|
||||
|
||||
if (!cancelled) setPools(mapped);
|
||||
} catch (e: any) {
|
||||
log("❌ Pools: Network or parsing error:", e?.message || e);
|
||||
if (!cancelled) setError('Network error while loading pools.');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [BASE_URL]);
|
||||
|
||||
return {
|
||||
pools,
|
||||
loading,
|
||||
error,
|
||||
refresh: async () => {
|
||||
const url = `${BASE_URL}/api/admin/pools`; // reverted to /api/admin/pools
|
||||
log("🔁 Pools: Refresh GET", url);
|
||||
const res = await authFetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) {
|
||||
log("❌ Pools: Refresh failed status:", res.status);
|
||||
return false;
|
||||
}
|
||||
const body = await res.json();
|
||||
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
|
||||
setPools(apiItems.map(item => ({
|
||||
id: String(item.id),
|
||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||
description: String(item.description ?? ''),
|
||||
price: Number(item.price ?? 0),
|
||||
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||
is_active: Boolean(item.is_active),
|
||||
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
|
||||
createdAt: String(item.created_at ?? new Date().toISOString()),
|
||||
})));
|
||||
log("✅ Pools: Refresh succeeded, items:", apiItems.length);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
49
src/app/admin/pool-management/hooks/poolStatus.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { authFetch } from '../../../utils/authFetch';
|
||||
|
||||
async function setPoolActiveStatus(
|
||||
id: string | number,
|
||||
is_active: boolean
|
||||
) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${BASE_URL}/api/admin/pools/${id}/active`;
|
||||
const res = await authFetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_active }),
|
||||
});
|
||||
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
const ok = res.ok;
|
||||
const message =
|
||||
body?.message ||
|
||||
(res.status === 404
|
||||
? 'Pool not found.'
|
||||
: res.status === 400
|
||||
? 'Invalid request.'
|
||||
: res.status === 403
|
||||
? 'Forbidden.'
|
||||
: res.status === 500
|
||||
? 'Server error.'
|
||||
: !ok
|
||||
? `Request failed (${res.status}).`
|
||||
: '');
|
||||
|
||||
return { ok, status: res.status, body, message };
|
||||
}
|
||||
|
||||
export async function setPoolInactive(id: string | number) {
|
||||
return setPoolActiveStatus(id, false);
|
||||
}
|
||||
|
||||
export async function setPoolActive(id: string | number) {
|
||||
return setPoolActiveStatus(id, true);
|
||||
}
|
||||
513
src/app/admin/pool-management/manage/page.tsx
Normal file
@ -0,0 +1,513 @@
|
||||
'use client'
|
||||
|
||||
import React, { Suspense } from 'react' // CHANGED: add Suspense
|
||||
import Header from '../../../components/nav/Header'
|
||||
import Footer from '../../../components/Footer'
|
||||
import { UsersIcon, PlusIcon, BanknotesIcon, CalendarDaysIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
||||
import { AdminAPI } from '../../../utils/api'
|
||||
|
||||
type PoolUser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
contributed: number
|
||||
joinedAt: string // NEW: member since
|
||||
}
|
||||
|
||||
function PoolManagePageInner() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const user = useAuthStore(s => s.user)
|
||||
const token = useAuthStore(s => s.accessToken)
|
||||
const isAdmin =
|
||||
!!user &&
|
||||
(
|
||||
(user as any)?.role === 'admin' ||
|
||||
(user as any)?.userType === 'admin' ||
|
||||
(user as any)?.isAdmin === true ||
|
||||
((user as any)?.roles?.includes?.('admin'))
|
||||
)
|
||||
|
||||
// Auth gate
|
||||
const [authChecked, setAuthChecked] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
if (user === null) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
if (user && !isAdmin) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
setAuthChecked(true)
|
||||
}, [user, isAdmin, router])
|
||||
|
||||
// Read pool data from query params with fallbacks (hooks must be before any return)
|
||||
const poolId = searchParams.get('id') ?? 'pool-unknown'
|
||||
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
|
||||
const poolDescription = searchParams.get('description') ?? ''
|
||||
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
||||
const poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
|
||||
const poolIsActive = searchParams.get('is_active') === 'true'
|
||||
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
||||
|
||||
// Members (no dummy data)
|
||||
const [users, setUsers] = React.useState<PoolUser[]>([])
|
||||
const [membersLoading, setMembersLoading] = React.useState(false)
|
||||
const [membersError, setMembersError] = React.useState<string>('')
|
||||
|
||||
// Stats (no dummy data)
|
||||
const [totalAmount, setTotalAmount] = React.useState<number>(0)
|
||||
const [amountThisYear, setAmountThisYear] = React.useState<number>(0)
|
||||
const [amountThisMonth, setAmountThisMonth] = React.useState<number>(0)
|
||||
|
||||
// Search modal state
|
||||
const [searchOpen, setSearchOpen] = React.useState(false)
|
||||
const [query, setQuery] = React.useState('')
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState<string>('')
|
||||
const [candidates, setCandidates] = React.useState<Array<{ id: string; name: string; email: string }>>([])
|
||||
const [hasSearched, setHasSearched] = React.useState(false)
|
||||
const [selectedCandidates, setSelectedCandidates] = React.useState<Set<string>>(new Set())
|
||||
const [savingMembers, setSavingMembers] = React.useState(false)
|
||||
|
||||
async function fetchMembers() {
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
setMembersError('')
|
||||
setMembersLoading(true)
|
||||
try {
|
||||
const resp = await AdminAPI.getPoolMembers(token, poolId)
|
||||
const rows = Array.isArray(resp?.members) ? resp.members : []
|
||||
const mapped: PoolUser[] = rows.map((row: any) => {
|
||||
const name = row.company_name
|
||||
? String(row.company_name)
|
||||
: [row.first_name, row.last_name].filter(Boolean).join(' ').trim()
|
||||
return {
|
||||
id: String(row.id),
|
||||
name: name || String(row.email || '').trim() || 'Unnamed user',
|
||||
email: String(row.email || '').trim(),
|
||||
contributed: 0,
|
||||
joinedAt: row.joined_at || new Date().toISOString()
|
||||
}
|
||||
})
|
||||
setUsers(mapped)
|
||||
} catch (e: any) {
|
||||
setMembersError(e?.message || 'Failed to load pool members.')
|
||||
} finally {
|
||||
setMembersLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchMembers()
|
||||
}, [token, poolId])
|
||||
|
||||
// Early return AFTER all hooks are declared to keep consistent order
|
||||
if (!authChecked) return null
|
||||
|
||||
async function doSearch() {
|
||||
setError('')
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q.length < 3) {
|
||||
setHasSearched(false)
|
||||
setCandidates([])
|
||||
return
|
||||
}
|
||||
if (!token) {
|
||||
setError('Authentication required.')
|
||||
setHasSearched(true)
|
||||
setCandidates([])
|
||||
return
|
||||
}
|
||||
|
||||
setHasSearched(true)
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await AdminAPI.getUserList(token)
|
||||
const list = Array.isArray(resp?.users) ? resp.users : []
|
||||
|
||||
const existingIds = new Set(users.map(u => String(u.id)))
|
||||
|
||||
const mapped: Array<{ id: string; name: string; email: string }> = list
|
||||
.filter((u: any) => u && u.role !== 'admin' && u.role !== 'super_admin')
|
||||
.map((u: any) => {
|
||||
const name = u.company_name
|
||||
? String(u.company_name)
|
||||
: [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
||||
return {
|
||||
id: String(u.id),
|
||||
name: name || String(u.email || '').trim() || 'Unnamed user',
|
||||
email: String(u.email || '').trim()
|
||||
}
|
||||
})
|
||||
.filter((u: { id: string; name: string; email: string }) => !existingIds.has(u.id))
|
||||
.filter((u: { id: string; name: string; email: string }) => {
|
||||
const hay = `${u.name} ${u.email}`.toLowerCase()
|
||||
return hay.includes(q)
|
||||
})
|
||||
|
||||
setCandidates(mapped)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to search users.')
|
||||
setCandidates([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function addUserFromModal(u: { id: string; name: string; email: string }) {
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
setSavingMembers(true)
|
||||
setError('')
|
||||
try {
|
||||
await AdminAPI.addPoolMembers(token, poolId, [u.id])
|
||||
await fetchMembers()
|
||||
setSearchOpen(false)
|
||||
setQuery('')
|
||||
setCandidates([])
|
||||
setHasSearched(false)
|
||||
setSelectedCandidates(new Set())
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to add user.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setSavingMembers(false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCandidate(id: string) {
|
||||
setSelectedCandidates(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function addSelectedUsers() {
|
||||
if (selectedCandidates.size === 0) return
|
||||
const selectedList = candidates.filter(c => selectedCandidates.has(c.id))
|
||||
if (selectedList.length === 0) return
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
setSavingMembers(true)
|
||||
setError('')
|
||||
try {
|
||||
const userIds = selectedList.map(u => u.id)
|
||||
await AdminAPI.addPoolMembers(token, poolId, userIds)
|
||||
await fetchMembers()
|
||||
setSearchOpen(false)
|
||||
setQuery('')
|
||||
setCandidates([])
|
||||
setHasSearched(false)
|
||||
setSelectedCandidates(new Set())
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to add users.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setSavingMembers(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
||||
<Header />
|
||||
{/* main wrapper: avoid high z-index stacking */}
|
||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
|
||||
<div className="max-w-7xl mx-auto relative z-0">
|
||||
{/* Header (remove sticky/z-10) */}
|
||||
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-blue-900" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-blue-900 tracking-tight">{poolName}</h1>
|
||||
<p className="text-sm text-blue-700">
|
||||
{poolDescription ? poolDescription : 'Manage users and track pool funds'}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${!poolIsActive ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!poolIsActive ? 'bg-gray-400' : 'bg-green-500'}`} />
|
||||
{!poolIsActive ? 'Inactive' : 'Active'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
<span className="text-gray-500">ID: {poolId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Back to Pool Management */}
|
||||
<button
|
||||
onClick={() => router.push('/admin/pool-management')}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-white text-blue-900 border border-blue-200 px-4 py-2 text-sm font-medium hover:bg-blue-50 transition"
|
||||
title="Back to Pool Management"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stats (now zero until backend wired) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
|
||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-blue-900 p-2">
|
||||
<BanknotesIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total in Pool</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">€ {totalAmount.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-amber-600 p-2">
|
||||
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">This Year</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">€ {amountThisYear.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-green-600 p-2">
|
||||
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Current Month</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">€ {amountThisMonth.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unified Members card: add button + list */}
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{users.map(u => (
|
||||
<article key={u.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-blue-900" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-900">{u.name}</h3>
|
||||
<p className="text-xs text-gray-600">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2 py-0.5 text-xs text-blue-900">
|
||||
€ {u.contributed.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
Member since:{' '}
|
||||
<span className="font-medium text-gray-900">
|
||||
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{membersLoading && (
|
||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||
Loading members...
|
||||
</div>
|
||||
)}
|
||||
{membersError && !membersLoading && (
|
||||
<div className="col-span-full text-center text-red-600 py-6">
|
||||
{membersError}
|
||||
</div>
|
||||
)}
|
||||
{users.length === 0 && !membersLoading && !membersError && (
|
||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||
No users in this pool yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{/* Search Modal (keep above with high z) */}
|
||||
{searchOpen && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
|
||||
<div className="w-full max-w-2xl rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||
<h4 className="text-lg font-semibold text-blue-900">Add user to pool</h4>
|
||||
<button
|
||||
onClick={() => setSearchOpen(false)}
|
||||
className="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={e => { e.preventDefault(); void doSearch(); }}
|
||||
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-gray-100"
|
||||
>
|
||||
<div className="md:col-span-3">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search name or email…"
|
||||
className="w-full rounded-md bg-gray-50 border border-gray-300 text-sm text-gray-900 placeholder-gray-400 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || query.trim().length < 3}
|
||||
className="flex-1 rounded-md bg-blue-900 hover:bg-blue-800 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
||||
>
|
||||
{loading ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
|
||||
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="px-6 pt-1 pb-3 text-right text-xs text-gray-500">
|
||||
Min. 3 characters
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="px-6 py-4">
|
||||
{error && <div className="text-sm text-red-600 mb-3">{error}</div>}
|
||||
{!error && query.trim().length < 3 && (
|
||||
<div className="py-8 text-sm text-gray-500 text-center">
|
||||
Enter at least 3 characters and click Search.
|
||||
</div>
|
||||
)}
|
||||
{!error && hasSearched && loading && candidates.length === 0 && (
|
||||
<ul className="space-y-0 divide-y divide-gray-200 border border-gray-200 rounded-md bg-gray-50">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<li key={i} className="animate-pulse px-4 py-3">
|
||||
<div className="h-3.5 w-36 bg-gray-200 rounded" />
|
||||
<div className="mt-2 h-3 w-56 bg-gray-100 rounded" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{!error && hasSearched && !loading && candidates.length === 0 && (
|
||||
<div className="py-8 text-sm text-gray-500 text-center">
|
||||
No users match your search.
|
||||
</div>
|
||||
)}
|
||||
{!error && candidates.length > 0 && (
|
||||
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
|
||||
{candidates.map(u => (
|
||||
<li key={u.id} className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-gray-50 transition">
|
||||
<label className="min-w-0 flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900"
|
||||
checked={selectedCandidates.has(u.id)}
|
||||
onChange={() => toggleCandidate(u.id)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon className="h-4 w-4 text-blue-900" />
|
||||
<span className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{u.name}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-gray-600 break-all">{u.email}</div>
|
||||
</div>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => addUserFromModal(u)}
|
||||
className="shrink-0 inline-flex items-center rounded-md bg-blue-900 hover:bg-blue-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{loading && candidates.length > 0 && (
|
||||
<div className="pointer-events-none relative">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
|
||||
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-gray-100 flex items-center justify-between bg-gray-50">
|
||||
<div className="text-xs text-gray-600">
|
||||
{selectedCandidates.size > 0 ? `${selectedCandidates.size} selected` : 'No users selected'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSearchOpen(false)}
|
||||
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 transition"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={addSelectedUsers}
|
||||
disabled={selectedCandidates.size === 0 || savingMembers}
|
||||
className="text-sm rounded-md px-4 py-2 font-medium bg-blue-900 text-white hover:bg-blue-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{savingMembers ? 'Adding…' : 'Add Selected'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
}
|
||||
|
||||
// CHANGED: Suspense wrapper required for useSearchParams() during prerender
|
||||
export default function PoolManagePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#0F172A] mx-auto mb-3" />
|
||||
<p className="text-[#4A4A4A]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PoolManagePageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
294
src/app/admin/pool-management/page.tsx
Normal file
@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Header from '../../components/nav/Header'
|
||||
import Footer from '../../components/Footer'
|
||||
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||
import { useAdminPools } from './hooks/getlist'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
import { addPool } from './hooks/addPool'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
import CreateNewPoolModal from './components/createNewPoolModal'
|
||||
|
||||
type Pool = {
|
||||
id: string
|
||||
pool_name: string
|
||||
description?: string
|
||||
price?: number
|
||||
pool_type?: 'coffee' | 'other'
|
||||
is_active?: boolean
|
||||
membersCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function PoolManagementPage() {
|
||||
const router = useRouter()
|
||||
|
||||
// Modal state
|
||||
const [creating, setCreating] = React.useState(false)
|
||||
const [createError, setCreateError] = React.useState<string>('')
|
||||
const [createSuccess, setCreateSuccess] = React.useState<string>('')
|
||||
const [createModalOpen, setCreateModalOpen] = React.useState(false)
|
||||
const [archiveError, setArchiveError] = React.useState<string>('')
|
||||
|
||||
// Token and API URL
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
|
||||
// Replace local fetch with hook
|
||||
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
||||
const [pools, setPools] = React.useState<Pool[]>([])
|
||||
const [showInactive, setShowInactive] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!loading && !error) {
|
||||
setPools(initialPools)
|
||||
}
|
||||
}, [initialPools, loading, error])
|
||||
|
||||
const filteredPools = pools.filter(p => showInactive ? !p.is_active : p.is_active)
|
||||
|
||||
// REPLACED: handleCreatePool to accept data from modal with new schema fields
|
||||
async function handleCreatePool(data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other' }) {
|
||||
setCreateError('')
|
||||
setCreateSuccess('')
|
||||
const pool_name = data.pool_name.trim()
|
||||
const description = data.description.trim()
|
||||
if (!pool_name) {
|
||||
setCreateError('Please provide a pool name.')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await addPool({ pool_name, description: description || undefined, price: data.price, pool_type: data.pool_type, is_active: true })
|
||||
if (res.ok && res.body?.data) {
|
||||
setCreateSuccess('Pool created successfully.')
|
||||
await refresh?.()
|
||||
setTimeout(() => {
|
||||
setCreateModalOpen(false)
|
||||
setCreateSuccess('')
|
||||
}, 1500)
|
||||
} else {
|
||||
setCreateError(res.message || 'Failed to create pool.')
|
||||
}
|
||||
} catch {
|
||||
setCreateError('Network error while creating pool.')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive(poolId: string) {
|
||||
setArchiveError('')
|
||||
const res = await setPoolInactive(poolId)
|
||||
if (res.ok) {
|
||||
await refresh?.()
|
||||
} else {
|
||||
setArchiveError(res.message || 'Failed to deactivate pool.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetActive(poolId: string) {
|
||||
setArchiveError('')
|
||||
const res = await setPoolActive(poolId)
|
||||
if (res.ok) {
|
||||
await refresh?.()
|
||||
} else {
|
||||
setArchiveError(res.message || 'Failed to activate pool.')
|
||||
}
|
||||
}
|
||||
|
||||
const user = useAuthStore(s => s.user)
|
||||
const isAdmin =
|
||||
!!user &&
|
||||
(
|
||||
(user as any)?.role === 'admin' ||
|
||||
(user as any)?.userType === 'admin' ||
|
||||
(user as any)?.isAdmin === true ||
|
||||
((user as any)?.roles?.includes?.('admin'))
|
||||
)
|
||||
|
||||
// NEW: block rendering until we decide access
|
||||
const [authChecked, setAuthChecked] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
// When user is null -> unauthenticated; undefined means not loaded yet (store default may be null in this app).
|
||||
if (user === null) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
if (user && !isAdmin) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
// user exists and is admin
|
||||
setAuthChecked(true)
|
||||
}, [user, isAdmin, router])
|
||||
|
||||
// Early return: render nothing until authorized, prevents any flash
|
||||
if (!authChecked) return null
|
||||
|
||||
// Remove Access Denied overlay; render normal content
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
||||
<Header />
|
||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
|
||||
<div className="max-w-7xl mx-auto relative z-0">
|
||||
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8 relative z-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Pool Management</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Create and manage user pools.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setCreateModalOpen(true); createError && setCreateError(''); }}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||
>
|
||||
Create New Pool
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Show:</span>
|
||||
<button
|
||||
onClick={() => setShowInactive(false)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition ${!showInactive ? 'bg-blue-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
>
|
||||
Active Pools
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowInactive(true)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition ${showInactive ? 'bg-blue-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
>
|
||||
Inactive Pools
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Pools List card */}
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-blue-900">Existing Pools</h2>
|
||||
<span className="text-sm text-gray-600">{pools.length} total</span>
|
||||
</div>
|
||||
|
||||
{/* Show archive errors */}
|
||||
{archiveError && (
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{archiveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-white border border-gray-100 shadow p-5">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-5 w-1/2 bg-gray-200 rounded" />
|
||||
<div className="h-4 w-3/4 bg-gray-200 rounded" />
|
||||
<div className="h-4 w-2/3 bg-gray-100 rounded" />
|
||||
<div className="h-8 w-full bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPools.map(pool => (
|
||||
<article key={pool.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col relative z-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-blue-900" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-blue-900">{pool.pool_name}</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${!pool.is_active ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!pool.is_active ? 'bg-gray-400' : 'bg-green-500'}`} />
|
||||
{!pool.is_active ? 'Inactive' : 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="text-gray-500">Members</span>
|
||||
<div className="font-medium text-gray-900">{pool.membersCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Created</span>
|
||||
<div className="font-medium text-gray-900">
|
||||
{new Date(pool.createdAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<button
|
||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams({
|
||||
id: String(pool.id),
|
||||
pool_name: pool.pool_name ?? '',
|
||||
description: pool.description ?? '',
|
||||
price: String(pool.price ?? 0),
|
||||
pool_type: pool.pool_type ?? 'other',
|
||||
is_active: pool.is_active ? 'true' : 'false',
|
||||
createdAt: pool.createdAt ?? '',
|
||||
})
|
||||
router.push(`/admin/pool-management/manage?${params.toString()}`)
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
{!pool.is_active ? (
|
||||
<button
|
||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-green-100 text-green-800 hover:bg-green-200 transition"
|
||||
onClick={() => handleSetActive(pool.id)}
|
||||
title="Activate this pool"
|
||||
>
|
||||
Set Active
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition"
|
||||
onClick={() => handleArchive(pool.id)}
|
||||
title="Archive this pool"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{filteredPools.length === 0 && !loading && !error && (
|
||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||
{showInactive ? 'No inactive pools found.' : 'No active pools found.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modal for creating a new pool */}
|
||||
<CreateNewPoolModal
|
||||
isOpen={createModalOpen}
|
||||
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
|
||||
onCreate={handleCreatePool}
|
||||
creating={creating}
|
||||
error={createError}
|
||||
success={createSuccess}
|
||||
clearMessages={() => { setCreateError(''); setCreateSuccess(''); }}
|
||||
/>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
}
|
||||
132
src/app/admin/subscriptions/components/ImageCropModal.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { Point, Area } from 'react-easy-crop'
|
||||
|
||||
interface ImageCropModalProps {
|
||||
isOpen: boolean
|
||||
imageSrc: string
|
||||
onClose: () => void
|
||||
onCropComplete: (croppedImageBlob: Blob) => void
|
||||
}
|
||||
|
||||
export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComplete }: ImageCropModalProps) {
|
||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||
|
||||
const onCropAreaComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels)
|
||||
}, [])
|
||||
|
||||
const createCroppedImage = async () => {
|
||||
if (!croppedAreaPixels) return
|
||||
|
||||
const image = new Image()
|
||||
image.src = imageSrc
|
||||
await new Promise((resolve) => {
|
||||
image.onload = resolve
|
||||
})
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Set canvas size to cropped area
|
||||
canvas.width = croppedAreaPixels.width
|
||||
canvas.height = croppedAreaPixels.height
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
croppedAreaPixels.x,
|
||||
croppedAreaPixels.y,
|
||||
croppedAreaPixels.width,
|
||||
croppedAreaPixels.height,
|
||||
0,
|
||||
0,
|
||||
croppedAreaPixels.width,
|
||||
croppedAreaPixels.height
|
||||
)
|
||||
|
||||
return new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob)
|
||||
}, 'image/jpeg', 0.95)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const croppedBlob = await createCroppedImage()
|
||||
if (croppedBlob) {
|
||||
onCropComplete(croppedBlob)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||||
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
||||
<h2 className="text-xl font-semibold text-blue-900">Crop & Adjust Image</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 transition"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Crop Area */}
|
||||
<div className="relative bg-gray-900" style={{ height: '500px' }}>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={16 / 9}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropAreaComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">
|
||||
Zoom: {zoom.toFixed(1)}x
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="w-full h-2 bg-blue-200 rounded-lg appearance-none cursor-pointer accent-blue-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
||||
>
|
||||
Apply Crop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
289
src/app/admin/subscriptions/createSubscription/page.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import PageLayout from '../../../components/PageLayout';
|
||||
import useCoffeeManagement from '../hooks/useCoffeeManagement';
|
||||
import { PhotoIcon } from '@heroicons/react/24/solid';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ImageCropModal from '../components/ImageCropModal';
|
||||
|
||||
export default function CreateSubscriptionPage() {
|
||||
const { createProduct } = useCoffeeManagement();
|
||||
const router = useRouter();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [price, setPrice] = useState('0.00');
|
||||
const [state, setState] = useState<'available'|'unavailable'>('available');
|
||||
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
|
||||
const [showCropModal, setShowCropModal] = useState(false);
|
||||
const [currency, setCurrency] = useState('EUR');
|
||||
const [isFeatured, setIsFeatured] = useState(false);
|
||||
// Fixed billing defaults (locked: month / 1)
|
||||
const billingInterval: 'month' = 'month';
|
||||
const intervalCount: number = 1;
|
||||
|
||||
const onCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await createProduct({
|
||||
title,
|
||||
description,
|
||||
price: parseFloat(price),
|
||||
currency,
|
||||
is_featured: isFeatured,
|
||||
state: state === 'available',
|
||||
pictureFile
|
||||
});
|
||||
router.push('/admin/subscriptions');
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to create');
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup object URLs
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleSelectFile(file?: File) {
|
||||
if (!file) return;
|
||||
const allowed = ['image/jpeg','image/png','image/webp'];
|
||||
if (!allowed.includes(file.type)) {
|
||||
setError('Invalid image type. Allowed: JPG, PNG, WebP');
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB
|
||||
setError('Image exceeds 10MB limit');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
// Create object URL for cropping
|
||||
const url = URL.createObjectURL(file);
|
||||
setOriginalImageSrc(url);
|
||||
setShowCropModal(true);
|
||||
}
|
||||
|
||||
function handleCropComplete(croppedBlob: Blob) {
|
||||
// Convert blob to file
|
||||
const croppedFile = new File([croppedBlob], 'cropped-image.jpg', { type: 'image/jpeg' });
|
||||
setPictureFile(croppedFile);
|
||||
|
||||
// Create preview URL
|
||||
const url = URL.createObjectURL(croppedBlob);
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Create Coffee</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Add a new coffee.</p>
|
||||
</div>
|
||||
<Link href="/admin/subscriptions"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
Back to list
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg">
|
||||
<form onSubmit={onCreate} className="space-y-8">
|
||||
{/* Picture Upload moved to top */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">Picture</label>
|
||||
<p className="text-xs text-gray-600 mb-3">Upload an image and crop it to fit the coffee thumbnail (16:9 aspect ratio, 144px height)</p>
|
||||
<div
|
||||
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
|
||||
style={{ minHeight: '400px' }}
|
||||
onClick={() => document.getElementById('file-upload')?.click()}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
|
||||
}}
|
||||
>
|
||||
{!previewUrl && (
|
||||
<div className="text-center w-full px-6 py-10">
|
||||
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" />
|
||||
<div className="mt-4 text-base font-medium text-blue-700">
|
||||
<span>Click or drag and drop an image here</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
|
||||
<p className="text-xs text-gray-500 mt-2">You'll be able to crop and adjust the image after uploading</p>
|
||||
</div>
|
||||
)}
|
||||
{previewUrl && (
|
||||
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg"
|
||||
/>
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setShowCropModal(true);
|
||||
}}
|
||||
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-blue-900 shadow hover:bg-white transition"
|
||||
>
|
||||
Edit Crop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setPictureFile(undefined);
|
||||
setPreviewUrl(null);
|
||||
}}
|
||||
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="file-upload"
|
||||
name="file-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => handleSelectFile(e.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title moved above description */}
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-blue-900">Title</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
required
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description now after title */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-blue-900">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
required
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
|
||||
rows={3}
|
||||
placeholder="Describe the product"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-600">Shown to users in the shop and checkout.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{/* Price */}
|
||||
<div>
|
||||
<label htmlFor="price" className="block text-sm font-medium text-blue-900">Price</label>
|
||||
<input
|
||||
id="price"
|
||||
name="price"
|
||||
required
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
|
||||
placeholder="0.00"
|
||||
type="number"
|
||||
value={price}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setPrice(val);
|
||||
}}
|
||||
onBlur={e => {
|
||||
const num = parseFloat(e.target.value);
|
||||
if (!isNaN(num)) {
|
||||
setPrice(num.toFixed(2));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Currency */}
|
||||
<div>
|
||||
<label htmlFor="currency" className="block text-sm font-medium text-blue-900">Currency (e.g., EUR)</label>
|
||||
<input id="currency" name="currency" required maxLength={3} pattern="[A-Za-z]{3}" className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400" placeholder="EUR" value={currency} onChange={e => setCurrency(e.target.value.toUpperCase().slice(0,3))} />
|
||||
</div>
|
||||
{/* Featured */}
|
||||
<div className="flex items-center gap-2 mt-6">
|
||||
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
|
||||
<label htmlFor="featured" className="text-sm font-medium text-blue-900">Featured</label>
|
||||
</div>
|
||||
{/* Subscription Billing (Locked) + Availability */}
|
||||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900">Subscription Billing</label>
|
||||
<p className="mt-1 text-xs text-gray-600">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<input disabled value={billingInterval} className="w-40 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
||||
<input disabled value={intervalCount} className="w-24 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="availability" className="block text-sm font-medium text-blue-900">Availability</label>
|
||||
<select id="availability" name="availability" required className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black" value={state} onChange={e => setState(e.target.value as any)}>
|
||||
<option value="available">Available</option>
|
||||
<option value="unavailable">Unavailable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-x-4">
|
||||
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
||||
Cancel
|
||||
</Link>
|
||||
<button type="submit" className="inline-flex justify-center rounded-lg bg-blue-900 px-5 py-3 text-sm font-semibold text-blue-50 shadow hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900 transition">
|
||||
Create Coffee
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Image Crop Modal */}
|
||||
{originalImageSrc && (
|
||||
<ImageCropModal
|
||||
isOpen={showCropModal}
|
||||
imageSrc={originalImageSrc}
|
||||
onClose={() => setShowCropModal(false)}
|
||||
onCropComplete={handleCropComplete}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
289
src/app/admin/subscriptions/edit/[id]/page.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import PageLayout from '../../../../components/PageLayout';
|
||||
import useCoffeeManagement, { CoffeeItem } from '../../hooks/useCoffeeManagement';
|
||||
import { PhotoIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
export default function EditSubscriptionPage() {
|
||||
const router = useRouter();
|
||||
// next/navigation app router dynamic param
|
||||
const params = useParams();
|
||||
const idParam = params?.id;
|
||||
const id = typeof idParam === 'string' ? parseInt(idParam, 10) : Array.isArray(idParam) ? parseInt(idParam[0], 10) : NaN;
|
||||
|
||||
const { listProducts, updateProduct } = useCoffeeManagement();
|
||||
|
||||
const [item, setItem] = useState<CoffeeItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [price, setPrice] = useState<string>('');
|
||||
const [currency, setCurrency] = useState('EUR');
|
||||
const [isFeatured, setIsFeatured] = useState(false);
|
||||
const [state, setState] = useState(true);
|
||||
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [removeExistingPicture, setRemoveExistingPicture] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
async function load() {
|
||||
if (!id || Number.isNaN(id)) {
|
||||
setError('Invalid subscription id');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const all = await listProducts();
|
||||
const found = all.find((p: CoffeeItem) => p.id === id) || null;
|
||||
if (!active) return;
|
||||
if (!found) {
|
||||
setError('Subscription not found');
|
||||
} else {
|
||||
setItem(found);
|
||||
setTitle(found.title || '');
|
||||
setDescription(found.description || '');
|
||||
setPrice(found.price != null ? String(found.price) : '');
|
||||
setCurrency(found.currency || 'EUR');
|
||||
setIsFeatured(!!found.is_featured);
|
||||
setState(!!found.state);
|
||||
setRemoveExistingPicture(false);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (active) setError(e?.message ?? 'Failed to load subscription');
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { active = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!item) return;
|
||||
setError(null);
|
||||
try {
|
||||
const numericPrice = Number(price);
|
||||
if (!Number.isFinite(numericPrice) || numericPrice < 0) {
|
||||
setError('Price must be a valid non-negative number');
|
||||
return;
|
||||
}
|
||||
await updateProduct(item.id, {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
price: numericPrice,
|
||||
currency: currency.trim(),
|
||||
is_featured: isFeatured,
|
||||
state,
|
||||
pictureFile,
|
||||
removePicture: removeExistingPicture && !pictureFile ? true : false,
|
||||
});
|
||||
router.push('/admin/subscriptions');
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Update failed');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pictureFile) {
|
||||
const url = URL.createObjectURL(pictureFile);
|
||||
setPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [pictureFile]);
|
||||
|
||||
function handleSelectFile(file?: File) {
|
||||
if (!file) return;
|
||||
const allowed = ['image/jpeg','image/png','image/webp'];
|
||||
if (!allowed.includes(file.type)) {
|
||||
setError('Invalid image type. Allowed: JPG, PNG, WebP');
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('Image exceeds 10MB limit');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setPictureFile(file);
|
||||
setRemoveExistingPicture(false); // selecting new overrides removal flag
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Edit Coffee</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Update details of the coffee.</p>
|
||||
</div>
|
||||
<Link href="/admin/subscriptions"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
Back to list
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && (
|
||||
<div className="rounded-md bg-blue-50 p-4 text-blue-700 text-sm mb-6">Loading subscription…</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-red-700 text-sm mb-6">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && item && (
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900">Title</label>
|
||||
<input
|
||||
required
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900">Price</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
required
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
|
||||
value={price}
|
||||
onChange={e => setPrice(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900">Currency</label>
|
||||
<input
|
||||
required
|
||||
maxLength={3}
|
||||
pattern="[A-Za-z]{3}"
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
|
||||
value={currency}
|
||||
onChange={e => setCurrency(e.target.value.toUpperCase().slice(0,3))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
|
||||
<label htmlFor="featured" className="text-sm font-medium text-blue-900">Featured</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="enabled" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={state} onChange={e => setState(e.target.checked)} />
|
||||
<label htmlFor="enabled" className="text-sm font-medium text-blue-900">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900">Description</label>
|
||||
<textarea
|
||||
required
|
||||
rows={4}
|
||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">Picture (optional)</label>
|
||||
<p className="text-xs text-gray-600 mb-3">Upload an image to replace the current picture (16:9 aspect ratio recommended)</p>
|
||||
<div
|
||||
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
|
||||
style={{ minHeight: '400px' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
|
||||
}}
|
||||
>
|
||||
{!previewUrl && !item.pictureUrl && (
|
||||
<div className="text-center w-full px-6 py-10">
|
||||
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" />
|
||||
<div className="mt-4 text-base font-medium text-blue-700">
|
||||
<span>Click or drag and drop a new image here</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
|
||||
</div>
|
||||
)}
|
||||
{(previewUrl || (!removeExistingPicture && item.pictureUrl)) && (
|
||||
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6">
|
||||
<img
|
||||
src={previewUrl || item.pictureUrl || ''}
|
||||
alt={previewUrl ? "Preview" : item.title}
|
||||
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg"
|
||||
/>
|
||||
<div className="absolute top-4 right-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (previewUrl) {
|
||||
setPictureFile(undefined);
|
||||
setPreviewUrl(null);
|
||||
} else if (item.pictureUrl) {
|
||||
setRemoveExistingPicture(true);
|
||||
}
|
||||
}}
|
||||
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{removeExistingPicture && !previewUrl && (
|
||||
<div className="text-center w-full px-6 py-10">
|
||||
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-gray-400" />
|
||||
<div className="mt-4 text-base font-medium text-gray-600">
|
||||
<span>Image removed - Click to upload a new one</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">PNG, JPG, WebP up to 10MB</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => handleSelectFile(e.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-x-4">
|
||||
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
||||
Cancel
|
||||
</Link>
|
||||
<button type="submit" className="inline-flex justify-center rounded-lg bg-blue-900 px-5 py-3 text-sm font-semibold text-blue-50 shadow hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900 transition">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
146
src/app/admin/subscriptions/hooks/useCoffeeManagement.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { useCallback } from 'react';
|
||||
import useAuthStore from '../../../store/authStore';
|
||||
|
||||
export type CoffeeItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency?: string;
|
||||
is_featured?: boolean;
|
||||
billing_interval?: 'day'|'week'|'month'|'year'|null;
|
||||
interval_count?: number|null;
|
||||
object_storage_id?: string|null;
|
||||
original_filename?: string|null;
|
||||
state: boolean;
|
||||
pictureUrl?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
function isFormData(body: any): body is FormData {
|
||||
return typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
}
|
||||
|
||||
export default function useCoffeeManagement() {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||
const getState = useAuthStore.getState;
|
||||
|
||||
const authorizedFetch = useCallback(
|
||||
async <T = any>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
responseType: 'json' | 'text' | 'blob' = 'json'
|
||||
): Promise<T> => {
|
||||
let token = getState().accessToken;
|
||||
if (!token) {
|
||||
const ok = await getState().refreshAuthToken();
|
||||
if (ok) token = getState().accessToken;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(init.headers as Record<string, string> || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
if (!isFormData(init.body) && init.method && init.method !== 'GET') {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
if (responseType === 'blob') return (await res.blob()) as unknown as T;
|
||||
if (responseType === 'text') return (await res.text()) as unknown as T;
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text) as T; } catch { return {} as T; }
|
||||
},
|
||||
[base]
|
||||
);
|
||||
|
||||
const listProducts = useCallback(async (): Promise<CoffeeItem[]> => {
|
||||
const data = await authorizedFetch<any[]>('/api/admin/coffee', { method: 'GET' });
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.map((r: any) => ({
|
||||
...r,
|
||||
id: Number(r.id),
|
||||
price: r.price != null && r.price !== '' ? Number(r.price) : 0,
|
||||
interval_count: r.interval_count != null && r.interval_count !== '' ? Number(r.interval_count) : null,
|
||||
state: !!r.state,
|
||||
})) as CoffeeItem[];
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const createProduct = useCallback(async (payload: {
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency?: string;
|
||||
is_featured?: boolean;
|
||||
state?: boolean;
|
||||
pictureFile?: File;
|
||||
}): Promise<CoffeeItem> => {
|
||||
const fd = new FormData();
|
||||
fd.append('title', payload.title);
|
||||
fd.append('description', payload.description);
|
||||
fd.append('price', String(payload.price));
|
||||
if (payload.currency) fd.append('currency', payload.currency);
|
||||
if (typeof payload.is_featured === 'boolean') fd.append('is_featured', String(payload.is_featured));
|
||||
if (typeof payload.state === 'boolean') fd.append('state', String(payload.state));
|
||||
// Fixed billing defaults
|
||||
fd.append('billing_interval', 'month');
|
||||
fd.append('interval_count', '1');
|
||||
if (payload.pictureFile) fd.append('picture', payload.pictureFile);
|
||||
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: fd });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const updateProduct = useCallback(async (id: number, payload: Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
is_featured: boolean;
|
||||
state: boolean;
|
||||
pictureFile: File;
|
||||
removePicture: boolean;
|
||||
}>): Promise<CoffeeItem> => {
|
||||
const fd = new FormData();
|
||||
if (payload.title !== undefined) fd.append('title', String(payload.title));
|
||||
if (payload.description !== undefined) fd.append('description', String(payload.description));
|
||||
if (payload.price !== undefined) fd.append('price', String(payload.price));
|
||||
if (payload.currency !== undefined) fd.append('currency', payload.currency);
|
||||
if (payload.is_featured !== undefined) fd.append('is_featured', String(payload.is_featured));
|
||||
if (payload.state !== undefined) fd.append('state', String(payload.state));
|
||||
if (payload.removePicture) fd.append('removePicture', 'true');
|
||||
// Keep fixed defaults
|
||||
fd.append('billing_interval', 'month');
|
||||
fd.append('interval_count', '1');
|
||||
if (payload.pictureFile) fd.append('picture', payload.pictureFile);
|
||||
return authorizedFetch<CoffeeItem>(`/api/admin/coffee/${id}`, { method: 'PUT', body: fd });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const setProductState = useCallback(async (id: number, state: boolean): Promise<CoffeeItem> => {
|
||||
return authorizedFetch<CoffeeItem>(`/api/admin/coffee/${id}/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ state })
|
||||
});
|
||||
}, [authorizedFetch]);
|
||||
|
||||
const deleteProduct = useCallback(async (id: number): Promise<{success?: boolean}> => {
|
||||
return authorizedFetch<{success?: boolean}>(`/api/admin/coffee/${id}`, { method: 'DELETE' });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
return {
|
||||
listProducts,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
setProductState,
|
||||
deleteProduct,
|
||||
};
|
||||
}
|
||||
158
src/app/admin/subscriptions/page.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PhotoIcon } from '@heroicons/react/24/solid';
|
||||
import Link from 'next/link';
|
||||
import PageLayout from '../../components/PageLayout';
|
||||
import useCoffeeManagement, { CoffeeItem } from './hooks/useCoffeeManagement';
|
||||
|
||||
export default function AdminSubscriptionsPage() {
|
||||
const { listProducts, setProductState, deleteProduct } = useCoffeeManagement();
|
||||
|
||||
const [items, setItems] = useState<CoffeeItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listProducts();
|
||||
setItems(Array.isArray(data) ? data : []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Failed to load products');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const availabilityBadge = (avail: boolean) => (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${avail ? 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200' : 'bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300'}`}>
|
||||
{avail ? 'Available' : 'Unavailable'}
|
||||
</span>
|
||||
);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<CoffeeItem | null>(null);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-6 px-6 rounded-2xl shadow-lg mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Coffees</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Manage all coffees.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/subscriptions/createSubscription"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition self-start sm:self-auto"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>
|
||||
Create Coffee
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{loading && (
|
||||
<div className="col-span-full text-sm text-gray-700">Loading…</div>
|
||||
)}
|
||||
{!loading && items.map(item => (
|
||||
<div key={item.id} className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 flex flex-col gap-3 hover:shadow-xl transition">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-xl font-semibold text-blue-900">{item.title}</h3>
|
||||
{availabilityBadge(!!item.state)}
|
||||
</div>
|
||||
<div className="mt-3 w-full h-40 rounded-xl ring-1 ring-gray-200 overflow-hidden flex items-center justify-center bg-gray-50">
|
||||
{item.pictureUrl ? (
|
||||
<img src={item.pictureUrl} alt={item.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<PhotoIcon className="w-12 h-12 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-800 line-clamp-4">{item.description}</p>
|
||||
<dl className="mt-4 grid grid-cols-1 gap-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500">Price</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
|
||||
</dd>
|
||||
</div>
|
||||
{item.billing_interval && item.interval_count ? (
|
||||
<div className="text-gray-600">
|
||||
<span className="text-xs">Subscription billing: {item.billing_interval} (x{item.interval_count})</span>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-medium shadow transition
|
||||
${item.state
|
||||
? 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200 hover:bg-amber-100'
|
||||
: 'bg-blue-900 text-blue-50 hover:bg-blue-800'}`}
|
||||
onClick={async () => { await setProductState(item.id, !item.state); await load(); }}
|
||||
>
|
||||
{item.state ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<Link
|
||||
href={`/admin/subscriptions/edit/${item.id}`}
|
||||
className="inline-flex items-center rounded-lg bg-indigo-50 px-4 py-2 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-200 hover:bg-indigo-100 shadow transition"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
className="inline-flex items-center rounded-lg bg-red-50 px-4 py-2 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-200 hover:bg-red-100 shadow transition"
|
||||
onClick={() => setDeleteTarget(item)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && !items.length && (
|
||||
<div className="col-span-full py-8 text-center text-sm text-gray-500">No subscriptions found.</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Confirm Delete Modal */}
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/30" onClick={() => setDeleteTarget(null)} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
|
||||
<div className="px-6 pt-6">
|
||||
<h3 className="text-lg font-semibold text-blue-900">Delete coffee?</h3>
|
||||
<p className="mt-2 text-sm text-gray-700">You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.</p>
|
||||
</div>
|
||||
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
||||
<button
|
||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow"
|
||||
onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
522
src/app/admin/user-management/page.tsx
Normal file
@ -0,0 +1,522 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import PageLayout from '../../components/PageLayout'
|
||||
import UserDetailModal from '../../components/UserDetailModal'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
PencilSquareIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
||||
import { AdminAPI } from '../../utils/api'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
|
||||
type UserType = 'personal' | 'company'
|
||||
type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived'
|
||||
type UserRole = 'user' | 'admin'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
email: string
|
||||
user_type: UserType
|
||||
role: UserRole
|
||||
created_at: string
|
||||
last_login_at: string | null
|
||||
status: string
|
||||
is_admin_verified: number
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
company_name?: string
|
||||
}
|
||||
|
||||
const STATUSES: UserStatus[] = ['active','pending','disabled','inactive']
|
||||
const TYPES: UserType[] = ['personal','company']
|
||||
const ROLES: UserRole[] = ['user','admin']
|
||||
|
||||
export default function AdminUserManagementPage() {
|
||||
const { isAdmin } = useAdminUsers()
|
||||
const token = useAuthStore(state => state.accessToken)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
// State for all users (not just pending)
|
||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Handle client-side mounting
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
// Fetch all users from backend
|
||||
const fetchAllUsers = useCallback(async () => {
|
||||
if (!token || !isAdmin) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await AdminAPI.getUserList(token)
|
||||
if (response.success) {
|
||||
setAllUsers(response.users || [])
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to fetch users')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch users'
|
||||
setError(errorMessage)
|
||||
console.error('AdminUserManagement.fetchAllUsers error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token, isAdmin])
|
||||
|
||||
// Load users on mount
|
||||
useEffect(() => {
|
||||
if (isClient && isAdmin && token) {
|
||||
fetchAllUsers()
|
||||
}
|
||||
}, [fetchAllUsers, isClient])
|
||||
|
||||
// Filter hooks - must be declared before conditional returns
|
||||
const [search, setSearch] = useState('')
|
||||
const [fType, setFType] = useState<'all'|UserType>('all')
|
||||
const [fStatus, setFStatus] = useState<'all'|UserStatus>('all')
|
||||
const [fRole, setFRole] = useState<'all'|UserRole>('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
// Modal state
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return allUsers.filter(u => {
|
||||
const firstName = u.first_name || ''
|
||||
const lastName = u.last_name || ''
|
||||
const companyName = u.company_name || ''
|
||||
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
||||
|
||||
// Use backend status directly for filtering
|
||||
const allowedStatuses: UserStatus[] = ['pending','active','suspended','inactive','archived']
|
||||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
||||
|
||||
return (
|
||||
(fType === 'all' || u.user_type === fType) &&
|
||||
(fStatus === 'all' || userStatus === fStatus) &&
|
||||
(fRole === 'all' || u.role === fRole) &&
|
||||
(
|
||||
!search.trim() ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
fullName.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
)
|
||||
})
|
||||
}, [allUsers, search, fType, fStatus, fRole])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||
const current = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
|
||||
// Move stats calculation above all conditional returns to avoid hook order errors
|
||||
const stats = useMemo(() => ({
|
||||
total: allUsers.length,
|
||||
admins: allUsers.filter(u => u.role === 'admin').length,
|
||||
personal: allUsers.filter(u => u.user_type === 'personal').length,
|
||||
company: allUsers.filter(u => u.user_type === 'company').length,
|
||||
active: allUsers.filter(u => u.status === 'active').length,
|
||||
pending: allUsers.filter(u => u.status === 'pending').length,
|
||||
}), [allUsers])
|
||||
|
||||
// Show loading during SSR/initial client render
|
||||
if (!isClient) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||||
<p className="text-blue-900">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Access check (only after client-side hydration)
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
||||
<div className="text-center">
|
||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
|
||||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const applyFilter = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// NEW: CSV export utilities (exports all filtered results, not only current page)
|
||||
const toCsvValue = (v: unknown) => {
|
||||
if (v === null || v === undefined) return '""'
|
||||
const s = String(v).replace(/"/g, '""')
|
||||
return `"${s}"`
|
||||
}
|
||||
|
||||
const exportCsv = () => {
|
||||
const headers = [
|
||||
'ID','Email','Type','Role','Status','Admin Verified',
|
||||
'First Name','Last Name','Company Name','Created At','Last Login At'
|
||||
]
|
||||
const rows = filtered.map(u => {
|
||||
// Use backend status directly
|
||||
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
|
||||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
||||
return [
|
||||
u.id,
|
||||
u.email,
|
||||
u.user_type,
|
||||
u.role,
|
||||
userStatus,
|
||||
u.is_admin_verified === 1 ? 'yes' : 'no',
|
||||
u.first_name || '',
|
||||
u.last_name || '',
|
||||
u.company_name || '',
|
||||
new Date(u.created_at).toISOString(),
|
||||
u.last_login_at ? new Date(u.last_login_at).toISOString() : ''
|
||||
].map(toCsvValue).join(',')
|
||||
})
|
||||
const csv = [headers.join(','), ...rows].join('\r\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `users_${new Date().toISOString().slice(0,10)}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const badge = (text: string, color: 'blue'|'amber'|'green'|'gray'|'rose'|'indigo'|'purple') => {
|
||||
const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide'
|
||||
const map: Record<string,string> = {
|
||||
blue: 'bg-blue-100 text-blue-700',
|
||||
amber: 'bg-amber-100 text-amber-700',
|
||||
green: 'bg-green-100 text-green-700',
|
||||
gray: 'bg-gray-100 text-gray-700',
|
||||
rose: 'bg-rose-100 text-rose-700',
|
||||
indigo: 'bg-indigo-100 text-indigo-700',
|
||||
purple: 'bg-purple-100 text-purple-700'
|
||||
}
|
||||
return <span className={`${base} ${map[color]}`}>{text}</span>
|
||||
}
|
||||
|
||||
const statusBadge = (s: UserStatus) =>
|
||||
s==='active' ? badge('Active','green')
|
||||
: s==='pending' ? badge('Pending','amber')
|
||||
: s==='suspended' ? badge('Suspended','rose')
|
||||
: s==='archived' ? badge('Archived','gray')
|
||||
: s==='inactive' ? badge('Inactive','gray')
|
||||
: badge('Unknown','gray')
|
||||
|
||||
const typeBadge = (t: UserType) =>
|
||||
t==='personal' ? badge('Personal','blue') : badge('Company','purple')
|
||||
|
||||
const roleBadge = (r: UserRole) =>
|
||||
r==='admin' ? badge('Admin','indigo') : badge('User','gray')
|
||||
|
||||
// Action handler for opening edit modal
|
||||
const onEdit = (id: string) => {
|
||||
setSelectedUserId(id)
|
||||
setIsDetailModalOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">User Management</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">
|
||||
Manage all users, view statistics, and handle verification.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Statistic Section + Verify Button */}
|
||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center gap-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6 flex-1">
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Total Users</div>
|
||||
<div className="text-xl font-semibold text-blue-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Admins</div>
|
||||
<div className="text-xl font-semibold text-indigo-700">{stats.admins}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Personal</div>
|
||||
<div className="text-xl font-semibold text-blue-700">{stats.personal}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Company</div>
|
||||
<div className="text-xl font-semibold text-purple-700">{stats.company}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Active</div>
|
||||
<div className="text-xl font-semibold text-green-700">{stats.active}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||
<div className="text-xs text-gray-500">Pending</div>
|
||||
<div className="text-xl font-semibold text-amber-700">{stats.pending}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-amber-100 hover:bg-amber-200 border border-amber-200 text-amber-800 text-base font-semibold px-5 py-3 shadow transition"
|
||||
onClick={() => window.location.href = '/admin/user-verify'}
|
||||
>
|
||||
Go to User Verification
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold">Error loading users</p>
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
<button
|
||||
onClick={fetchAllUsers}
|
||||
className="mt-2 text-sm underline hover:no-underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Card */}
|
||||
<form
|
||||
onSubmit={applyFilter}
|
||||
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-blue-900">
|
||||
Search & Filter Users
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
||||
{/* Search */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="sr-only">Search</label>
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Email, name, company..."
|
||||
className="w-full rounded-lg border border-gray-300 pl-10 pr-3 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Type */}
|
||||
<div>
|
||||
<select
|
||||
value={fType}
|
||||
onChange={e => setFType(e.target.value as any)}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Status */}
|
||||
<div>
|
||||
<select
|
||||
value={fStatus}
|
||||
onChange={e => setFStatus(e.target.value as any)}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
{STATUSES.map(s => <option key={s} value={s}>{s[0].toUpperCase()+s.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{/* Role */}
|
||||
<div>
|
||||
<select
|
||||
value={fRole}
|
||||
onChange={e => setFRole(e.target.value as any)}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
{ROLES.map(r => <option key={r} value={r}>{r[0].toUpperCase()+r.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportCsv}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-blue-900 text-sm font-semibold px-5 py-3 shadow transition"
|
||||
title="Export all filtered users to CSV"
|
||||
>
|
||||
Export all users as CSV
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 text-sm font-semibold px-5 py-3 shadow transition"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
|
||||
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
|
||||
<div className="text-lg font-semibold text-blue-900">
|
||||
All Users
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Showing {current.length} of {filtered.length} users
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-100 text-sm">
|
||||
<thead className="bg-blue-50 text-blue-900 font-medium">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">User</th>
|
||||
<th className="px-4 py-3 text-left">Type</th>
|
||||
<th className="px-4 py-3 text-left">Status</th>
|
||||
<th className="px-4 py-3 text-left">Role</th>
|
||||
<th className="px-4 py-3 text-left">Created</th>
|
||||
<th className="px-4 py-3 text-left">Last Login</th>
|
||||
<th className="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
||||
<span className="text-sm text-blue-900">Loading users...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : current.map(u => {
|
||||
const displayName = u.user_type === 'company'
|
||||
? u.company_name || 'Unknown Company'
|
||||
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
|
||||
|
||||
const initials = u.user_type === 'company'
|
||||
? (u.company_name?.[0] || 'C').toUpperCase()
|
||||
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
||||
|
||||
// Use backend status directly for display to avoid desync
|
||||
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
|
||||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
||||
|
||||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
||||
const lastLoginDate = u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'
|
||||
|
||||
return (
|
||||
<tr key={u.id} className="hover:bg-blue-50">
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-900 to-blue-700 text-white text-xs font-semibold shadow">
|
||||
{initials}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-blue-900 leading-tight">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-[11px] text-blue-700">
|
||||
{u.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">{typeBadge(u.user_type)}</td>
|
||||
<td className="px-4 py-4">{statusBadge(userStatus)}</td>
|
||||
<td className="px-4 py-4">{roleBadge(u.role)}</td>
|
||||
<td className="px-4 py-4 text-blue-900">{createdDate}</td>
|
||||
<td className="px-4 py-4 text-blue-700 italic">
|
||||
{lastLoginDate}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onEdit(u.id.toString())}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-900 px-3 py-2 text-xs font-medium transition"
|
||||
>
|
||||
<PencilSquareIcon className="h-4 w-4" /> Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{current.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
|
||||
No users match current filters.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-8 py-6 bg-blue-50 border-t border-blue-100">
|
||||
<div className="text-xs text-blue-700">
|
||||
Page {page} of {totalPages} ({filtered.length} total users)
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={page===1}
|
||||
onClick={() => setPage(p => Math.max(1,p-1))}
|
||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
‹ Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={page===totalPages}
|
||||
onClick={() => setPage(p => Math.min(totalPages,p+1))}
|
||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* User Detail Modal */}
|
||||
<UserDetailModal
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => {
|
||||
setIsDetailModalOpen(false)
|
||||
setSelectedUserId(null)
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onUserUpdated={fetchAllUsers}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
396
src/app/admin/user-verify/page.tsx
Normal file
@ -0,0 +1,396 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import PageLayout from '../../components/PageLayout'
|
||||
import UserDetailModal from '../../components/UserDetailModal'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ExclamationTriangleIcon,
|
||||
EyeIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
||||
import { PendingUser } from '../../utils/api'
|
||||
|
||||
type UserType = 'personal' | 'company'
|
||||
type UserRole = 'user' | 'admin'
|
||||
type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
||||
type StatusFilter = 'all' | 'pending' | 'verifying' | 'active'
|
||||
|
||||
export default function AdminUserVerifyPage() {
|
||||
const {
|
||||
pendingUsers,
|
||||
loading,
|
||||
error,
|
||||
isAdmin,
|
||||
fetchPendingUsers
|
||||
} = useAdminUsers()
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
// Handle client-side mounting
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
const [search, setSearch] = useState('')
|
||||
const [fType, setFType] = useState<'all' | UserType>('all')
|
||||
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
||||
const [fReady, setFReady] = useState<VerificationReadyFilter>('all')
|
||||
const [fStatus, setFStatus] = useState<StatusFilter>('all')
|
||||
const [perPage, setPerPage] = useState(10)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// All computations must be after hooks but before conditional returns
|
||||
const filtered = useMemo(() => {
|
||||
return pendingUsers.filter(u => {
|
||||
const firstName = u.first_name || ''
|
||||
const lastName = u.last_name || ''
|
||||
const companyName = u.company_name || ''
|
||||
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
||||
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
|
||||
u.documents_uploaded === 1 && u.contract_signed === 1
|
||||
|
||||
return (
|
||||
(fType === 'all' || u.user_type === fType) &&
|
||||
(fRole === 'all' || u.role === fRole) &&
|
||||
(fStatus === 'all' || u.status === fStatus) &&
|
||||
(
|
||||
fReady === 'all' ||
|
||||
(fReady === 'ready' && isReadyToVerify) ||
|
||||
(fReady === 'not_ready' && !isReadyToVerify)
|
||||
) &&
|
||||
(
|
||||
!search.trim() ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
fullName.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
)
|
||||
})
|
||||
}, [pendingUsers, search, fType, fRole, fReady, fStatus])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
|
||||
const current = filtered.slice((page - 1) * perPage, page * perPage)
|
||||
|
||||
// Modal state
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||
|
||||
const applyFilters = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const badge = (text: string, color: string) =>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${color}`}>
|
||||
{text}
|
||||
</span>
|
||||
|
||||
const typeBadge = (t: UserType) =>
|
||||
t === 'personal'
|
||||
? badge('Personal', 'bg-blue-100 text-blue-700')
|
||||
: badge('Company', 'bg-purple-100 text-purple-700')
|
||||
|
||||
const roleBadge = (r: UserRole) =>
|
||||
r === 'admin'
|
||||
? badge('Admin', 'bg-indigo-100 text-indigo-700')
|
||||
: badge('User', 'bg-gray-100 text-gray-700')
|
||||
|
||||
const statusBadge = (s: PendingUser['status']) => {
|
||||
if (s === 'pending') return badge('Pending', 'bg-amber-100 text-amber-700')
|
||||
if (s === 'verifying') return badge('Verifying', 'bg-blue-100 text-blue-700')
|
||||
return badge('Active', 'bg-green-100 text-green-700')
|
||||
}
|
||||
|
||||
const verificationStatusBadge = (user: PendingUser) => {
|
||||
const steps = [
|
||||
{ name: 'Email', completed: user.email_verified === 1 },
|
||||
{ name: 'Profile', completed: user.profile_completed === 1 },
|
||||
{ name: 'Documents', completed: user.documents_uploaded === 1 },
|
||||
{ name: 'Contract', completed: user.contract_signed === 1 }
|
||||
]
|
||||
|
||||
const completedSteps = steps.filter(s => s.completed).length
|
||||
const totalSteps = steps.length
|
||||
|
||||
if (completedSteps === totalSteps) {
|
||||
return badge('Ready to Verify', 'bg-green-100 text-green-700')
|
||||
} else {
|
||||
return badge(`${completedSteps}/${totalSteps} Steps`, 'bg-gray-100 text-gray-700')
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading during SSR/initial client render
|
||||
if (!isClient) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||||
<p className="text-blue-900">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Access check (only after client-side hydration)
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
||||
<div className="text-center">
|
||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
|
||||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">User Verification Center</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">
|
||||
Review and verify all users who need admin approval. Users must complete all steps before verification.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold">Error loading data</p>
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
<button
|
||||
onClick={fetchPendingUsers}
|
||||
className="mt-2 text-sm underline hover:no-underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Card */}
|
||||
<form
|
||||
onSubmit={applyFilters}
|
||||
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-blue-900">
|
||||
Search & Filter Pending Users
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-7 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Search</label>
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Email, name, company..."
|
||||
className="w-full rounded-lg border border-gray-300 pl-10 pr-3 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-blue-900 mb-1">User Type</label>
|
||||
<select
|
||||
value={fType}
|
||||
onChange={e => { setFType(e.target.value as any); setPage(1) }}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Role</label>
|
||||
<select
|
||||
value={fRole}
|
||||
onChange={e => { setFRole(e.target.value as any); setPage(1) }}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Verification Readiness</label>
|
||||
<select
|
||||
value={fReady}
|
||||
onChange={e => { setFReady(e.target.value as VerificationReadyFilter); setPage(1) }}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
>
|
||||
<option value="all">All Readiness</option>
|
||||
<option value="ready">Ready to Verify</option>
|
||||
<option value="not_ready">Not Ready</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Status</label>
|
||||
<select
|
||||
value={fStatus}
|
||||
onChange={e => { setFStatus(e.target.value as StatusFilter); setPage(1) }}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="verifying">Verifying</option>
|
||||
<option value="active">Active</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Rows per page</label>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Pending Users Table */}
|
||||
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
|
||||
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
|
||||
<div className="text-lg font-semibold text-blue-900">
|
||||
Users Pending Verification
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Showing {current.length} of {filtered.length} users
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-100 text-sm">
|
||||
<thead className="bg-blue-50 text-blue-900 font-medium">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">User</th>
|
||||
<th className="px-4 py-3 text-left">Type</th>
|
||||
<th className="px-4 py-3 text-left">Progress</th>
|
||||
<th className="px-4 py-3 text-left">Status</th>
|
||||
<th className="px-4 py-3 text-left">Role</th>
|
||||
<th className="px-4 py-3 text-left">Created</th>
|
||||
<th className="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
||||
<span className="text-sm text-blue-900">Loading users...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : current.map(u => {
|
||||
const displayName = u.user_type === 'company'
|
||||
? u.company_name || 'Unknown Company'
|
||||
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
|
||||
|
||||
const initials = u.user_type === 'company'
|
||||
? (u.company_name?.[0] || 'C').toUpperCase()
|
||||
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
||||
|
||||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
||||
|
||||
return (
|
||||
<tr key={u.id} className="hover:bg-blue-50">
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-900 to-blue-700 text-white text-xs font-semibold shadow">
|
||||
{initials}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-blue-900 leading-tight">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-[11px] text-blue-700">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">{typeBadge(u.user_type)}</td>
|
||||
<td className="px-4 py-4">{verificationStatusBadge(u)}</td>
|
||||
<td className="px-4 py-4">{statusBadge(u.status)}</td>
|
||||
<td className="px-4 py-4">{roleBadge(u.role)}</td>
|
||||
<td className="px-4 py-4 text-blue-900">{createdDate}</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUserId(u.id.toString())
|
||||
setIsDetailModalOpen(true)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-900 px-3 py-2 text-xs font-medium transition"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" /> View
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{current.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
|
||||
No unverified users match current filters.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-8 py-6 bg-blue-50 border-t border-blue-100">
|
||||
<div className="text-xs text-blue-700">
|
||||
Page {page} of {totalPages} ({filtered.length} pending users)
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
‹ Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={page === totalPages}
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* User Detail Modal */}
|
||||
<UserDetailModal
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => {
|
||||
setIsDetailModalOpen(false)
|
||||
setSelectedUserId(null)
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onUserUpdated={() => {
|
||||
fetchPendingUsers()
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
217
src/app/affiliate-links/page.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
import Waves from '../components/background/waves'
|
||||
|
||||
type Affiliate = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
logoUrl?: string
|
||||
category: string
|
||||
commissionRate?: string
|
||||
}
|
||||
|
||||
// Fallback placeholder image
|
||||
const PLACEHOLDER_IMAGE = 'https://images.unsplash.com/photo-1557804506-669a67965ba0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80'
|
||||
|
||||
export default function AffiliateLinksPage() {
|
||||
const [affiliates, setAffiliates] = useState<Affiliate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
// NEW: selected category
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAffiliates() {
|
||||
try {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const res = await fetch(`${BASE_URL}/api/affiliates/active`)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch affiliates')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const activeAffiliates = data.data || []
|
||||
|
||||
setAffiliates(activeAffiliates.map((item: any) => ({
|
||||
id: String(item.id),
|
||||
name: String(item.name || 'Partner'),
|
||||
description: String(item.description || ''),
|
||||
url: String(item.url || '#'),
|
||||
logoUrl: item.logoUrl || PLACEHOLDER_IMAGE,
|
||||
category: String(item.category || 'Other'),
|
||||
commissionRate: item.commission_rate ? String(item.commission_rate) : undefined
|
||||
})))
|
||||
} catch (err) {
|
||||
console.error('Error loading affiliates:', err)
|
||||
setError('Failed to load affiliate partners')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAffiliates()
|
||||
}, [])
|
||||
|
||||
const posts = affiliates.map(affiliate => ({
|
||||
id: affiliate.id,
|
||||
title: affiliate.name,
|
||||
href: affiliate.url,
|
||||
description: affiliate.description,
|
||||
imageUrl: affiliate.logoUrl || PLACEHOLDER_IMAGE,
|
||||
category: { title: affiliate.category, href: '#' },
|
||||
commissionRate: affiliate.commissionRate
|
||||
}))
|
||||
|
||||
// NEW: fixed categories from the provided image, merged with backend ones
|
||||
const categories = useMemo(() => {
|
||||
const fromImage = [
|
||||
'Technology',
|
||||
'Energy',
|
||||
'Finance',
|
||||
'Healthcare',
|
||||
'Education',
|
||||
'Travel',
|
||||
'Retail',
|
||||
'Construction',
|
||||
'Food',
|
||||
'Automotive',
|
||||
'Fashion',
|
||||
'Pets',
|
||||
]
|
||||
const set = new Set<string>(fromImage)
|
||||
affiliates.forEach(a => { if (a.category) set.add(a.category) })
|
||||
return ['all', ...Array.from(set)]
|
||||
}, [affiliates])
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div
|
||||
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
||||
style={{ backgroundImage: 'none', background: 'none' }}
|
||||
>
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
backgroundColor="rgba(245, 245, 240, 1)"
|
||||
waveSpeedX={0.02}
|
||||
waveSpeedY={0.01}
|
||||
waveAmpX={40}
|
||||
waveAmpY={20}
|
||||
friction={0.9}
|
||||
tension={0.01}
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 min-h-screen flex flex-col">
|
||||
<main className="flex-1 max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8 w-full">
|
||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
||||
{/* Header (aligned with management pages) */}
|
||||
<header className="flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Affiliate Partners</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">
|
||||
Discover our trusted partners and earn commissions through affiliate links.
|
||||
</p>
|
||||
</div>
|
||||
{/* NEW: Category filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-blue-900 font-medium">Filter by category:</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="rounded-md border border-blue-200 bg-white px-3 py-1.5 text-sm text-blue-900 shadow-sm"
|
||||
>
|
||||
{categories.map(c => (
|
||||
<option key={c} value={c}>{c === 'all' ? 'All' : c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* States */}
|
||||
{loading && (
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-b-transparent" />
|
||||
<p className="mt-4 text-sm text-gray-600">Loading affiliate partners...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="mx-auto max-w-2xl rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && posts.length === 0 && (
|
||||
<div className="mx-auto max-w-2xl text-center text-sm text-gray-600">
|
||||
No affiliate partners available at the moment.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cards (aligned to white panels, border, shadow) */}
|
||||
{!loading && !error && posts.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{posts.map((post) => {
|
||||
// NEW: highlight when matches selected category (keep all visible)
|
||||
const isHighlighted = selectedCategory !== 'all' && post.category.title === selectedCategory
|
||||
return (
|
||||
<article
|
||||
key={post.id}
|
||||
className={`rounded-2xl bg-white border shadow-lg overflow-hidden flex flex-col transition
|
||||
${isHighlighted ? 'border-2 border-indigo-400 ring-2 ring-indigo-200' : 'border-gray-100'}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<img alt="" src={post.imageUrl} className="aspect-video w-full object-cover" />
|
||||
</div>
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-xl font-semibold text-blue-900">{post.title}</h3>
|
||||
{post.commissionRate && (
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border border-indigo-200 bg-indigo-50 text-indigo-700">
|
||||
{post.commissionRate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<a
|
||||
href={post.category.href}
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 border text-blue-900
|
||||
${isHighlighted ? 'border-indigo-300 bg-indigo-50' : 'border-blue-200 bg-blue-50'}`}
|
||||
>
|
||||
{post.category.title}
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-700 line-clamp-4">{post.description}</p>
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<a
|
||||
href={post.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow transition"
|
||||
>
|
||||
Visit Affiliate Link
|
||||
</a>
|
||||
<span className="text-[11px] text-gray-500">
|
||||
External partner website.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
102
src/app/api/login/route.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function getSharedCookieDomain(req: NextRequest): string | undefined {
|
||||
const host = req.headers.get('host') ?? ''
|
||||
return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined
|
||||
}
|
||||
|
||||
function splitSetCookieHeader(header: string): string[] {
|
||||
const parts: string[] = []
|
||||
let start = 0
|
||||
let inExpires = false
|
||||
for (let i = 0; i < header.length; i++) {
|
||||
const lower = header.slice(i, i + 8).toLowerCase()
|
||||
if (lower === 'expires=') inExpires = true
|
||||
if (inExpires && header[i] === ';') inExpires = false
|
||||
if (!inExpires && header[i] === ',') {
|
||||
parts.push(header.slice(start, i).trim())
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
const last = header.slice(start).trim()
|
||||
if (last) parts.push(last)
|
||||
return parts
|
||||
}
|
||||
|
||||
function readSetCookies(res: Response): string[] {
|
||||
const anyHeaders = res.headers as any
|
||||
if (typeof anyHeaders.getSetCookie === 'function') return anyHeaders.getSetCookie()
|
||||
const single = res.headers.get('set-cookie')
|
||||
return single ? splitSetCookieHeader(single) : []
|
||||
}
|
||||
|
||||
function parseRefreshCookie(setCookie: string) {
|
||||
const m = setCookie.match(/^refreshToken=([^;]*)/i)
|
||||
if (!m) return null
|
||||
const value = m[1] ?? ''
|
||||
|
||||
const maxAgeMatch = setCookie.match(/;\s*max-age=(\d+)/i)
|
||||
const expiresMatch = setCookie.match(/;\s*expires=([^;]+)/i)
|
||||
const sameSiteMatch = setCookie.match(/;\s*samesite=(lax|strict|none)/i)
|
||||
|
||||
return {
|
||||
value,
|
||||
maxAge: maxAgeMatch ? Number(maxAgeMatch[1]) : undefined,
|
||||
expires: expiresMatch ? new Date(expiresMatch[1]) : undefined,
|
||||
sameSite: (sameSiteMatch?.[1]?.toLowerCase() as 'lax' | 'strict' | 'none' | undefined) ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
if (!apiBase) return NextResponse.json({ message: 'Missing NEXT_PUBLIC_API_BASE_URL' }, { status: 500 })
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
|
||||
const apiRes = await fetch(`${apiBase}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await apiRes.json().catch(() => null)
|
||||
const out = NextResponse.json(data, { status: apiRes.status })
|
||||
|
||||
const setCookies = readSetCookies(apiRes)
|
||||
const refreshSetCookie = setCookies.find((c) => /^refreshToken=/i.test(c))
|
||||
if (refreshSetCookie) {
|
||||
const parsed = parseRefreshCookie(refreshSetCookie)
|
||||
if (parsed?.value) {
|
||||
// Clear host-only duplicates first.
|
||||
out.cookies.set('refreshToken', '', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
})
|
||||
out.cookies.set('__Secure-refreshToken', '', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
})
|
||||
|
||||
out.cookies.set('refreshToken', parsed.value, {
|
||||
domain: getSharedCookieDomain(req),
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: parsed.sameSite ?? 'lax',
|
||||
...(parsed.maxAge !== undefined ? { maxAge: parsed.maxAge } : {}),
|
||||
...(parsed.maxAge === undefined && parsed.expires ? { expires: parsed.expires } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
47
src/app/api/logout/route.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function getSharedCookieDomain(req: NextRequest): string | undefined {
|
||||
const host = req.headers.get('host') ?? ''
|
||||
return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
const cookie = req.headers.get('cookie') ?? ''
|
||||
|
||||
// Best-effort: tell backend to revoke refresh token (if endpoint exists)
|
||||
let data: any = { success: true }
|
||||
let status = 200
|
||||
if (apiBase) {
|
||||
const apiRes = await fetch(`${apiBase}/api/logout`, {
|
||||
method: 'POST',
|
||||
headers: { cookie, 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
}).catch(() => null)
|
||||
|
||||
if (apiRes) {
|
||||
status = apiRes.status
|
||||
data = await apiRes.json().catch(() => data)
|
||||
}
|
||||
}
|
||||
|
||||
const out = NextResponse.json(data, { status })
|
||||
|
||||
// Clear host-only variants
|
||||
out.cookies.set('refreshToken', '', { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 0 })
|
||||
out.cookies.set('__Secure-refreshToken', '', { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 0 })
|
||||
|
||||
// Clear shared-domain variant
|
||||
out.cookies.set('refreshToken', '', {
|
||||
domain: getSharedCookieDomain(req),
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
116
src/app/api/refresh/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function getSharedCookieDomain(req: NextRequest): string | undefined {
|
||||
const host = req.headers.get('host') ?? ''
|
||||
return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined
|
||||
}
|
||||
|
||||
function splitSetCookieHeader(header: string): string[] {
|
||||
// Handles "Expires=Wed, 21 Oct ..." commas.
|
||||
const parts: string[] = []
|
||||
let start = 0
|
||||
let inExpires = false
|
||||
for (let i = 0; i < header.length; i++) {
|
||||
const lower = header.slice(i, i + 8).toLowerCase()
|
||||
if (lower === 'expires=') inExpires = true
|
||||
if (inExpires && header[i] === ';') inExpires = false
|
||||
if (!inExpires && header[i] === ',') {
|
||||
parts.push(header.slice(start, i).trim())
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
const last = header.slice(start).trim()
|
||||
if (last) parts.push(last)
|
||||
return parts
|
||||
}
|
||||
|
||||
function readSetCookies(res: Response): string[] {
|
||||
const anyHeaders = res.headers as any
|
||||
if (typeof anyHeaders.getSetCookie === 'function') return anyHeaders.getSetCookie()
|
||||
const single = res.headers.get('set-cookie')
|
||||
return single ? splitSetCookieHeader(single) : []
|
||||
}
|
||||
|
||||
function parseRefreshCookie(setCookie: string) {
|
||||
const m = setCookie.match(/^refreshToken=([^;]*)/i)
|
||||
if (!m) return null
|
||||
const value = m[1] ?? ''
|
||||
|
||||
const maxAgeMatch = setCookie.match(/;\s*max-age=(\d+)/i)
|
||||
const expiresMatch = setCookie.match(/;\s*expires=([^;]+)/i)
|
||||
const sameSiteMatch = setCookie.match(/;\s*samesite=(lax|strict|none)/i)
|
||||
|
||||
return {
|
||||
value,
|
||||
maxAge: maxAgeMatch ? Number(maxAgeMatch[1]) : undefined,
|
||||
expires: expiresMatch ? new Date(expiresMatch[1]) : undefined,
|
||||
sameSite: (sameSiteMatch?.[1]?.toLowerCase() as 'lax' | 'strict' | 'none' | undefined) ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
if (!apiBase) return NextResponse.json({ message: 'Missing NEXT_PUBLIC_API_BASE_URL' }, { status: 500 })
|
||||
|
||||
// Prefer a single parsed cookie value to avoid "refreshToken=old; refreshToken=new" ambiguity.
|
||||
const rt =
|
||||
req.cookies.get('refreshToken')?.value ??
|
||||
req.cookies.get('__Secure-refreshToken')?.value ??
|
||||
''
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (rt) {
|
||||
headers.cookie = `refreshToken=${rt}`
|
||||
} else {
|
||||
// fallback (best-effort)
|
||||
const cookie = req.headers.get('cookie') ?? ''
|
||||
if (cookie) headers.cookie = cookie
|
||||
}
|
||||
|
||||
const apiRes = await fetch(`${apiBase}/api/refresh`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const data = await apiRes.json().catch(() => null)
|
||||
const out = NextResponse.json(data, { status: apiRes.status })
|
||||
|
||||
const setCookies = readSetCookies(apiRes)
|
||||
const refreshSetCookie = setCookies.find((c) => /^refreshToken=/i.test(c))
|
||||
if (refreshSetCookie) {
|
||||
const parsed = parseRefreshCookie(refreshSetCookie)
|
||||
if (parsed?.value) {
|
||||
// Clear any host-only variants on the frontend host to prevent duplicates.
|
||||
out.cookies.set('refreshToken', '', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
})
|
||||
out.cookies.set('__Secure-refreshToken', '', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
})
|
||||
|
||||
// Set the shared-domain cookie used across subdomains.
|
||||
out.cookies.set('refreshToken', parsed.value, {
|
||||
domain: getSharedCookieDomain(req),
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: parsed.sameSite ?? 'lax',
|
||||
...(parsed.maxAge !== undefined ? { maxAge: parsed.maxAge } : {}),
|
||||
...(parsed.maxAge === undefined && parsed.expires ? { expires: parsed.expires } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
88
src/app/coffee-abonnements/hooks/getActiveCoffees.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { authFetch } from '../../utils/authFetch';
|
||||
|
||||
export type ActiveCoffee = {
|
||||
id: string | number;
|
||||
title: string;
|
||||
description: string;
|
||||
price: string | number; // price can be a string or number
|
||||
pictureUrl?: string;
|
||||
state: number; // 1 for active, 0 for inactive
|
||||
};
|
||||
|
||||
export type CoffeeItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
pricePer10: number; // price for 10 pieces
|
||||
image: string;
|
||||
};
|
||||
|
||||
export function useActiveCoffees() {
|
||||
const [coffees, setCoffees] = useState<CoffeeItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
|
||||
const url = `${base}/api/admin/coffee/active`;
|
||||
|
||||
console.log('[useActiveCoffees] Fetching active coffees from:', url);
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
authFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(async (response) => {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
console.log('[useActiveCoffees] Response status:', response.status);
|
||||
console.log('[useActiveCoffees] Response content-type:', contentType);
|
||||
|
||||
if (!response.ok || !contentType.includes('application/json')) {
|
||||
const text = await response.text().catch(() => '');
|
||||
console.warn('[useActiveCoffees] Non-JSON response or error body:', text.slice(0, 200));
|
||||
throw new Error(`Request failed: ${response.status} ${text.slice(0, 160)}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
console.log('[useActiveCoffees] Raw JSON response:', json);
|
||||
|
||||
const data: ActiveCoffee[] =
|
||||
Array.isArray(json?.data) ? json.data :
|
||||
Array.isArray(json) ? json :
|
||||
[]
|
||||
console.log('[useActiveCoffees] Parsed coffee data:', data);
|
||||
|
||||
const mapped: CoffeeItem[] = data
|
||||
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
|
||||
.map((coffee) => {
|
||||
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price
|
||||
return {
|
||||
id: String(coffee.id),
|
||||
name: coffee.title || `Coffee ${coffee.id}`,
|
||||
description: coffee.description || '',
|
||||
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
|
||||
image: coffee.pictureUrl || '',
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[useActiveCoffees] Mapped coffee items:', mapped)
|
||||
setCoffees(mapped)
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('[useActiveCoffees] Error fetching coffees:', error);
|
||||
setError(error?.message || 'Failed to load active coffees');
|
||||
setCoffees([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
console.log('[useActiveCoffees] Fetch complete. Loading state:', false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { coffees, loading, error };
|
||||
}
|
||||
307
src/app/coffee-abonnements/page.tsx
Normal file
@ -0,0 +1,307 @@
|
||||
'use client';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import PageLayout from '../components/PageLayout';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useActiveCoffees } from './hooks/getActiveCoffees';
|
||||
|
||||
export default function CoffeeAbonnementPage() {
|
||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||
const [bump, setBump] = useState<Record<string, boolean>>({});
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch active coffees from the backend
|
||||
const { coffees, loading, error } = useActiveCoffees();
|
||||
|
||||
const selectedEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(selections).map(([id, qty]) => {
|
||||
const coffee = coffees.find((c) => c.id === id);
|
||||
if (!coffee) return null;
|
||||
return { coffee, quantity: qty };
|
||||
}).filter(Boolean) as { coffee: ReturnType<typeof useActiveCoffees>['coffees'][number]; quantity: number }[],
|
||||
[selections, coffees]
|
||||
);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() =>
|
||||
selectedEntries.reduce(
|
||||
(sum, entry) => sum + (entry.quantity / 10) * entry.coffee.pricePer10,
|
||||
0
|
||||
),
|
||||
[selectedEntries]
|
||||
);
|
||||
|
||||
// NEW: enforce exactly 120 capsules (12 packs)
|
||||
const totalCapsules = useMemo(
|
||||
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
|
||||
[selectedEntries]
|
||||
);
|
||||
const packsSelected = totalCapsules / 10;
|
||||
const canProceed = packsSelected === 12; // CHANGED: require exactly 12 packs
|
||||
|
||||
const TAX_RATE = 0.07;
|
||||
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
|
||||
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
|
||||
|
||||
const proceedToSummary = () => {
|
||||
if (!canProceed) return;
|
||||
try {
|
||||
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
|
||||
} catch {}
|
||||
router.push('/coffee-abonnements/summary');
|
||||
};
|
||||
|
||||
const toggleCoffee = (id: string) => {
|
||||
setSelections((prev) => {
|
||||
const copy = { ...prev };
|
||||
if (id in copy) {
|
||||
delete copy[id];
|
||||
} else {
|
||||
copy[id] = 10;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
const changeQuantity = (id: string, delta: number) => {
|
||||
setSelections((prev) => {
|
||||
if (!(id in prev)) return prev;
|
||||
const next = prev[id] + delta;
|
||||
if (next < 10 || next > 120) return prev;
|
||||
const updated = { ...prev, [id]: next };
|
||||
setBump((b) => ({ ...b, [id]: true }));
|
||||
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="mx-auto max-w-7xl px-4 py-10 space-y-10 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
<span className="text-[#1C2B4A]">Configure Coffee Subscription</span>
|
||||
</h1>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">1</span>
|
||||
<span className="ml-2 font-medium">Selection</span>
|
||||
</div>
|
||||
<div className="h-px flex-1 bg-gray-200" />
|
||||
<div className="flex items-center opacity-60">
|
||||
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">2</span>
|
||||
<span className="ml-2 font-medium">Summary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">1. Choose coffees & quantities</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
||||
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
||||
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{coffees.map((coffee) => {
|
||||
const active = coffee.id in selections;
|
||||
const qty = selections[coffee.id] || 0;
|
||||
return (
|
||||
<div
|
||||
key={coffee.id}
|
||||
className={`group rounded-xl border p-4 shadow-sm transition ${
|
||||
active ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-md' : 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-md mb-3">
|
||||
{coffee.image ? (
|
||||
<img
|
||||
src={coffee.image}
|
||||
alt={coffee.name}
|
||||
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-36 w-full bg-gray-100 rounded-md" />
|
||||
)}
|
||||
{/* price badge (per 10) */}
|
||||
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
|
||||
<span
|
||||
aria-label={`Price €${coffee.pricePer10} per 10 capsules`}
|
||||
className={`relative inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm transition-transform group-hover:scale-105 ${
|
||||
active ? 'bg-[#1C2B4A]' : 'bg-[#1C2B4A]/80'
|
||||
}`}
|
||||
>
|
||||
€{coffee.pricePer10}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[#1C2B4A]/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">
|
||||
per 10 pcs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-semibold text-sm">{coffee.name}</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600 leading-relaxed">
|
||||
{coffee.description}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCoffee(coffee.id)}
|
||||
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
||||
active
|
||||
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
|
||||
: 'border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{active ? 'Remove' : 'Add'}
|
||||
</button>
|
||||
{active && (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-gray-500">Quantity (10–120)</span>
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full bg-[#1C2B4A] text-white px-3 py-1 text-xs font-semibold transition-transform duration-300 ${bump[coffee.id] ? 'scale-110' : 'scale-100'}`}
|
||||
>
|
||||
{qty} pcs
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => changeQuantity(coffee.id, -10)}
|
||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||
>
|
||||
-10
|
||||
</button>
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={120}
|
||||
step={10}
|
||||
value={qty}
|
||||
onChange={(e) =>
|
||||
changeQuantity(coffee.id, parseInt(e.target.value, 10) - qty)
|
||||
}
|
||||
className="w-full appearance-none cursor-pointer bg-transparent"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
|
||||
((qty - 10) / (120 - 10)) * 100 +
|
||||
'%,#e5e7eb ' +
|
||||
((qty - 10) / (120 - 10)) * 100 +
|
||||
'%,#e5e7eb 100%)',
|
||||
height: '6px',
|
||||
borderRadius: '999px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeQuantity(coffee.id, +10)}
|
||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||
>
|
||||
+10
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-500">
|
||||
<span>Subtotal</span>
|
||||
<span className="font-semibold text-gray-700">
|
||||
€{((qty / 10) * coffee.pricePer10).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section 2: Compact preview + next steps */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">2. Preview</h2>
|
||||
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
|
||||
{selectedEntries.length === 0 && (
|
||||
<p className="text-sm text-gray-600">No coffees selected yet.</p>
|
||||
)}
|
||||
{selectedEntries.map((entry) => (
|
||||
<div key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{entry.coffee.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{entry.quantity} Stk •{' '}
|
||||
<span className="inline-flex items-center font-semibold text-[#1C2B4A]">
|
||||
€{entry.coffee.pricePer10}/10
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right font-semibold">
|
||||
€{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between pt-2 border-t">
|
||||
<span className="text-sm font-semibold">Total (net)</span>
|
||||
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">
|
||||
€{totalPrice.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Packs/capsules summary and validation hint (refined design) */}
|
||||
<div className="text-xs text-gray-700">
|
||||
Selected: {totalCapsules} capsules ({packsSelected} packs of 10).
|
||||
{packsSelected !== 12 && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
|
||||
Please select exactly 120 capsules (12 packs).
|
||||
{packsSelected < 12 ? ` ${12 - packsSelected} packs missing.` : ` ${packsSelected - 12} packs too many.`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={proceedToSummary}
|
||||
disabled={!canProceed}
|
||||
className={`group w-full mt-2 rounded-lg px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
|
||||
canProceed
|
||||
? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg'
|
||||
: 'bg-gray-200 text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Next steps
|
||||
<svg
|
||||
className={`ml-2 h-5 w-5 transition-transform ${
|
||||
canProceed ? 'group-hover:translate-x-0.5' : ''
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{!canProceed && (
|
||||
<p className="text-xs text-gray-600">
|
||||
You can continue once exactly 120 capsules (12 packs) are selected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
92
src/app/coffee-abonnements/summary/hooks/getTaxRate.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { authFetch } from "../../../utils/authFetch";
|
||||
|
||||
interface VatRate {
|
||||
countryCode: string;
|
||||
standardRate: number;
|
||||
}
|
||||
|
||||
const normalizeVatRate = (rate: number | null | undefined): number | null => {
|
||||
if (rate == null || Number.isNaN(rate)) return null;
|
||||
return rate > 1 ? rate / 100 : rate;
|
||||
};
|
||||
|
||||
const toNumber = (v: any): number | null => {
|
||||
if (v == null) return null;
|
||||
const n = typeof v === 'string' ? Number(v) : (typeof v === 'number' ? v : Number(v));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
const getCode = (row: any): string => {
|
||||
const raw = row?.countryCode ?? row?.code ?? row?.country ?? row?.country_code;
|
||||
return typeof raw === 'string' ? raw.toUpperCase() : '';
|
||||
};
|
||||
|
||||
const getRateRaw = (row: any): number | null => {
|
||||
// support multiple field names and string numbers
|
||||
const raw = row?.standardRate ?? row?.rate ?? row?.ratePercent ?? row?.standard_rate;
|
||||
const num = toNumber(raw);
|
||||
return num;
|
||||
};
|
||||
|
||||
const getRateNormalized = (row: any): number | null => {
|
||||
const num = getRateRaw(row);
|
||||
return normalizeVatRate(num);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the standard VAT rate for a given ISO country code.
|
||||
* Returns null if not found or on any error.
|
||||
*/
|
||||
export async function getStandardVatRate(countryCode: string): Promise<number | null> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/tax/vat-rates`;
|
||||
console.info('[VAT] getStandardVatRate -> GET', url, { countryCode });
|
||||
const res = await authFetch(url, { method: "GET" });
|
||||
console.info('[VAT] getStandardVatRate status:', res.status);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const raw = await res.json().catch(() => null);
|
||||
const arr = Array.isArray(raw?.data) ? raw.data : (Array.isArray(raw) ? raw : []);
|
||||
console.info('[VAT] getStandardVatRate parsed length:', Array.isArray(arr) ? arr.length : 0);
|
||||
if (!Array.isArray(arr) || arr.length === 0) return null;
|
||||
|
||||
const upper = countryCode.toUpperCase();
|
||||
const match = arr.find((r: any) => getCode(r) === upper);
|
||||
const normalized = match ? getRateNormalized(match) : null;
|
||||
console.info('[VAT] getStandardVatRate match:', {
|
||||
upper,
|
||||
resolvedCode: match ? getCode(match) : null,
|
||||
rawRate: match ? getRateRaw(match) : null,
|
||||
normalized
|
||||
});
|
||||
return normalized;
|
||||
} catch (e) {
|
||||
console.error('[VAT] getStandardVatRate error:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type VatRateEntry = { code: string; rate: number | null }
|
||||
|
||||
export async function getVatRates(): Promise<VatRateEntry[]> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/tax/vat-rates`;
|
||||
console.info('[VAT] getVatRates -> GET', url);
|
||||
const res = await authFetch(url, { method: 'GET' });
|
||||
console.info('[VAT] getVatRates status:', res.status);
|
||||
if (!res.ok) return [];
|
||||
const raw = await res.json().catch(() => null);
|
||||
const arr = Array.isArray(raw?.data) ? raw.data : (Array.isArray(raw) ? raw : []);
|
||||
console.info('[VAT] getVatRates parsed length:', Array.isArray(arr) ? arr.length : 0);
|
||||
if (!Array.isArray(arr) || arr.length === 0) return [];
|
||||
const mapped = arr.map((r: any) => ({
|
||||
code: getCode(r),
|
||||
rate: getRateNormalized(r)
|
||||
})).filter((r: VatRateEntry) => !!r.code);
|
||||
console.info('[VAT] getVatRates mapped sample:', mapped.slice(0, 5));
|
||||
return mapped;
|
||||
} catch (e) {
|
||||
console.error('[VAT] getVatRates error:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
138
src/app/coffee-abonnements/summary/hooks/subscribeAbo.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
|
||||
export type SubscribeAboItem = { coffeeId: string | number; quantity?: number }
|
||||
export type SubscribeAboInput = {
|
||||
coffeeId?: string | number // optional when items provided
|
||||
items?: SubscribeAboItem[] // NEW: whole order in one call
|
||||
billing_interval?: string
|
||||
interval_count?: number
|
||||
is_auto_renew?: boolean
|
||||
target_user_id?: number
|
||||
recipient_name?: string
|
||||
recipient_email?: string
|
||||
recipient_notes?: string
|
||||
// NEW: customer fields
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
email?: string
|
||||
street?: string
|
||||
postalCode?: string
|
||||
city?: string
|
||||
country?: string
|
||||
frequency?: string
|
||||
startDate?: string
|
||||
// NEW: logged-in user id
|
||||
referred_by?: number
|
||||
}
|
||||
|
||||
type Abonement = any
|
||||
type HistoryEvent = any
|
||||
|
||||
const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
|
||||
const parseJson = async (res: Response) => {
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
const isJson = ct.includes('application/json')
|
||||
const json = isJson ? await res.json().catch(() => ({})) : null
|
||||
return { json, isJson }
|
||||
}
|
||||
|
||||
export async function subscribeAbo(input: SubscribeAboInput) {
|
||||
const hasItems = Array.isArray(input.items) && input.items.length > 0
|
||||
if (!hasItems && !input.coffeeId) throw new Error('coffeeId is required')
|
||||
|
||||
const hasRecipientFields = !!(input.recipient_name || input.recipient_email || input.recipient_notes)
|
||||
if (hasRecipientFields && !input.recipient_name) {
|
||||
throw new Error('recipient_name is required when gifting to a non-account recipient.')
|
||||
}
|
||||
|
||||
// NEW: validate customer fields (required in UI)
|
||||
const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency','startDate'] as const
|
||||
const missing = requiredFields.filter(k => {
|
||||
const v = (input as any)[k]
|
||||
return typeof v !== 'string' || v.trim() === ''
|
||||
})
|
||||
if (missing.length) {
|
||||
throw new Error(`Missing required fields: ${missing.join(', ')}`)
|
||||
}
|
||||
|
||||
const body: any = {
|
||||
billing_interval: input.billing_interval ?? 'month',
|
||||
interval_count: input.interval_count ?? 1,
|
||||
is_auto_renew: input.is_auto_renew ?? true,
|
||||
// NEW: include customer fields
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
email: input.email,
|
||||
street: input.street,
|
||||
postalCode: input.postalCode,
|
||||
city: input.city,
|
||||
country: input.country?.toUpperCase?.() ?? input.country,
|
||||
frequency: input.frequency,
|
||||
startDate: input.startDate,
|
||||
}
|
||||
if (hasItems) {
|
||||
body.items = input.items!.map(i => ({
|
||||
coffeeId: i.coffeeId,
|
||||
quantity: i.quantity != null ? i.quantity : 1,
|
||||
}))
|
||||
// NEW: enforce exactly 12 packs
|
||||
const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0)
|
||||
if (sumPacks !== 12) {
|
||||
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 12')
|
||||
throw new Error('Order must contain exactly 12 packs (120 capsules).')
|
||||
}
|
||||
} else {
|
||||
body.coffeeId = input.coffeeId
|
||||
// single-item legacy path — backend expects bundle, prefer items usage
|
||||
}
|
||||
// NEW: always include available recipient fields and target_user_id when provided
|
||||
if (input.target_user_id != null) body.target_user_id = input.target_user_id
|
||||
if (input.recipient_name) body.recipient_name = input.recipient_name
|
||||
if (input.recipient_email) body.recipient_email = input.recipient_email
|
||||
if (input.recipient_notes) body.recipient_notes = input.recipient_notes
|
||||
// NEW: always include referred_by if provided
|
||||
if (input.referred_by != null) body.referred_by = input.referred_by
|
||||
|
||||
const url = `${apiBase}/api/abonements/subscribe`
|
||||
console.info('[subscribeAbo] POST', url, { body })
|
||||
// NEW: explicit JSON preview that matches the actual request body
|
||||
console.info('[subscribeAbo] Body JSON:', JSON.stringify(body))
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'include',
|
||||
})
|
||||
const { json } = await parseJson(res)
|
||||
console.info('[subscribeAbo] Response', res.status, json)
|
||||
if (!res.ok || !json?.success) throw new Error(json?.message || `Subscribe failed: ${res.status}`)
|
||||
return json.data as Abonement
|
||||
}
|
||||
|
||||
async function postAction(url: string) {
|
||||
const res = await authFetch(url, { method: 'POST', headers: { Accept: 'application/json' }, credentials: 'include' })
|
||||
const { json } = await parseJson(res)
|
||||
if (!res.ok || !json?.success) throw new Error(json?.message || `Request failed: ${res.status}`)
|
||||
return json.data as Abonement
|
||||
}
|
||||
|
||||
export const pauseAbo = (id: string | number) => postAction(`${apiBase}/abonements/${id}/pause`)
|
||||
export const resumeAbo = (id: string | number) => postAction(`${apiBase}/abonements/${id}/resume`)
|
||||
export const cancelAbo = (id: string | number) => postAction(`${apiBase}/abonements/${id}/cancel`)
|
||||
export const renewAbo = (id: string | number) => postAction(`${apiBase}/admin/abonements/${id}/renew`)
|
||||
|
||||
export async function getMyAbonements(status?: string) {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : ''
|
||||
const res = await authFetch(`${apiBase}/abonements/mine${qs}`, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include' })
|
||||
const { json } = await parseJson(res)
|
||||
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
|
||||
return (json.data || []) as Abonement[]
|
||||
}
|
||||
|
||||
export async function getAboHistory(id: string | number) {
|
||||
const res = await authFetch(`${apiBase}/abonements/${id}/history`, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include' })
|
||||
const { json } = await parseJson(res)
|
||||
if (!res.ok || !json?.success) throw new Error(json?.message || `History failed: ${res.status}`)
|
||||
return (json.data || []) as HistoryEvent[]
|
||||
}
|
||||
413
src/app/coffee-abonnements/summary/page.tsx
Normal file
@ -0,0 +1,413 @@
|
||||
'use client';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import PageLayout from '../../components/PageLayout';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useActiveCoffees } from '../hooks/getActiveCoffees';
|
||||
import { getStandardVatRate, getVatRates } from './hooks/getTaxRate';
|
||||
import { subscribeAbo } from './hooks/subscribeAbo';
|
||||
import useAuthStore from '../../store/authStore'
|
||||
|
||||
export default function SummaryPage() {
|
||||
const router = useRouter();
|
||||
const { coffees, loading, error } = useActiveCoffees();
|
||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||
const [form, setForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
street: '',
|
||||
postalCode: '',
|
||||
city: '',
|
||||
country: 'DE',
|
||||
frequency: 'monatlich',
|
||||
startDate: ''
|
||||
});
|
||||
const [showThanks, setShowThanks] = useState(false);
|
||||
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
|
||||
const [taxRate, setTaxRate] = useState(0.07); // minimal fallback only
|
||||
const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('coffeeSelections');
|
||||
if (raw) setSelections(JSON.parse(raw));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showThanks) return;
|
||||
const items = Array.from({ length: 40 }).map(() => ({
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 0.6,
|
||||
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||
}));
|
||||
setConfetti(items);
|
||||
}, [showThanks]);
|
||||
|
||||
const selectedEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(selections)
|
||||
.map(([id, qty]) => {
|
||||
const coffee = coffees.find(c => c.id === id);
|
||||
return coffee ? { coffee, quantity: qty } : null;
|
||||
})
|
||||
.filter(Boolean) as { coffee: ReturnType<typeof useActiveCoffees>['coffees'][number]; quantity: number }[],
|
||||
[selections, coffees]
|
||||
);
|
||||
|
||||
// NEW: computed packs/capsules for validation
|
||||
const totalCapsules = useMemo(
|
||||
() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0),
|
||||
[selectedEntries]
|
||||
)
|
||||
const totalPacks = totalCapsules / 10
|
||||
|
||||
const token = useAuthStore.getState().accessToken
|
||||
console.info('[SummaryPage] token prefix:', token ? `${token.substring(0, 12)}…` : null)
|
||||
// NEW: capture logged-in user id for referral
|
||||
const currentUserId = useAuthStore.getState().user?.id
|
||||
console.info('[SummaryPage] currentUserId:', currentUserId)
|
||||
|
||||
// Countries list from backend VAT rates (fallback to current country if list empty)
|
||||
const countryOptions = useMemo(() => {
|
||||
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [(form.country || 'DE').toUpperCase()]
|
||||
console.info('[SummaryPage] countryOptions:', opts)
|
||||
return opts
|
||||
}, [vatRates, form.country]);
|
||||
|
||||
// Load VAT rates list from backend and set initial taxRate
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
console.info('[SummaryPage] Loading vat rates (mount). country:', form.country)
|
||||
const list = await getVatRates();
|
||||
if (!active) return;
|
||||
console.info('[SummaryPage] getVatRates result count:', list.length)
|
||||
setVatRates(list);
|
||||
const upper = form.country.toUpperCase();
|
||||
const match = list.find(r => r.code === upper);
|
||||
if (match?.rate != null) {
|
||||
console.info('[SummaryPage] Initial taxRate from list:', match.rate, 'country:', upper)
|
||||
setTaxRate(match.rate);
|
||||
} else {
|
||||
const rate = await getStandardVatRate(form.country);
|
||||
console.info('[SummaryPage] Fallback taxRate via getStandardVatRate:', rate, 'country:', upper)
|
||||
setTaxRate(rate ?? 0.07);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, []); // mount-only
|
||||
|
||||
// Update taxRate when country changes (from backend only)
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
const upper = form.country.toUpperCase();
|
||||
console.info('[SummaryPage] Country changed:', upper)
|
||||
const fromList = vatRates.find(r => r.code === upper)?.rate;
|
||||
if (fromList != null) {
|
||||
console.info('[SummaryPage] taxRate from existing list:', fromList)
|
||||
if (active) setTaxRate(fromList);
|
||||
return;
|
||||
}
|
||||
const rate = await getStandardVatRate(form.country);
|
||||
console.info('[SummaryPage] taxRate via getStandardVatRate:', rate)
|
||||
if (active) setTaxRate(rate ?? 0.07);
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [form.country, vatRates]);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => selectedEntries.reduce((sum, e) => sum + (e.quantity / 10) * e.coffee.pricePer10, 0),
|
||||
[selectedEntries]
|
||||
);
|
||||
const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]);
|
||||
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxRate, taxAmount]);
|
||||
|
||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
selectedEntries.length > 0 &&
|
||||
totalPacks === 12 && // CHANGED: require exactly 12 packs
|
||||
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true));
|
||||
|
||||
const backToSelection = () => router.push('/coffee-abonnements');
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || submitLoading) return
|
||||
// NEW: guard (defensive) — backend requires exactly 12 packs
|
||||
if (totalPacks !== 12) {
|
||||
setSubmitError('Order must contain exactly 12 packs (120 capsules).')
|
||||
return
|
||||
}
|
||||
setSubmitError(null)
|
||||
setSubmitLoading(true)
|
||||
try {
|
||||
const payload = {
|
||||
items: selectedEntries.map(entry => ({
|
||||
coffeeId: entry.coffee.id,
|
||||
quantity: Math.round(entry.quantity / 10), // packs
|
||||
})),
|
||||
billing_interval: 'month',
|
||||
interval_count: 1,
|
||||
is_auto_renew: true,
|
||||
// NEW: pass customer fields
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
email: form.email.trim(),
|
||||
street: form.street.trim(),
|
||||
postalCode: form.postalCode.trim(),
|
||||
city: form.city.trim(),
|
||||
country: form.country.trim(),
|
||||
frequency: form.frequency.trim(),
|
||||
startDate: form.startDate.trim(),
|
||||
// NEW: always include referred_by if available
|
||||
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
|
||||
}
|
||||
console.info('[SummaryPage] subscribeAbo payload:', payload)
|
||||
// NEW: explicit JSON preview to match request body
|
||||
console.info('[SummaryPage] subscribeAbo payload JSON:', JSON.stringify(payload))
|
||||
await subscribeAbo(payload)
|
||||
setShowThanks(true);
|
||||
try { sessionStorage.removeItem('coffeeSelections'); } catch {}
|
||||
} catch (e: any) {
|
||||
setSubmitError(e?.message || 'Subscription could not be created.');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="mx-auto max-w-6xl px-4 py-10 space-y-8 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
<span className="text-[#1C2B4A]">Summary & Details</span>
|
||||
</h1>
|
||||
<button
|
||||
onClick={backToSelection}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100"
|
||||
>
|
||||
Back to selection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||
<div className="flex items-center opacity-60">
|
||||
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">1</span>
|
||||
<span className="ml-2 font-medium">Selection</span>
|
||||
</div>
|
||||
<div className="h-px flex-1 bg-gray-200" />
|
||||
<div className="flex items-center">
|
||||
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">2</span>
|
||||
<span className="ml-2 font-medium">Summary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
||||
<p className="text-sm text-red-700 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={backToSelection}
|
||||
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
|
||||
>
|
||||
Back to selection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* submit error */}
|
||||
{submitError && (
|
||||
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
||||
<p className="text-sm text-red-700">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
||||
<div className="h-20 rounded-md bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
) : selectedEntries.length === 0 ? (
|
||||
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
||||
<p className="text-sm text-gray-600 mb-4">No selection found.</p>
|
||||
<button
|
||||
onClick={backToSelection}
|
||||
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
|
||||
>
|
||||
Back to selection
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
{/* Left: Customer data */}
|
||||
<section className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold mb-4">1. Your details</h2>
|
||||
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* inputs translated */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">First name</label>
|
||||
<input name="firstName" value={form.firstName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Last name</label>
|
||||
<input name="lastName" value={form.lastName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<input type="email" name="email" value={form.email} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Street & No.</label>
|
||||
<input name="street" value={form.street} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ZIP</label>
|
||||
<input name="postalCode" value={form.postalCode} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">City</label>
|
||||
<input name="city" value={form.city} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Country</label>
|
||||
<select name="country" value={form.country} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]">
|
||||
{countryOptions.map(code => (
|
||||
<option key={code} value={code}>{code}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Delivery interval</label>
|
||||
<select name="frequency" value={form.frequency} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]">
|
||||
<option value="monatlich">Monthly</option>
|
||||
<option value="zweimonatlich">Every 2 months</option>
|
||||
<option value="vierteljährlich">Quarterly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Start date</label>
|
||||
<input type="date" name="startDate" value={form.startDate} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!canSubmit || submitLoading}
|
||||
className={`group w-full mt-6 rounded-lg px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
|
||||
canSubmit && !submitLoading ? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg' : 'bg-gray-200 text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{submitLoading ? 'Creating…' : 'Complete subscription'}
|
||||
<svg className={`ml-2 h-5 w-5 transition-transform ${canSubmit ? 'group-hover:translate-x-0.5' : ''}`} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
{!canSubmit && <p className="text-xs text-gray-500 mt-2">Please select coffees and fill all fields.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right: Order summary */}
|
||||
<section className="lg:col-span-1">
|
||||
<h2 className="text-xl font-semibold mb-4">2. Your selection</h2>
|
||||
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg lg:sticky lg:top-6">
|
||||
{selectedEntries.map(entry => (
|
||||
<div key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{entry.coffee.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{entry.quantity} pcs • <span className="inline-flex items-center font-semibold text-[#1C2B4A]">€{entry.coffee.pricePer10}/10</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right font-semibold">€{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between pt-2 border-t">
|
||||
<span className="text-sm font-semibold">Total (net)</span>
|
||||
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">€{totalPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Tax ({(taxRate * 100).toFixed(1)}%)</span>
|
||||
<span className="text-sm font-medium">€{taxAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">Total incl. tax</span>
|
||||
<span className="text-xl font-extrabold text-[#1C2B4A]">€{totalWithTax.toFixed(2)}</span>
|
||||
</div>
|
||||
{/* Validation summary (refined design) */}
|
||||
<div className="mt-2 text-xs text-gray-700">
|
||||
Selected: {totalCapsules} capsules ({totalPacks} packs of 10).
|
||||
{totalPacks !== 12 && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
|
||||
Exactly 12 packs (120 capsules) are required.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thank you overlay */}
|
||||
{showThanks && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="relative mx-4 w-full max-w-md rounded-2xl bg-white p-8 text-center shadow-2xl">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{confetti.map((c, i) => (
|
||||
<span key={i} className="confetti" style={{ left: `${c.left}%`, animationDelay: `${c.delay}s`, background: c.color }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#1C2B4A]/10 text-[#1C2B4A] pop">
|
||||
<svg viewBox="0 0 24 24" className="h-9 w-9" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold">Thanks for your subscription!</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">We have received your order.</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">
|
||||
Back to selection
|
||||
</button>
|
||||
<button onClick={() => setShowThanks(false)} className="rounded-lg border border-gray-300 px-4 py-2 font-semibold hover:bg-gray-50">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.confetti {
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
width: 8px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
opacity: 0.9;
|
||||
animation: fall 1.8s linear forwards;
|
||||
}
|
||||
@keyframes fall {
|
||||
0% { transform: translateY(0) rotate(0deg); }
|
||||
100% { transform: translateY(110vh) rotate(720deg); }
|
||||
}
|
||||
.pop {
|
||||
animation: pop 450ms ease-out forwards;
|
||||
}
|
||||
@keyframes pop {
|
||||
0% { transform: scale(0.6); opacity: 0; }
|
||||
60% { transform: scale(1.08); opacity: 1; }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
284
src/app/community/page.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import Header from '../components/nav/Header'
|
||||
import Footer from '../components/Footer'
|
||||
import {
|
||||
UsersIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
HeartIcon,
|
||||
FireIcon,
|
||||
TrophyIcon,
|
||||
UserGroupIcon,
|
||||
PlusIcon,
|
||||
ArrowRightIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
export default function CommunityPage() {
|
||||
const router = useRouter()
|
||||
const user = useAuthStore(state => state.user)
|
||||
|
||||
// Redirect if not logged in
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
// Don't render if no user
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||
<p className="text-[#4A4A4A]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock community data
|
||||
const communityStats = [
|
||||
{ label: 'Members', value: '12,487', icon: UsersIcon, color: 'text-blue-600' },
|
||||
{ label: 'Active Groups', value: '156', icon: UserGroupIcon, color: 'text-green-600' },
|
||||
{ label: 'Discussions', value: '3,421', icon: ChatBubbleLeftRightIcon, color: 'text-purple-600' },
|
||||
{ label: 'Daily Active', value: '2,186', icon: FireIcon, color: 'text-orange-600' }
|
||||
]
|
||||
|
||||
const trendingGroups = [
|
||||
{
|
||||
name: 'Eco Warriors',
|
||||
members: '1,284',
|
||||
category: 'Sustainability',
|
||||
image: '🌱',
|
||||
description: 'Join fellow eco-enthusiasts in making the world greener'
|
||||
},
|
||||
{
|
||||
name: 'Zero Waste Living',
|
||||
members: '892',
|
||||
category: 'Lifestyle',
|
||||
image: '♻️',
|
||||
description: 'Tips and tricks for living a zero-waste lifestyle'
|
||||
},
|
||||
{
|
||||
name: 'Sustainable Fashion',
|
||||
members: '756',
|
||||
category: 'Fashion',
|
||||
image: '👕',
|
||||
description: 'Ethical fashion choices and sustainable brands'
|
||||
},
|
||||
{
|
||||
name: 'Green Tech',
|
||||
members: '634',
|
||||
category: 'Technology',
|
||||
image: '💚',
|
||||
description: 'Discuss the latest in green technology and innovation'
|
||||
}
|
||||
]
|
||||
|
||||
const recentPosts = [
|
||||
{
|
||||
user: 'Sarah M.',
|
||||
group: 'Eco Warriors',
|
||||
time: '2 hours ago',
|
||||
content: 'Just discovered a fantastic new way to upcycle old furniture! Has anyone tried painting with eco-friendly paints?',
|
||||
likes: 23,
|
||||
comments: 8
|
||||
},
|
||||
{
|
||||
user: 'David K.',
|
||||
group: 'Zero Waste Living',
|
||||
time: '4 hours ago',
|
||||
content: 'Week 3 of my zero-waste challenge! Managed to produce only 1 small jar of waste. Here are my top tips...',
|
||||
likes: 45,
|
||||
comments: 12
|
||||
},
|
||||
{
|
||||
user: 'Maria L.',
|
||||
group: 'Sustainable Fashion',
|
||||
time: '6 hours ago',
|
||||
content: 'Found an amazing local brand that makes clothes from recycled ocean plastic. Their quality is incredible!',
|
||||
likes: 38,
|
||||
comments: 15
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header Section */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Welcome to Profit Planet Community 🌍
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Connect with like-minded individuals, share sustainable practices, and make a positive impact together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Community Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-12">
|
||||
{communityStats.map((stat, index) => (
|
||||
<div key={index} className="bg-white rounded-lg p-6 shadow-sm border border-gray-200 text-center">
|
||||
<stat.icon className={`h-8 w-8 ${stat.color} mx-auto mb-3`} />
|
||||
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||
<p className="text-sm text-gray-600">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Trending Groups */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<TrophyIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />
|
||||
Trending Groups
|
||||
</h2>
|
||||
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium flex items-center">
|
||||
View All
|
||||
<ArrowRightIcon className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{trendingGroups.map((group, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="text-2xl">{group.image}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{group.name}</h3>
|
||||
<p className="text-xs text-[#8D6B1D] font-medium mb-1">{group.category}</p>
|
||||
<p className="text-sm text-gray-600 mb-2">{group.description}</p>
|
||||
<p className="text-xs text-gray-500">{group.members} members</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full mt-3 px-3 py-2 bg-[#8D6B1D]/10 text-[#8D6B1D] rounded-lg text-sm font-medium hover:bg-[#8D6B1D]/20 transition-colors">
|
||||
Join Group
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Discussions */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<ChatBubbleLeftRightIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />
|
||||
Recent Discussions
|
||||
</h2>
|
||||
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium">
|
||||
Start Discussion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{recentPosts.map((post, index) => (
|
||||
<div key={index} className="border-b border-gray-100 pb-6 last:border-b-0">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="w-10 h-10 bg-[#8D6B1D]/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-[#8D6B1D]">
|
||||
{post.user.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{post.user}</span>
|
||||
<span className="text-gray-300">•</span>
|
||||
<span className="text-sm text-[#8D6B1D]">{post.group}</span>
|
||||
<span className="text-gray-300">•</span>
|
||||
<span className="text-sm text-gray-500">{post.time}</span>
|
||||
</div>
|
||||
<p className="text-gray-800 mb-3">{post.content}</p>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="flex items-center space-x-1 text-gray-500 hover:text-red-500 transition-colors">
|
||||
<HeartIcon className="h-4 w-4" />
|
||||
<span className="text-sm">{post.likes}</span>
|
||||
</button>
|
||||
<button className="flex items-center space-x-1 text-gray-500 hover:text-[#8D6B1D] transition-colors">
|
||||
<ChatBubbleLeftRightIcon className="h-4 w-4" />
|
||||
<span className="text-sm">{post.comments}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
||||
<div className="space-y-3">
|
||||
<button className="w-full flex items-center justify-center px-4 py-3 bg-[#8D6B1D] text-white rounded-lg hover:bg-[#7A5E1A] transition-colors">
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create Group
|
||||
</button>
|
||||
<button className="w-full flex items-center justify-center px-4 py-3 border border-[#8D6B1D] text-[#8D6B1D] rounded-lg hover:bg-[#8D6B1D]/10 transition-colors">
|
||||
<ChatBubbleLeftRightIcon className="h-4 w-4 mr-2" />
|
||||
Start Discussion
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="w-full flex items-center justify-center px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My Groups */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">My Groups</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<div className="text-lg">🌱</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Eco Warriors</p>
|
||||
<p className="text-xs text-gray-500">1,284 members</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<div className="text-lg">♻️</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Zero Waste Living</p>
|
||||
<p className="text-xs text-gray-500">892 members</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Community Guidelines */}
|
||||
<div className="bg-gradient-to-br from-[#8D6B1D]/10 to-[#C49225]/10 rounded-lg p-6 border border-[#8D6B1D]/20">
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Community Guidelines</h3>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
<li>• Be respectful and kind</li>
|
||||
<li>• Stay on topic</li>
|
||||
<li>• Share authentic experiences</li>
|
||||
<li>• Help others learn and grow</li>
|
||||
</ul>
|
||||
<button className="text-xs text-[#8D6B1D] hover:underline mt-3">
|
||||
Read full guidelines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
src/app/components/AuthInitializer.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import useAuthStore from '../store/authStore'
|
||||
|
||||
// Helper to decode JWT and get expiry
|
||||
function getTokenExpiry(token: string | null): Date | null {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const [, payload] = token.split(".");
|
||||
const { exp } = JSON.parse(atob(payload));
|
||||
return exp ? new Date(exp * 1000) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AuthInitializer({ children }: { children: React.ReactNode }) {
|
||||
const { refreshAuthToken, setAuthReady, accessToken } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
// Try to refresh token from httpOnly cookie
|
||||
await refreshAuthToken()
|
||||
} catch (error) {
|
||||
console.log('No valid refresh token found')
|
||||
} finally {
|
||||
// Set auth as ready regardless of success/failure
|
||||
setAuthReady(true)
|
||||
}
|
||||
}
|
||||
|
||||
initializeAuth()
|
||||
}, [refreshAuthToken, setAuthReady])
|
||||
|
||||
// Automatic token refresh - check every minute
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
const currentToken = useAuthStore.getState().accessToken;
|
||||
if (currentToken) {
|
||||
const expiry = getTokenExpiry(currentToken);
|
||||
if (expiry) {
|
||||
const timeUntilExpiry = expiry.getTime() - Date.now();
|
||||
const threeMinutes = 3 * 60 * 1000; // 3 minutes in milliseconds
|
||||
|
||||
// If token expires within 3 minutes, refresh it
|
||||
if (timeUntilExpiry <= threeMinutes && timeUntilExpiry > 0) {
|
||||
console.log('🔄 Token expires soon, auto-refreshing...', {
|
||||
expiresIn: Math.round(timeUntilExpiry / 1000),
|
||||
expiresAt: expiry.toLocaleTimeString()
|
||||
});
|
||||
try {
|
||||
const success = await refreshAuthToken();
|
||||
if (success) {
|
||||
console.log('✅ Token auto-refresh successful');
|
||||
} else {
|
||||
console.log('❌ Token auto-refresh failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Token auto-refresh error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 60000); // Check every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshAuthToken])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
372
src/app/components/Beams.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
import { forwardRef, useImperativeHandle, useEffect, useRef, useMemo, FC, ReactNode } from 'react';
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { PerspectiveCamera } from '@react-three/drei';
|
||||
import { degToRad } from 'three/src/math/MathUtils.js';
|
||||
|
||||
type UniformValue = THREE.IUniform<unknown> | unknown;
|
||||
|
||||
interface ExtendMaterialConfig {
|
||||
header: string;
|
||||
vertexHeader?: string;
|
||||
fragmentHeader?: string;
|
||||
material?: THREE.MeshPhysicalMaterialParameters & { fog?: boolean };
|
||||
uniforms?: Record<string, UniformValue>;
|
||||
vertex?: Record<string, string>;
|
||||
fragment?: Record<string, string>;
|
||||
}
|
||||
|
||||
type ShaderWithDefines = THREE.ShaderLibShader & {
|
||||
defines?: Record<string, string | number | boolean>;
|
||||
};
|
||||
|
||||
function extendMaterial<T extends THREE.Material = THREE.Material>(
|
||||
BaseMaterial: new (params?: THREE.MaterialParameters) => T,
|
||||
cfg: ExtendMaterialConfig
|
||||
): THREE.ShaderMaterial {
|
||||
const physical = THREE.ShaderLib.physical as ShaderWithDefines;
|
||||
const { vertexShader: baseVert, fragmentShader: baseFrag, uniforms: baseUniforms } = physical;
|
||||
const baseDefines = physical.defines ?? {};
|
||||
|
||||
const uniforms: Record<string, THREE.IUniform> = THREE.UniformsUtils.clone(baseUniforms);
|
||||
|
||||
const defaults = new BaseMaterial(cfg.material || {}) as T & {
|
||||
color?: THREE.Color;
|
||||
roughness?: number;
|
||||
metalness?: number;
|
||||
envMap?: THREE.Texture;
|
||||
envMapIntensity?: number;
|
||||
};
|
||||
|
||||
if (defaults.color) uniforms.diffuse.value = defaults.color;
|
||||
if ('roughness' in defaults) uniforms.roughness.value = defaults.roughness;
|
||||
if ('metalness' in defaults) uniforms.metalness.value = defaults.metalness;
|
||||
if ('envMap' in defaults) uniforms.envMap.value = defaults.envMap;
|
||||
if ('envMapIntensity' in defaults) uniforms.envMapIntensity.value = defaults.envMapIntensity;
|
||||
|
||||
Object.entries(cfg.uniforms ?? {}).forEach(([key, u]) => {
|
||||
uniforms[key] =
|
||||
u !== null && typeof u === 'object' && 'value' in u
|
||||
? (u as THREE.IUniform<unknown>)
|
||||
: ({ value: u } as THREE.IUniform<unknown>);
|
||||
});
|
||||
|
||||
let vert = `${cfg.header}\n${cfg.vertexHeader ?? ''}\n${baseVert}`;
|
||||
let frag = `${cfg.header}\n${cfg.fragmentHeader ?? ''}\n${baseFrag}`;
|
||||
|
||||
for (const [inc, code] of Object.entries(cfg.vertex ?? {})) {
|
||||
vert = vert.replace(inc, `${inc}\n${code}`);
|
||||
}
|
||||
for (const [inc, code] of Object.entries(cfg.fragment ?? {})) {
|
||||
frag = frag.replace(inc, `${inc}\n${code}`);
|
||||
}
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
defines: { ...baseDefines },
|
||||
uniforms,
|
||||
vertexShader: vert,
|
||||
fragmentShader: frag,
|
||||
lights: true,
|
||||
fog: !!cfg.material?.fog
|
||||
});
|
||||
|
||||
return mat;
|
||||
}
|
||||
|
||||
const CanvasWrapper: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<Canvas dpr={[1, 2]} frameloop="always" className="w-full h-full relative">
|
||||
{children}
|
||||
</Canvas>
|
||||
);
|
||||
|
||||
const hexToNormalizedRGB = (hex: string): [number, number, number] => {
|
||||
const clean = hex.replace('#', '');
|
||||
const r = parseInt(clean.substring(0, 2), 16);
|
||||
const g = parseInt(clean.substring(2, 4), 16);
|
||||
const b = parseInt(clean.substring(4, 6), 16);
|
||||
return [r / 255, g / 255, b / 255];
|
||||
};
|
||||
|
||||
const noise = `
|
||||
float random (in vec2 st) {
|
||||
return fract(sin(dot(st.xy,
|
||||
vec2(12.9898,78.233)))*
|
||||
43758.5453123);
|
||||
}
|
||||
float noise (in vec2 st) {
|
||||
vec2 i = floor(st);
|
||||
vec2 f = fract(st);
|
||||
float a = random(i);
|
||||
float b = random(i + vec2(1.0, 0.0));
|
||||
float c = random(i + vec2(0.0, 1.0));
|
||||
float d = random(i + vec2(1.0, 1.0));
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(a, b, u.x) +
|
||||
(c - a)* u.y * (1.0 - u.x) +
|
||||
(d - b) * u.x * u.y;
|
||||
}
|
||||
vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}
|
||||
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}
|
||||
vec3 fade(vec3 t) {return t*t*t*(t*(t*6.0-15.0)+10.0);}
|
||||
float cnoise(vec3 P){
|
||||
vec3 Pi0 = floor(P);
|
||||
vec3 Pi1 = Pi0 + vec3(1.0);
|
||||
Pi0 = mod(Pi0, 289.0);
|
||||
Pi1 = mod(Pi1, 289.0);
|
||||
vec3 Pf0 = fract(P);
|
||||
vec3 Pf1 = Pf0 - vec3(1.0);
|
||||
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
|
||||
vec4 iy = vec4(Pi0.yy, Pi1.yy);
|
||||
vec4 iz0 = Pi0.zzzz;
|
||||
vec4 iz1 = Pi1.zzzz;
|
||||
vec4 ixy = permute(permute(ix) + iy);
|
||||
vec4 ixy0 = permute(ixy + iz0);
|
||||
vec4 ixy1 = permute(ixy + iz1);
|
||||
vec4 gx0 = ixy0 / 7.0;
|
||||
vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
|
||||
gx0 = fract(gx0);
|
||||
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
|
||||
vec4 sz0 = step(gz0, vec4(0.0));
|
||||
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
|
||||
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
|
||||
vec4 gx1 = ixy1 / 7.0;
|
||||
vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
|
||||
gx1 = fract(gx1);
|
||||
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
|
||||
vec4 sz1 = step(gz1, vec4(0.0));
|
||||
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
|
||||
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
|
||||
vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
|
||||
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
|
||||
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
|
||||
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
|
||||
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
|
||||
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
|
||||
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
|
||||
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);
|
||||
vec4 norm0 = taylorInvSqrt(vec4(dot(g000,g000),dot(g010,g010),dot(g100,g100),dot(g110,g110)));
|
||||
g000 *= norm0.x; g010 *= norm0.y; g100 *= norm0.z; g110 *= norm0.w;
|
||||
vec4 norm1 = taylorInvSqrt(vec4(dot(g001,g001),dot(g011,g011),dot(g101,g101),dot(g111,g111)));
|
||||
g001 *= norm1.x; g011 *= norm1.y; g101 *= norm1.z; g111 *= norm1.w;
|
||||
float n000 = dot(g000, Pf0);
|
||||
float n100 = dot(g100, vec3(Pf1.x,Pf0.yz));
|
||||
float n010 = dot(g010, vec3(Pf0.x,Pf1.y,Pf0.z));
|
||||
float n110 = dot(g110, vec3(Pf1.xy,Pf0.z));
|
||||
float n001 = dot(g001, vec3(Pf0.xy,Pf1.z));
|
||||
float n101 = dot(g101, vec3(Pf1.x,Pf0.y,Pf1.z));
|
||||
float n011 = dot(g011, vec3(Pf0.x,Pf1.yz));
|
||||
float n111 = dot(g111, Pf1);
|
||||
vec3 fade_xyz = fade(Pf0);
|
||||
vec4 n_z = mix(vec4(n000,n100,n010,n110),vec4(n001,n101,n011,n111),fade_xyz.z);
|
||||
vec2 n_yz = mix(n_z.xy,n_z.zw,fade_xyz.y);
|
||||
float n_xyz = mix(n_yz.x,n_yz.y,fade_xyz.x);
|
||||
return 2.2 * n_xyz;
|
||||
}
|
||||
`;
|
||||
|
||||
interface BeamsProps {
|
||||
beamWidth?: number;
|
||||
beamHeight?: number;
|
||||
beamNumber?: number;
|
||||
lightColor?: string;
|
||||
speed?: number;
|
||||
noiseIntensity?: number;
|
||||
scale?: number;
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
const Beams: FC<BeamsProps> = ({
|
||||
beamWidth = 2,
|
||||
beamHeight = 15,
|
||||
beamNumber = 12,
|
||||
lightColor = '#ffffff',
|
||||
speed = 2,
|
||||
noiseIntensity = 1.75,
|
||||
scale = 0.2,
|
||||
rotation = 0
|
||||
}) => {
|
||||
const meshRef = useRef<THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>>(null!);
|
||||
|
||||
const beamMaterial = useMemo(
|
||||
() =>
|
||||
extendMaterial(THREE.MeshStandardMaterial, {
|
||||
header: `
|
||||
varying vec3 vEye;
|
||||
varying float vNoise;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vPosition;
|
||||
uniform float time;
|
||||
uniform float uSpeed;
|
||||
uniform float uNoiseIntensity;
|
||||
uniform float uScale;
|
||||
${noise}`,
|
||||
vertexHeader: `
|
||||
float getPos(vec3 pos) {
|
||||
vec3 noisePos =
|
||||
vec3(pos.x * 0., pos.y - uv.y, pos.z + time * uSpeed * 3.) * uScale;
|
||||
return cnoise(noisePos);
|
||||
}
|
||||
vec3 getCurrentPos(vec3 pos) {
|
||||
vec3 newpos = pos;
|
||||
newpos.z += getPos(pos);
|
||||
return newpos;
|
||||
}
|
||||
vec3 getNormal(vec3 pos) {
|
||||
vec3 curpos = getCurrentPos(pos);
|
||||
vec3 nextposX = getCurrentPos(pos + vec3(0.01, 0.0, 0.0));
|
||||
vec3 nextposZ = getCurrentPos(pos + vec3(0.0, -0.01, 0.0));
|
||||
vec3 tangentX = normalize(nextposX - curpos);
|
||||
vec3 tangentZ = normalize(nextposZ - curpos);
|
||||
return normalize(cross(tangentZ, tangentX));
|
||||
}`,
|
||||
fragmentHeader: '',
|
||||
vertex: {
|
||||
'#include <begin_vertex>': `transformed.z += getPos(transformed.xyz);`,
|
||||
'#include <beginnormal_vertex>': `objectNormal = getNormal(position.xyz);`
|
||||
},
|
||||
fragment: {
|
||||
'#include <dithering_fragment>': `
|
||||
float randomNoise = noise(gl_FragCoord.xy);
|
||||
gl_FragColor.rgb -= randomNoise / 15. * uNoiseIntensity;`
|
||||
},
|
||||
material: { fog: true },
|
||||
uniforms: {
|
||||
diffuse: new THREE.Color(...hexToNormalizedRGB('#000000')),
|
||||
time: { shared: true, mixed: true, linked: true, value: 0 },
|
||||
roughness: 0.3,
|
||||
metalness: 0.3,
|
||||
uSpeed: { shared: true, mixed: true, linked: true, value: speed },
|
||||
envMapIntensity: 10,
|
||||
uNoiseIntensity: noiseIntensity,
|
||||
uScale: scale
|
||||
}
|
||||
}),
|
||||
[speed, noiseIntensity, scale]
|
||||
);
|
||||
|
||||
return (
|
||||
<CanvasWrapper>
|
||||
<group rotation={[0, 0, degToRad(rotation)]}>
|
||||
<PlaneNoise ref={meshRef} material={beamMaterial} count={beamNumber} width={beamWidth} height={beamHeight} />
|
||||
<DirLight color={lightColor} position={[0, 3, 10]} />
|
||||
</group>
|
||||
<ambientLight intensity={1} />
|
||||
<color attach="background" args={['#000000']} />
|
||||
<PerspectiveCamera makeDefault position={[0, 0, 20]} fov={30} />
|
||||
</CanvasWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
function createStackedPlanesBufferGeometry(
|
||||
n: number,
|
||||
width: number,
|
||||
height: number,
|
||||
spacing: number,
|
||||
heightSegments: number
|
||||
): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const numVertices = n * (heightSegments + 1) * 2;
|
||||
const numFaces = n * heightSegments * 2;
|
||||
const positions = new Float32Array(numVertices * 3);
|
||||
const indices = new Uint32Array(numFaces * 3);
|
||||
const uvs = new Float32Array(numVertices * 2);
|
||||
|
||||
let vertexOffset = 0;
|
||||
let indexOffset = 0;
|
||||
let uvOffset = 0;
|
||||
const totalWidth = n * width + (n - 1) * spacing;
|
||||
const xOffsetBase = -totalWidth / 2;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const xOffset = xOffsetBase + i * (width + spacing);
|
||||
const uvXOffset = Math.random() * 300;
|
||||
const uvYOffset = Math.random() * 300;
|
||||
|
||||
for (let j = 0; j <= heightSegments; j++) {
|
||||
const y = height * (j / heightSegments - 0.5);
|
||||
const v0 = [xOffset, y, 0];
|
||||
const v1 = [xOffset + width, y, 0];
|
||||
positions.set([...v0, ...v1], vertexOffset * 3);
|
||||
|
||||
const uvY = j / heightSegments;
|
||||
uvs.set([uvXOffset, uvY + uvYOffset, uvXOffset + 1, uvY + uvYOffset], uvOffset);
|
||||
|
||||
if (j < heightSegments) {
|
||||
const a = vertexOffset,
|
||||
b = vertexOffset + 1,
|
||||
c = vertexOffset + 2,
|
||||
d = vertexOffset + 3;
|
||||
indices.set([a, b, c, c, b, d], indexOffset);
|
||||
indexOffset += 6;
|
||||
}
|
||||
vertexOffset += 2;
|
||||
uvOffset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
const MergedPlanes = forwardRef<
|
||||
THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>,
|
||||
{
|
||||
material: THREE.ShaderMaterial;
|
||||
width: number;
|
||||
count: number;
|
||||
height: number;
|
||||
}
|
||||
>(({ material, width, count, height }, ref) => {
|
||||
const mesh = useRef<THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>>(null!);
|
||||
useImperativeHandle(ref, () => mesh.current);
|
||||
const geometry = useMemo(
|
||||
() => createStackedPlanesBufferGeometry(count, width, height, 0, 100),
|
||||
[count, width, height]
|
||||
);
|
||||
useFrame((_, delta) => {
|
||||
mesh.current.material.uniforms.time.value += 0.1 * delta;
|
||||
});
|
||||
return <mesh ref={mesh} geometry={geometry} material={material} />;
|
||||
});
|
||||
MergedPlanes.displayName = 'MergedPlanes';
|
||||
|
||||
const PlaneNoise = forwardRef<
|
||||
THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>,
|
||||
{
|
||||
material: THREE.ShaderMaterial;
|
||||
width: number;
|
||||
count: number;
|
||||
height: number;
|
||||
}
|
||||
>((props, ref) => (
|
||||
<MergedPlanes ref={ref} material={props.material} width={props.width} count={props.count} height={props.height} />
|
||||
));
|
||||
PlaneNoise.displayName = 'PlaneNoise';
|
||||
|
||||
const DirLight: FC<{ position: [number, number, number]; color: string }> = ({ position, color }) => {
|
||||
const dir = useRef<THREE.DirectionalLight>(null!);
|
||||
useEffect(() => {
|
||||
if (!dir.current) return;
|
||||
const cam = dir.current.shadow.camera as THREE.Camera & {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
far: number;
|
||||
};
|
||||
cam.top = 24;
|
||||
cam.bottom = -24;
|
||||
cam.left = -24;
|
||||
cam.right = 24;
|
||||
cam.far = 64;
|
||||
dir.current.shadow.bias = -0.004;
|
||||
}, []);
|
||||
return <directionalLight ref={dir} color={color} intensity={1} position={position} />;
|
||||
};
|
||||
|
||||
export default Beams;
|
||||
198
src/app/components/Crosshair.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, RefObject } from 'react'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
const lerp = (a: number, b: number, n: number): number => (1 - n) * a + n * b
|
||||
|
||||
const getMousePos = (e: MouseEvent, container?: HTMLElement | null): { x: number; y: number } => {
|
||||
if (container) {
|
||||
const bounds = container.getBoundingClientRect()
|
||||
return {
|
||||
x: e.clientX - bounds.left,
|
||||
y: e.clientY - bounds.top,
|
||||
}
|
||||
}
|
||||
return { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
interface CrosshairProps {
|
||||
color?: string
|
||||
containerRef?: RefObject<HTMLDivElement | null> | null
|
||||
}
|
||||
|
||||
const Crosshair: React.FC<CrosshairProps> = ({ color = 'white', containerRef = null }) => {
|
||||
const cursorRef = useRef<HTMLDivElement>(null)
|
||||
const lineHorizontalRef = useRef<HTMLDivElement>(null)
|
||||
const lineVerticalRef = useRef<HTMLDivElement>(null)
|
||||
const filterXRef = useRef<SVGFETurbulenceElement>(null)
|
||||
const filterYRef = useRef<SVGFETurbulenceElement>(null)
|
||||
|
||||
let mouse = { x: 0, y: 0 }
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (ev: Event) => {
|
||||
const mouseEvent = ev as MouseEvent
|
||||
mouse = getMousePos(mouseEvent, containerRef?.current || undefined)
|
||||
|
||||
if (containerRef?.current) {
|
||||
const bounds = containerRef.current.getBoundingClientRect()
|
||||
if (
|
||||
mouseEvent.clientX < bounds.left ||
|
||||
mouseEvent.clientX > bounds.right ||
|
||||
mouseEvent.clientY < bounds.top ||
|
||||
mouseEvent.clientY > bounds.bottom
|
||||
) {
|
||||
gsap.to([lineHorizontalRef.current, lineVerticalRef.current].filter(Boolean), { opacity: 0 })
|
||||
} else {
|
||||
gsap.to([lineHorizontalRef.current, lineVerticalRef.current].filter(Boolean), { opacity: 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const target: HTMLElement | Window = containerRef?.current || window
|
||||
target.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
const renderedStyles: {
|
||||
[key: string]: { previous: number; current: number; amt: number }
|
||||
} = {
|
||||
tx: { previous: 0, current: 0, amt: 0.15 },
|
||||
ty: { previous: 0, current: 0, amt: 0.15 },
|
||||
}
|
||||
|
||||
gsap.set([lineHorizontalRef.current, lineVerticalRef.current].filter(Boolean), { opacity: 0 })
|
||||
|
||||
const onMouseMove = (ev: Event) => {
|
||||
const mouseEvent = ev as MouseEvent
|
||||
mouse = getMousePos(mouseEvent, containerRef?.current || undefined)
|
||||
|
||||
renderedStyles.tx.previous = renderedStyles.tx.current = mouse.x
|
||||
renderedStyles.ty.previous = renderedStyles.ty.current = mouse.y
|
||||
|
||||
gsap.to([lineHorizontalRef.current, lineVerticalRef.current].filter(Boolean), {
|
||||
duration: 0.9,
|
||||
ease: 'Power3.easeOut',
|
||||
opacity: 1,
|
||||
})
|
||||
|
||||
requestAnimationFrame(render)
|
||||
|
||||
target.removeEventListener('mousemove', onMouseMove)
|
||||
}
|
||||
|
||||
target.addEventListener('mousemove', onMouseMove)
|
||||
|
||||
const primitiveValues = { turbulence: 0 }
|
||||
|
||||
const tl = gsap
|
||||
.timeline({
|
||||
paused: true,
|
||||
onStart: () => {
|
||||
if (lineHorizontalRef.current) {
|
||||
lineHorizontalRef.current.style.filter = 'url(#filter-noise-x)'
|
||||
}
|
||||
if (lineVerticalRef.current) {
|
||||
lineVerticalRef.current.style.filter = 'url(#filter-noise-y)'
|
||||
}
|
||||
},
|
||||
onUpdate: () => {
|
||||
if (filterXRef.current && filterYRef.current) {
|
||||
filterXRef.current.setAttribute('baseFrequency', primitiveValues.turbulence.toString())
|
||||
filterYRef.current.setAttribute('baseFrequency', primitiveValues.turbulence.toString())
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
if (lineHorizontalRef.current) lineHorizontalRef.current.style.filter = 'none'
|
||||
if (lineVerticalRef.current) lineVerticalRef.current.style.filter = 'none'
|
||||
},
|
||||
})
|
||||
.to(primitiveValues, {
|
||||
duration: 0.5,
|
||||
ease: 'power1',
|
||||
startAt: { turbulence: 1 },
|
||||
turbulence: 0,
|
||||
})
|
||||
|
||||
const enter = () => tl.restart()
|
||||
const leave = () => {
|
||||
tl.progress(1).kill()
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
renderedStyles.tx.current = mouse.x
|
||||
renderedStyles.ty.current = mouse.y
|
||||
|
||||
for (const key in renderedStyles) {
|
||||
const style = renderedStyles[key]
|
||||
style.previous = lerp(style.previous, style.current, style.amt)
|
||||
}
|
||||
|
||||
if (lineHorizontalRef.current && lineVerticalRef.current) {
|
||||
gsap.set(lineVerticalRef.current, { x: renderedStyles.tx.previous })
|
||||
gsap.set(lineHorizontalRef.current, { y: renderedStyles.ty.previous })
|
||||
}
|
||||
|
||||
requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
const links: NodeListOf<HTMLAnchorElement> = containerRef?.current
|
||||
? containerRef.current.querySelectorAll('a')
|
||||
: document.querySelectorAll('a')
|
||||
|
||||
links.forEach(link => {
|
||||
link.addEventListener('mouseenter', enter)
|
||||
link.addEventListener('mouseleave', leave)
|
||||
})
|
||||
|
||||
return () => {
|
||||
target.removeEventListener('mousemove', handleMouseMove)
|
||||
target.removeEventListener('mousemove', onMouseMove)
|
||||
links.forEach(link => {
|
||||
link.removeEventListener('mouseenter', enter)
|
||||
link.removeEventListener('mouseleave', leave)
|
||||
})
|
||||
}
|
||||
}, [containerRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cursorRef}
|
||||
className={`${containerRef ? 'absolute' : 'fixed'} top-0 left-0 w-full h-full pointer-events-none z-[10000]`}
|
||||
>
|
||||
<svg className="absolute top-0 left-0 w-full h-full">
|
||||
<defs>
|
||||
<filter id="filter-noise-x">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.000001"
|
||||
numOctaves="1"
|
||||
ref={filterXRef}
|
||||
/>
|
||||
<feDisplacementMap in="SourceGraphic" scale="40" />
|
||||
</filter>
|
||||
<filter id="filter-noise-y">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.000001"
|
||||
numOctaves="1"
|
||||
ref={filterYRef}
|
||||
/>
|
||||
<feDisplacementMap in="SourceGraphic" scale="40" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<div
|
||||
ref={lineHorizontalRef}
|
||||
className="absolute w-full h-px pointer-events-none opacity-0 translate-y-1/2"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<div
|
||||
ref={lineVerticalRef}
|
||||
className="absolute h-full w-px pointer-events-none opacity-0 translate-x-1/2"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Crosshair
|
||||
26
src/app/components/Footer.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<footer className="relative z-50 w-full bg-[#0F172A] py-4 px-6 shadow-inner">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
<div className="text-sm text-white/70">
|
||||
© {new Date().getFullYear()} {t('footer.company')} - {t('footer.rights')}
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<a href="#" className="text-sm text-white/70 hover:text-[#8D6B1D] transition-colors">
|
||||
{t('footer.privacy')}
|
||||
</a>
|
||||
<a href="#" className="text-sm text-white/70 hover:text-[#8D6B1D] transition-colors">
|
||||
{t('footer.terms')}
|
||||
</a>
|
||||
<a href="#" className="text-sm text-white/70 hover:text-[#8D6B1D] transition-colors">
|
||||
{t('footer.contact')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
96
src/app/components/LanguageSwitcher.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { SUPPORTED_LANGUAGES, LANGUAGE_NAMES } from '../i18n/config';
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
variant?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
// Flag Icons mit Emoji (viel sauberer als selbst gezeichnete CSS-Flaggen)
|
||||
const FlagIcon = ({ countryCode, className = "size-5" }: { countryCode: string; className?: string }) => {
|
||||
const flags = {
|
||||
'de': '🇩🇪',
|
||||
'en': '🇬🇧'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`${className} flex items-center justify-center text-base`}>
|
||||
{flags[countryCode as keyof typeof flags] || '🏳️'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcherProps) {
|
||||
const { language, setLanguage } = useTranslation();
|
||||
|
||||
const getButtonStyles = () => {
|
||||
if (variant === 'dark') {
|
||||
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white/10 px-3 py-2 text-sm font-semibold text-white inset-ring-1 inset-ring-white/5 hover:bg-white/20';
|
||||
}
|
||||
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-gray-100 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-gray-300 hover:bg-gray-200';
|
||||
};
|
||||
|
||||
const getMenuStyles = () => {
|
||||
if (variant === 'dark') {
|
||||
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-white/10 rounded-md bg-gray-800 outline-1 -outline-offset-1 outline-white/10 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
|
||||
}
|
||||
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-100 rounded-md bg-white outline-1 -outline-offset-1 outline-gray-200 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
|
||||
};
|
||||
|
||||
const getItemStyles = (isActive: boolean) => {
|
||||
if (variant === 'dark') {
|
||||
return `group flex items-center px-4 py-2 text-sm ${
|
||||
isActive
|
||||
? 'bg-[#8D6B1D] text-white'
|
||||
: 'text-gray-300 data-focus:bg-white/5 data-focus:text-white data-focus:outline-hidden'
|
||||
}`;
|
||||
}
|
||||
return `group flex items-center px-4 py-2 text-sm ${
|
||||
isActive
|
||||
? 'bg-[#8D6B1D] text-white'
|
||||
: 'text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden'
|
||||
}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block">
|
||||
<MenuButton className={getButtonStyles()}>
|
||||
<FlagIcon countryCode={language} className="size-4" />
|
||||
{LANGUAGE_NAMES[language]}
|
||||
<ChevronDownIcon aria-hidden="true" className="-mr-1 size-5 text-gray-500" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
transition
|
||||
className={getMenuStyles()}
|
||||
>
|
||||
<div className="py-1">
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<MenuItem key={lang}>
|
||||
<button
|
||||
onClick={() => setLanguage(lang)}
|
||||
className={getItemStyles(language === lang)}
|
||||
>
|
||||
<FlagIcon
|
||||
countryCode={lang}
|
||||
className={`mr-3 size-5 ${
|
||||
variant === 'dark'
|
||||
? (language === lang ? 'opacity-100' : 'opacity-70 group-data-focus:opacity-100')
|
||||
: (language === lang ? 'opacity-100' : 'opacity-80 group-data-focus:opacity-100')
|
||||
}`}
|
||||
/>
|
||||
<span className="flex-1 text-left">{LANGUAGE_NAMES[lang]}</span>
|
||||
{language === lang && (
|
||||
<span className="ml-2 text-xs font-bold">✓</span>
|
||||
)}
|
||||
</button>
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
81
src/app/components/PageLayout.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Header from './nav/Header';
|
||||
import Footer from './Footer';
|
||||
import PageTransitionEffect from './animation/pageTransitionEffect';
|
||||
|
||||
// Utility to detect mobile devices
|
||||
function isMobileDevice() {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
showHeader?: boolean;
|
||||
showFooter?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export default function PageLayout({
|
||||
children,
|
||||
showHeader = true,
|
||||
showFooter = true,
|
||||
className = 'bg-white text-gray-900',
|
||||
contentClassName = 'flex-1 relative z-10 w-full',
|
||||
}: PageLayoutProps) {
|
||||
const isMobile = isMobileDevice();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
|
||||
const pathname = usePathname();
|
||||
|
||||
// Global scrollbar restore / leak cleanup (runs on navigation)
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
// ensure a visible/stable vertical scrollbar on desktop
|
||||
html.style.overflowY = 'scroll';
|
||||
body.style.overflowY = 'auto';
|
||||
|
||||
// clear common scroll-lock leftovers (gap where scrollbar should be)
|
||||
if (html.style.overflow === 'hidden') html.style.overflow = '';
|
||||
if (body.style.overflow === 'hidden') body.style.overflow = '';
|
||||
html.style.paddingRight = '';
|
||||
body.style.paddingRight = '';
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen w-full flex flex-col ${className}`}>
|
||||
|
||||
{showHeader && (
|
||||
<div className="relative z-50 w-full flex-shrink-0">
|
||||
<Header setGlobalLoggingOut={setIsLoggingOut} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className={contentClassName}>
|
||||
<PageTransitionEffect>{children}</PageTransitionEffect>
|
||||
</div>
|
||||
|
||||
{showFooter && (
|
||||
<div className="relative z-50 w-full flex-shrink-0">
|
||||
<Footer />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global logout transition overlay (covers whole page) */}
|
||||
{isLoggingOut && (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-3 text-white">
|
||||
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
||||
<p className="text-sm font-medium">Logging you out...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
src/app/components/SplitText.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);
|
||||
|
||||
export interface SplitTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
ease?: string | ((t: number) => number);
|
||||
splitType?: 'chars' | 'words' | 'lines' | 'words, chars';
|
||||
from?: gsap.TweenVars;
|
||||
to?: gsap.TweenVars;
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
|
||||
textAlign?: React.CSSProperties['textAlign'];
|
||||
onLetterAnimationComplete?: () => void;
|
||||
}
|
||||
|
||||
const SplitText: React.FC<SplitTextProps> = ({
|
||||
text,
|
||||
className = '',
|
||||
delay = 50,
|
||||
duration = 1.25,
|
||||
ease = 'power3.out',
|
||||
splitType = 'chars',
|
||||
from = { opacity: 0, y: 40 },
|
||||
to = { opacity: 1, y: 0 },
|
||||
threshold = 0.1,
|
||||
rootMargin = '-100px',
|
||||
tag = 'p',
|
||||
textAlign = 'center',
|
||||
onLetterAnimationComplete
|
||||
}) => {
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
const animationCompletedRef = useRef(false);
|
||||
const onCompleteRef = useRef(onLetterAnimationComplete);
|
||||
const [fontsLoaded, setFontsLoaded] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onLetterAnimationComplete;
|
||||
}, [onLetterAnimationComplete]);
|
||||
|
||||
// Reset animation completion when text changes so we can re-split/re-animate
|
||||
useEffect(() => {
|
||||
animationCompletedRef.current = false;
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (document.fonts.status === 'loaded') {
|
||||
setFontsLoaded(true);
|
||||
} else {
|
||||
document.fonts.ready.then(() => {
|
||||
setFontsLoaded(true);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useGSAP(
|
||||
() => {
|
||||
if (!ref.current || !text || !fontsLoaded) return;
|
||||
if (animationCompletedRef.current) return;
|
||||
|
||||
const el = ref.current as HTMLElement & {
|
||||
_rbsplitInstance?: GSAPSplitText;
|
||||
};
|
||||
|
||||
if (el._rbsplitInstance) {
|
||||
try {
|
||||
el._rbsplitInstance.revert();
|
||||
} catch (_) {}
|
||||
el._rbsplitInstance = undefined;
|
||||
}
|
||||
|
||||
const startPct = (1 - threshold) * 100;
|
||||
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin);
|
||||
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0;
|
||||
const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px';
|
||||
const sign =
|
||||
marginValue === 0
|
||||
? ''
|
||||
: marginValue < 0
|
||||
? `-=${Math.abs(marginValue)}${marginUnit}`
|
||||
: `+=${marginValue}${marginUnit}`;
|
||||
const start = `top ${startPct}%${sign}`;
|
||||
|
||||
let targets: Element[] = [];
|
||||
const assignTargets = (self: GSAPSplitText) => {
|
||||
if (splitType.includes('chars') && (self as GSAPSplitText).chars?.length)
|
||||
targets = (self as GSAPSplitText).chars;
|
||||
if (!targets.length && splitType.includes('words') && self.words.length) targets = self.words;
|
||||
if (!targets.length && splitType.includes('lines') && self.lines.length) targets = self.lines;
|
||||
if (!targets.length) targets = self.chars || self.words || self.lines;
|
||||
};
|
||||
|
||||
const splitInstance = new GSAPSplitText(el, {
|
||||
type: splitType,
|
||||
smartWrap: true,
|
||||
autoSplit: splitType === 'lines',
|
||||
linesClass: 'split-line',
|
||||
wordsClass: 'split-word',
|
||||
charsClass: 'split-char',
|
||||
reduceWhiteSpace: false,
|
||||
onSplit: (self: GSAPSplitText) => {
|
||||
assignTargets(self);
|
||||
return gsap.fromTo(
|
||||
targets,
|
||||
{ ...from },
|
||||
{
|
||||
...to,
|
||||
duration,
|
||||
ease,
|
||||
stagger: delay / 1000,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start,
|
||||
once: true,
|
||||
fastScrollEnd: true,
|
||||
anticipatePin: 0.4
|
||||
},
|
||||
onComplete: () => {
|
||||
animationCompletedRef.current = true;
|
||||
onCompleteRef.current?.();
|
||||
},
|
||||
willChange: 'transform, opacity',
|
||||
force3D: true
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
el._rbsplitInstance = splitInstance;
|
||||
|
||||
return () => {
|
||||
ScrollTrigger.getAll().forEach(st => {
|
||||
if (st.trigger === el) st.kill();
|
||||
});
|
||||
try {
|
||||
splitInstance.revert();
|
||||
} catch (_) {}
|
||||
el._rbsplitInstance = undefined;
|
||||
};
|
||||
},
|
||||
{
|
||||
dependencies: [
|
||||
text,
|
||||
delay,
|
||||
duration,
|
||||
ease,
|
||||
splitType,
|
||||
JSON.stringify(from),
|
||||
JSON.stringify(to),
|
||||
threshold,
|
||||
rootMargin,
|
||||
fontsLoaded
|
||||
],
|
||||
scope: ref
|
||||
}
|
||||
);
|
||||
|
||||
const renderTag = () => {
|
||||
const style: React.CSSProperties = {
|
||||
textAlign,
|
||||
wordWrap: 'break-word',
|
||||
willChange: 'transform, opacity'
|
||||
};
|
||||
const classes = `split-parent overflow-hidden inline-block whitespace-normal ${className}`;
|
||||
|
||||
switch (tag) {
|
||||
case 'h1':
|
||||
return (
|
||||
<h1 ref={ref} style={style} className={classes}>
|
||||
{text}
|
||||
</h1>
|
||||
);
|
||||
case 'h2':
|
||||
return (
|
||||
<h2 ref={ref} style={style} className={classes}>
|
||||
{text}
|
||||
</h2>
|
||||
);
|
||||
case 'h3':
|
||||
return (
|
||||
<h3 ref={ref} style={style} className={classes}>
|
||||
{text}
|
||||
</h3>
|
||||
);
|
||||
case 'h4':
|
||||
return (
|
||||
<h4 ref={ref} style={style} className={classes}>
|
||||
{text}
|
||||
</h4>
|
||||
);
|
||||
case 'h5':
|
||||
return (
|
||||
<h5 ref={ref} style={style} className={classes}>
|
||||
{text}
|
||||
</h5>
|
||||
);
|
||||
case 'h6':
|
||||
return (
|
||||
<h6 ref={ref} style={style} className={classes}>
|
||||
{text}
|
||||
</h6>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<p ref={ref} style={style} className={classes}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return renderTag();
|
||||
};
|
||||
|
||||
export default SplitText;
|
||||