Compare commits
No commits in common. "dev" and "main" have entirely different histories.
41
.gitignore
vendored
@ -1,41 +0,0 @@
|
|||||||
# 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
@ -1,36 +0,0 @@
|
|||||||
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.
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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'
|
|
||||||
|
|
||||||
// Move accessToken to HttpOnly cookie in future for better security
|
|
||||||
// Backend sets 'refreshToken' cookie on login; use it as auth presence
|
|
||||||
const AUTH_COOKIES = ['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*'],
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
10436
package-lock.json
generated
58
package.json
@ -1,58 +0,0 @@
|
|||||||
{
|
|
||||||
"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": {
|
|
||||||
"@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",
|
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"@tailwindplus/elements": "^1.0.15",
|
|
||||||
"@tailwindui/react": "^0.1.1",
|
|
||||||
"axios": "^1.12.2",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"country-flag-icons": "^1.5.21",
|
|
||||||
"country-select-js": "^2.1.0",
|
|
||||||
"intl-tel-input": "^25.10.11",
|
|
||||||
"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",
|
|
||||||
"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",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: ["@tailwindcss/postcss"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 15 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 40 MiB |
|
Before Width: | Height: | Size: 838 KiB |
|
Before Width: | Height: | Size: 833 KiB |
|
Before Width: | Height: | Size: 20 MiB |
|
Before Width: | Height: | Size: 5.9 MiB |
|
Before Width: | Height: | Size: 838 KiB |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@ -1,14 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,392 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@ -1,263 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import useContractManagement from '../hooks/useContractManagement';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onSaved?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ContractEditor({ onSaved }: 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 [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
|
|
||||||
const [description, setDescription] = useState<string>('');
|
|
||||||
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
||||||
|
|
||||||
const { uploadTemplate, updateTemplateState } = useContractManagement();
|
|
||||||
|
|
||||||
// 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() &&
|
|
||||||
description.trim() &&
|
|
||||||
type &&
|
|
||||||
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, description).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setStatusMsg(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build a file from HTML code
|
|
||||||
const file = new File([html], `${slug(name)}.html`, { type: 'text/html' });
|
|
||||||
const created = await uploadTemplate({
|
|
||||||
file,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
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) onSaved();
|
|
||||||
// Optionally clear fields
|
|
||||||
// setName(''); setHtmlCode(''); setDescription(''); setType('contract'); setLang('en');
|
|
||||||
} catch (e: any) {
|
|
||||||
setStatusMsg(e?.message || 'Save failed.');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<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>
|
|
||||||
<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)}
|
|
||||||
required
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
|
||||||
import useContractManagement from '../hooks/useContractManagement';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
refreshKey?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ContractTemplate = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
version: number;
|
|
||||||
status: 'draft' | 'published' | 'archived' | string;
|
|
||||||
updatedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 }: 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',
|
|
||||||
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';
|
|
||||||
try {
|
|
||||||
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
|
|
||||||
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
|
|
||||||
} 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} />
|
|
||||||
</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">
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,435 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,435 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import useAuthStore from '../../../store/authStore';
|
|
||||||
|
|
||||||
export type DocumentTemplate = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type?: 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;
|
|
||||||
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);
|
|
||||||
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;
|
|
||||||
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.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]);
|
|
||||||
|
|
||||||
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,
|
|
||||||
updateTemplate,
|
|
||||||
updateTemplateState,
|
|
||||||
generatePdfWithSignature,
|
|
||||||
listTemplatesPublic,
|
|
||||||
// stamps
|
|
||||||
listStampsAll,
|
|
||||||
listMyCompanyStamps,
|
|
||||||
getActiveCompanyStamp,
|
|
||||||
uploadCompanyStamp,
|
|
||||||
activateCompanyStamp,
|
|
||||||
deactivateCompanyStamp,
|
|
||||||
deleteCompanyStamp,
|
|
||||||
// utils
|
|
||||||
downloadBlobFile,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
'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');
|
|
||||||
|
|
||||||
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">
|
|
||||||
<div className="flex flex-col md:flex-row max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8 gap-8">
|
|
||||||
{/* Sidebar Navigation */}
|
|
||||||
<nav className="md:w-56 w-full flex md:flex-col flex-row gap-2 md:gap-4">
|
|
||||||
{NAV.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setSection(item.key)}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition
|
|
||||||
${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>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="flex-1 space-y-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-4">
|
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Contract Management</h1>
|
|
||||||
<p className="text-lg text-blue-700">
|
|
||||||
Manage contract templates, company stamp, and create new templates.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Section Panels */}
|
|
||||||
{section === 'stamp' && (
|
|
||||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 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-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} />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{section === 'editor' && (
|
|
||||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 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 onSaved={bumpRefresh} />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
'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 };
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
@ -1,246 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
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' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,527 +0,0 @@
|
|||||||
'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)
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
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!
|
|
||||||
}
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,224 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
@ -1,540 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
|
||||||
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, ...
|
|
||||||
|
|
||||||
export default function MatrixDetailPage() {
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
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`)
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
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' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
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' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,531 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,293 +0,0 @@
|
|||||||
'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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,307 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import PageLayout from '../components/PageLayout'
|
|
||||||
import {
|
|
||||||
UsersIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
CpuChipIcon,
|
|
||||||
ServerStackIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
Squares2X2Icon,
|
|
||||||
BanknotesIcon,
|
|
||||||
ClipboardDocumentListIcon
|
|
||||||
} from '@heroicons/react/24/outline'
|
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useAdminUsers } from '../hooks/useAdminUsers'
|
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const { userStats, isAdmin } = useAdminUsers()
|
|
||||||
const [isClient, setIsClient] = useState(false)
|
|
||||||
|
|
||||||
// Handle client-side mounting
|
|
||||||
useEffect(() => {
|
|
||||||
setIsClient(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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">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">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push('/admin/matrix-management')}
|
|
||||||
className="group w-full flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 px-4 py-4 transition"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<Squares2X2Icon className="h-6 w-6 text-blue-600" />
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push('/admin/subscriptions')}
|
|
||||||
className="group w-full flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 hover:bg-amber-100 px-4 py-4 transition"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-amber-100 border border-amber-200">
|
|
||||||
<BanknotesIcon className="h-6 w-6 text-amber-600" />
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-amber-600 opacity-70 group-hover:opacity-100" />
|
|
||||||
</button>
|
|
||||||
<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 transition"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
<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 transition"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push('/admin/news-management')}
|
|
||||||
className="group w-full flex items-center justify-between rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 px-4 py-4 transition"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-green-100 border border-green-200">
|
|
||||||
<ClipboardDocumentListIcon className="h-6 w-6 text-green-600" />
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-green-600 opacity-70 group-hover:opacity-100" />
|
|
||||||
</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>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
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: 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: 0,
|
|
||||||
createdAt: String(item.created_at ?? new Date().toISOString()),
|
|
||||||
})));
|
|
||||||
log("✅ Pools: Refresh succeeded, items:", apiItems.length);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,353 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
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'
|
|
||||||
|
|
||||||
type PoolUser = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
contributed: number
|
|
||||||
joinedAt: string // NEW: member since
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PoolManagePage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
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'))
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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[]>([])
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
// Early return AFTER all hooks are declared to keep consistent order
|
|
||||||
if (!authChecked) return null
|
|
||||||
|
|
||||||
// Remove dummy candidate source; keep search scaffolding returning empty
|
|
||||||
async function doSearch() {
|
|
||||||
setError('')
|
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
if (q.length < 3) {
|
|
||||||
setHasSearched(false)
|
|
||||||
setCandidates([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setHasSearched(true)
|
|
||||||
setLoading(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
setCandidates([]) // no local dummy results
|
|
||||||
setLoading(false)
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addUserFromModal(u: { id: string; name: string; email: string }) {
|
|
||||||
// Append user to pool; contribution stays zero; joinedAt is now.
|
|
||||||
setUsers(prev => [{ id: u.id, name: u.name, email: u.email, contributed: 0, joinedAt: new Date().toISOString() }, ...prev])
|
|
||||||
setSearchOpen(false)
|
|
||||||
setQuery('')
|
|
||||||
setCandidates([])
|
|
||||||
setHasSearched(false)
|
|
||||||
setError('')
|
|
||||||
setLoading(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>
|
|
||||||
))}
|
|
||||||
{users.length === 0 && (
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
<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-end bg-gray-50">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageTransitionEffect>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,294 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,289 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,289 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,522 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,393 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
|
||||||
import PageLayout from '../../components/PageLayout'
|
|
||||||
import UserDetailModal from '../../components/UserDetailModal'
|
|
||||||
import {
|
|
||||||
MagnifyingGlassIcon,
|
|
||||||
CheckIcon,
|
|
||||||
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'
|
|
||||||
|
|
||||||
export default function AdminUserVerifyPage() {
|
|
||||||
const {
|
|
||||||
pendingUsers,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
verifying,
|
|
||||||
verifyUser: handleVerifyUser,
|
|
||||||
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 [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}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
(fType === 'all' || u.user_type === fType) &&
|
|
||||||
(fRole === 'all' || u.role === fRole) &&
|
|
||||||
(
|
|
||||||
!search.trim() ||
|
|
||||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
fullName.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [pendingUsers, search, fType, fRole])
|
|
||||||
|
|
||||||
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 md:grid-cols-6 gap-6">
|
|
||||||
<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>
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
<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 className="flex items-stretch">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full inline-flex items-center justify-center 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>
|
|
||||||
</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 isVerifying = verifying.has(u.id.toString())
|
|
||||||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
|
||||||
|
|
||||||
// Check if user has completed all verification steps
|
|
||||||
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
|
|
||||||
u.documents_uploaded === 1 && u.contract_signed === 1
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
{isReadyToVerify ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleVerifyUser(u.id.toString())}
|
|
||||||
disabled={isVerifying}
|
|
||||||
className={`inline-flex items-center gap-1 rounded-lg border px-3 py-2 text-xs font-medium transition
|
|
||||||
${isVerifying
|
|
||||||
? 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
: 'border-emerald-200 bg-emerald-50 hover:bg-emerald-100 text-emerald-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isVerifying ? (
|
|
||||||
<>
|
|
||||||
<span className="h-3 w-3 rounded-full border-2 border-emerald-500 border-b-transparent animate-spin" />
|
|
||||||
Verifying...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="h-4 w-4" /> Verify
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-500 italic">Incomplete steps</span>
|
|
||||||
)}
|
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
</PageLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
|
||||||
import PageLayout from '../components/PageLayout'
|
|
||||||
|
|
||||||
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="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 (aligned with management pages) */}
|
|
||||||
<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">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>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GlobalAnimatedBackground() {
|
|
||||||
// Always use dashboard style for a uniform look
|
|
||||||
const bgGradient = "linear-gradient(135deg, #1e293b 0%, #334155 100%)";
|
|
||||||
|
|
||||||
// Detect small screens (mobile/tablet)
|
|
||||||
const isMobile = isMobileDevice();
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
// Render only the static background gradient and overlay, no animation
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 0,
|
|
||||||
width: "100vw",
|
|
||||||
height: "100vh",
|
|
||||||
background: bgGradient,
|
|
||||||
transition: "background 0.5s",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 0,
|
|
||||||
width: "100vw",
|
|
||||||
height: "100vh",
|
|
||||||
background: bgGradient,
|
|
||||||
transition: "background 0.5s",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{/* Overlays */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
|
|
||||||
<div className="absolute top-10 left-10 w-64 h-1 bg-blue-300 opacity-50 animate-slide-loop"></div>
|
|
||||||
<div className="absolute bottom-20 right-20 w-48 h-1 bg-blue-200 opacity-40 animate-slide-loop"></div>
|
|
||||||
<div className="absolute top-1/3 left-1/4 w-72 h-1 bg-blue-400 opacity-30 animate-slide-loop"></div>
|
|
||||||
<div className="absolute top-16 left-1/3 w-32 h-32 bg-blue-500 rounded-full opacity-50 animate-float"></div>
|
|
||||||
<div className="absolute bottom-24 right-1/4 w-40 h-40 bg-blue-600 rounded-full opacity-40 animate-float"></div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-24 h-24 bg-blue-700 rounded-full opacity-30 animate-float"></div>
|
|
||||||
<div className="absolute top-1/4 left-1/5 w-20 h-20 bg-blue-300 rounded-lg opacity-40 animate-float-slow"></div>
|
|
||||||
<div className="absolute bottom-1/3 right-1/3 w-28 h-28 bg-blue-400 rounded-lg opacity-30 animate-float-slow"></div>
|
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
@keyframes float {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes float-slow {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes slide-loop {
|
|
||||||
0% {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(-100vw);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animate-float {
|
|
||||||
animation: float 6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.animate-float-slow {
|
|
||||||
animation: float-slow 8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.animate-slide-loop {
|
|
||||||
animation: slide-loop 12s linear infinite;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GlobalAnimatedBackground;
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@ -1,307 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
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[]
|
|
||||||
}
|
|
||||||
@ -1,413 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,284 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
'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}</>
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PageLayout({
|
|
||||||
children,
|
|
||||||
showHeader = true,
|
|
||||||
showFooter = true
|
|
||||||
}: PageLayoutProps) {
|
|
||||||
const isMobile = isMobileDevice();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen w-full flex flex-col bg-white text-gray-900">
|
|
||||||
|
|
||||||
{showHeader && (
|
|
||||||
<div className="relative z-50 w-full flex-shrink-0">
|
|
||||||
<Header />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="flex-1 relative z-10 w-full">
|
|
||||||
<PageTransitionEffect>{children}</PageTransitionEffect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showFooter && (
|
|
||||||
<div className="relative z-50 w-full flex-shrink-0">
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,339 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Fragment } from 'react'
|
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
|
||||||
import {
|
|
||||||
XMarkIcon,
|
|
||||||
EnvelopeIcon,
|
|
||||||
IdentificationIcon,
|
|
||||||
UserIcon,
|
|
||||||
DocumentTextIcon,
|
|
||||||
ClockIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
HandRaisedIcon,
|
|
||||||
HeartIcon,
|
|
||||||
CheckIcon
|
|
||||||
} from '@heroicons/react/24/outline'
|
|
||||||
|
|
||||||
interface TutorialStep {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
details: string[]
|
|
||||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
|
||||||
buttonText: string
|
|
||||||
buttonAction: () => void
|
|
||||||
canProceed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TutorialModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
currentStep: number
|
|
||||||
steps: TutorialStep[]
|
|
||||||
onNext: () => void
|
|
||||||
onPrevious: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TutorialModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
currentStep,
|
|
||||||
steps,
|
|
||||||
onNext,
|
|
||||||
onPrevious
|
|
||||||
}: TutorialModalProps) {
|
|
||||||
const step = steps[currentStep - 1]
|
|
||||||
|
|
||||||
if (!step) return null
|
|
||||||
|
|
||||||
const isLastStep = currentStep === steps.length
|
|
||||||
const isFirstStep = currentStep === 1
|
|
||||||
|
|
||||||
// Helper function to check if step is completed
|
|
||||||
const isStepCompleted = (stepId: number) => {
|
|
||||||
if (stepId === 2) return step.buttonText.includes("✅") // Email verified
|
|
||||||
if (stepId === 3) return step.buttonText.includes("✅") // ID uploaded
|
|
||||||
if (stepId === 4) return step.buttonText.includes("✅") // Profile completed
|
|
||||||
if (stepId === 5) return step.buttonText.includes("✅") // Agreement signed
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get clean button text without emoji
|
|
||||||
const getCleanButtonText = (text: string) => {
|
|
||||||
return text.replace(/✅/g, '').trim().replace(/!$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepCompleted = isStepCompleted(step.id)
|
|
||||||
const buttonText = stepCompleted ? getCleanButtonText(step.buttonText) : step.buttonText
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-blue-900/60 backdrop-blur-sm transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative w-full max-w-5xl h-[60vh]">
|
|
||||||
<div className="relative isolate overflow-hidden bg-slate-50 h-full after:pointer-events-none after:absolute after:inset-0 after:inset-ring after:inset-ring-gray-200/50 sm:rounded-3xl after:sm:rounded-3xl lg:flex lg:gap-x-12 lg:px-8 w-full">
|
|
||||||
{/* Background Gradient */}
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 1024 1024"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute -top-48 -left-48 -z-10 size-96 mask-[radial-gradient(closest-side,white,transparent)]"
|
|
||||||
>
|
|
||||||
<circle r={512} cx={512} cy={512} fill="url(#tutorial-gradient)" fillOpacity="0.7" />
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="tutorial-gradient">
|
|
||||||
<stop stopColor="#3B82F6" />
|
|
||||||
<stop offset={1} stopColor="#8B5CF6" />
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Close Button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-4 top-4 z-10 rounded-md bg-gray-200/70 p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-300/70 focus:outline-none focus:ring-2 focus:ring-blue-500 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Content Section - Left Half */}
|
|
||||||
<div className="lg:flex-1 lg:max-w-md text-center lg:text-left py-8 px-6 flex flex-col justify-center">
|
|
||||||
{/* Icon */}
|
|
||||||
<div className="mx-auto lg:mx-0 flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mb-4 ring-2 ring-blue-200">
|
|
||||||
<step.icon className="h-6 w-6 text-blue-600" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h2 className="text-xl font-semibold tracking-tight whitespace-nowrap text-gray-800 sm:text-2xl">
|
|
||||||
{step.title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p className="mt-3 text-sm text-gray-600 leading-relaxed h-12 overflow-hidden">
|
|
||||||
{step.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Details */}
|
|
||||||
<ul className="mt-4 text-left text-gray-600 space-y-2">
|
|
||||||
{step.details.map((detail, index) => (
|
|
||||||
<li key={index} className="flex items-start gap-2">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-blue-500 mt-1.5 flex-shrink-0" />
|
|
||||||
<span className="text-xs leading-5">{detail}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Progress indicator */}
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-2">
|
|
||||||
<span>Step {currentStep} of {steps.length}</span>
|
|
||||||
<span>{Math.round((currentStep / steps.length) * 100)}% Complete</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-1.5 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${(currentStep / steps.length) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="mt-6 flex items-center justify-center gap-x-3 lg:justify-start">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={step.buttonAction}
|
|
||||||
disabled={(!step.canProceed && currentStep !== steps.length) || buttonText.includes("Waiting for admin review") || stepCompleted}
|
|
||||||
className={`rounded-md px-3 py-2 text-sm font-semibold inset-ring inset-ring-gray-200/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all flex items-center gap-2 ${
|
|
||||||
stepCompleted
|
|
||||||
? 'bg-green-600 text-white'
|
|
||||||
: (step.canProceed || currentStep === steps.length) && !buttonText.includes("Waiting for admin review")
|
|
||||||
? 'bg-blue-600 text-white hover:bg-blue-500 shadow-lg hover:shadow-xl'
|
|
||||||
: 'bg-gray-300 text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{stepCompleted && <CheckIcon className="h-4 w-4" />}
|
|
||||||
{buttonText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
{(!isFirstStep || !isLastStep) && (
|
|
||||||
<div className="mt-4 flex items-center justify-center gap-x-4 lg:justify-start">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onPrevious}
|
|
||||||
disabled={isFirstStep}
|
|
||||||
className={`text-xs font-semibold transition-colors ${
|
|
||||||
isFirstStep
|
|
||||||
? 'text-slate-50 cursor-default'
|
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
← Go back
|
|
||||||
</button>
|
|
||||||
{!isLastStep && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onNext}
|
|
||||||
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Continue →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Visual Section - Right Half */}
|
|
||||||
<div className="relative lg:flex-1 mt-4 lg:mt-0 h-32 lg:h-full lg:min-h-[150px] flex items-end justify-end">
|
|
||||||
{/* <img
|
|
||||||
src="/images/misc/cow.png"
|
|
||||||
alt="Profit Planet Mascot"
|
|
||||||
className="max-h-full max-w-full object-contain opacity-90 pl-30"
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tutorial step data
|
|
||||||
export const createTutorialSteps = (
|
|
||||||
emailVerified: boolean,
|
|
||||||
idUploaded: boolean,
|
|
||||||
additionalInfo: boolean,
|
|
||||||
contractSigned: boolean,
|
|
||||||
userType: string,
|
|
||||||
onVerifyEmail: () => void,
|
|
||||||
onUploadId: () => void,
|
|
||||||
onCompleteInfo: () => void,
|
|
||||||
onSignContract: () => void,
|
|
||||||
onCloseTutorial: () => void,
|
|
||||||
onNext: () => void
|
|
||||||
): TutorialStep[] => [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "Hello there! 👋 Welcome to Profit Planet",
|
|
||||||
description: "We're so happy you've decided to join us! This quick tutorial will guide you through setting up your account in just a few simple steps. Let's make this journey together - it'll only take a few minutes!",
|
|
||||||
details: [
|
|
||||||
"We'll walk you through each step personally",
|
|
||||||
"Everything is designed to be simple and clear",
|
|
||||||
"You can skip steps if you want to come back later",
|
|
||||||
"Our team is here to help if you need anything"
|
|
||||||
],
|
|
||||||
icon: HandRaisedIcon,
|
|
||||||
buttonText: "Let's get started! 🚀",
|
|
||||||
buttonAction: onNext,
|
|
||||||
canProceed: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Let's verify your email address 📧",
|
|
||||||
description: "First things first - we'd love to make sure we can reach you! Please check your email inbox for a friendly message from us and the verification code.",
|
|
||||||
details: [
|
|
||||||
"Check your email inbox for our welcome message",
|
|
||||||
"Copy & paste the verification code into the field",
|
|
||||||
"Don't see it? Check your spam folder - sometimes it hides there",
|
|
||||||
"Need a new email? Just click below and we'll send another"
|
|
||||||
],
|
|
||||||
icon: EnvelopeIcon,
|
|
||||||
buttonText: emailVerified ? "Email verified! ✅" : "Verify my email",
|
|
||||||
buttonAction: emailVerified ? onNext : onVerifyEmail,
|
|
||||||
canProceed: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Time to upload your ID 📋",
|
|
||||||
description: "Now we need to get to know you better! Please upload a clear photo of your official ID. Don't worry - this information is completely secure and helps us keep everyone safe.",
|
|
||||||
details: [
|
|
||||||
"Take a clear, well-lit photo of your ID document",
|
|
||||||
"Make sure all text is easily readable",
|
|
||||||
"Passport, driver's license, or national ID all work perfectly",
|
|
||||||
"We protect your privacy - this is just for verification"
|
|
||||||
],
|
|
||||||
icon: IdentificationIcon,
|
|
||||||
buttonText: idUploaded ? "ID uploaded! ✅" : "Upload my ID",
|
|
||||||
buttonAction: idUploaded ? onNext : onUploadId,
|
|
||||||
canProceed: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "Complete your profile 👤",
|
|
||||||
description: `Almost there! Now let's fill out your ${userType === 'personal' ? 'personal' : 'company'} profile. This helps us customize your experience and ensure everything runs smoothly.`,
|
|
||||||
details: userType === 'personal' ? [
|
|
||||||
"Share your full name and date of birth with us",
|
|
||||||
"Add your current address (we keep this private)",
|
|
||||||
"Include a phone number so we can reach you if needed",
|
|
||||||
"All information is required for account security"
|
|
||||||
] : [
|
|
||||||
"Tell us about your company and business details",
|
|
||||||
"Add your business address and contact information",
|
|
||||||
"Upload any business documents we might need",
|
|
||||||
"Make sure everything matches your official records"
|
|
||||||
],
|
|
||||||
icon: UserIcon,
|
|
||||||
buttonText: additionalInfo ? "Profile completed! ✅" : "Complete my profile",
|
|
||||||
buttonAction: additionalInfo ? onNext : onCompleteInfo,
|
|
||||||
canProceed: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Ready to sign your contract! 📝",
|
|
||||||
description: "Perfect! You've completed all the preparation steps. Now it's time to review and sign your personalized contract to finalize your account setup.",
|
|
||||||
details: [
|
|
||||||
"Review the terms and conditions carefully",
|
|
||||||
"Your contract has been prepared based on your information",
|
|
||||||
"Digital signature makes the process quick and secure",
|
|
||||||
"This is the final step to activate your account"
|
|
||||||
],
|
|
||||||
icon: DocumentTextIcon,
|
|
||||||
buttonText: contractSigned ? "Contract signed! ✅" : "Review & sign contract",
|
|
||||||
buttonAction: contractSigned ? onNext : onSignContract,
|
|
||||||
canProceed: emailVerified && idUploaded && additionalInfo
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: "You're all set! 🎉 Welcome to the family",
|
|
||||||
description: "Congratulations! Our team will now review your information and have you approved very soon!",
|
|
||||||
details: [
|
|
||||||
"Our team will carefully review everything you've submitted",
|
|
||||||
"This usually takes just 1-2 business days",
|
|
||||||
"We'll send you a celebratory email once you're approved",
|
|
||||||
"In the meantime, feel free to explore your dashboard"
|
|
||||||
],
|
|
||||||
icon: HeartIcon,
|
|
||||||
buttonText: "Perfect! I understand 💫",
|
|
||||||
buttonAction: onCloseTutorial,
|
|
||||||
canProceed: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@ -1,757 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Fragment, useState, useEffect } from 'react'
|
|
||||||
import { Dialog, Transition, Listbox } from '@headlessui/react'
|
|
||||||
import {
|
|
||||||
XMarkIcon,
|
|
||||||
UserIcon,
|
|
||||||
DocumentTextIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
EnvelopeIcon,
|
|
||||||
PhoneIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
BuildingOfficeIcon,
|
|
||||||
IdentificationIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
ChevronUpDownIcon,
|
|
||||||
CheckIcon
|
|
||||||
} from '@heroicons/react/24/outline'
|
|
||||||
import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
|
||||||
import useAuthStore from '../store/authStore'
|
|
||||||
|
|
||||||
interface UserDetailModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
userId: string | null
|
|
||||||
onUserUpdated?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserStatus = 'inactive' | 'pending' | 'active' | 'suspended' | 'archived'
|
|
||||||
|
|
||||||
const STATUS_OPTIONS: { value: UserStatus; label: string; color: string }[] = [
|
|
||||||
{ value: 'pending', label: 'Pending', color: 'amber' },
|
|
||||||
{ value: 'active', label: 'Active', color: 'green' },
|
|
||||||
{ value: 'suspended', label: 'Suspended', color: 'rose' },
|
|
||||||
{ value: 'archived', label: 'Archived', color: 'gray' },
|
|
||||||
{ value: 'inactive', label: 'Inactive', color: 'gray' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
|
||||||
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<UserStatus>('pending')
|
|
||||||
const token = useAuthStore(state => state.accessToken)
|
|
||||||
|
|
||||||
// Contract preview state (lazy-loaded)
|
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
|
||||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
|
||||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && userId && token) {
|
|
||||||
fetchUserDetails()
|
|
||||||
}
|
|
||||||
}, [isOpen, userId, token])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (userDetails?.userStatus?.status) {
|
|
||||||
setSelectedStatus(userDetails.userStatus.status as UserStatus)
|
|
||||||
}
|
|
||||||
}, [userDetails])
|
|
||||||
|
|
||||||
const fetchUserDetails = async () => {
|
|
||||||
if (!userId || !token) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await AdminAPI.getDetailedUserInfo(token, userId)
|
|
||||||
if (response.success) {
|
|
||||||
setUserDetails(response)
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to fetch user details')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch user details'
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('UserDetailModal.fetchUserDetails error:', err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStatusChange = async (newStatus: UserStatus) => {
|
|
||||||
if (!userId || !token || newStatus === selectedStatus) return
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await AdminAPI.updateUserStatus(token, userId, newStatus)
|
|
||||||
if (response.success) {
|
|
||||||
setSelectedStatus(newStatus)
|
|
||||||
await fetchUserDetails()
|
|
||||||
if (onUserUpdated) {
|
|
||||||
onUserUpdated()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to update user status')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update user status'
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('UserDetailModal.handleStatusChange error:', err)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleAdminVerification = async () => {
|
|
||||||
if (!userId || !token || !userDetails?.userStatus) return
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newValue = userDetails.userStatus.is_admin_verified === 1 ? 0 : 1
|
|
||||||
const response = await AdminAPI.updateUserVerification(token, userId, newValue)
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
await fetchUserDetails()
|
|
||||||
if (onUserUpdated) {
|
|
||||||
onUserUpdated()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to update verification status')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update verification status'
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('UserDetailModal.handleToggleAdminVerification error:', err)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadContractPreview = async () => {
|
|
||||||
if (!userId || !token || !userDetails) return
|
|
||||||
setPreviewLoading(true)
|
|
||||||
setPreviewError(null)
|
|
||||||
try {
|
|
||||||
const html = await AdminAPI.getContractPreviewHtml(token, String(userId), userDetails.user.user_type)
|
|
||||||
setPreviewHtml(html)
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('UserDetailModal.loadContractPreview error:', e)
|
|
||||||
setPreviewError(e?.message || 'Failed to load contract preview')
|
|
||||||
setPreviewHtml(null)
|
|
||||||
} finally {
|
|
||||||
setPreviewLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined | null) => {
|
|
||||||
if (!dateString) return 'N/A'
|
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number | undefined) => {
|
|
||||||
if (!bytes) return '0 B'
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status: UserStatus) => {
|
|
||||||
const option = STATUS_OPTIONS.find(opt => opt.value === status)
|
|
||||||
return option?.color || 'gray'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusBadgeClass = (color: string) => {
|
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
amber: 'bg-amber-100 text-amber-800 border-amber-200',
|
|
||||||
green: 'bg-green-100 text-green-800 border-green-200',
|
|
||||||
rose: 'bg-rose-100 text-rose-800 border-rose-200',
|
|
||||||
gray: 'bg-gray-100 text-gray-800 border-gray-200'
|
|
||||||
}
|
|
||||||
return colorMap[color] || colorMap.gray
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-6">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white shadow-xl transition-all w-full max-w-5xl max-h-[85vh] flex flex-col">
|
|
||||||
{/* Close Button */}
|
|
||||||
<div className="absolute right-0 top-0 z-10 pr-4 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable Content Area */}
|
|
||||||
<div className="overflow-y-auto px-4 pb-4 pt-5 sm:p-6">
|
|
||||||
<div className="w-full">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex justify-center items-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="rounded-md bg-red-50 p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
|
||||||
<div className="mt-2 text-sm text-red-700">{error}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : userDetails ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header Section with User Info & Status */}
|
|
||||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg px-6 py-8 text-white">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-full">
|
|
||||||
{userDetails.user.user_type === 'company' ? (
|
|
||||||
<BuildingOfficeIcon className="h-10 w-10 text-white" />
|
|
||||||
) : (
|
|
||||||
<UserIcon className="h-10 w-10 text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
|
||||||
<h2 className="text-2xl font-bold">
|
|
||||||
{userDetails.user.user_type === 'personal'
|
|
||||||
? `${userDetails.personalProfile?.first_name || ''} ${userDetails.personalProfile?.last_name || ''}`.trim()
|
|
||||||
: userDetails.companyProfile?.company_name || 'Unknown'}
|
|
||||||
</h2>
|
|
||||||
<p className="text-indigo-100 mt-1">{userDetails.user.email}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-3">
|
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
|
||||||
userDetails.user.user_type === 'personal'
|
|
||||||
? 'bg-blue-100 text-blue-800'
|
|
||||||
: 'bg-purple-100 text-purple-800'
|
|
||||||
}`}>
|
|
||||||
{userDetails.user.user_type === 'personal' ? 'Personal' : 'Company'}
|
|
||||||
</span>
|
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
|
||||||
userDetails.user.role === 'admin' || userDetails.user.role === 'super_admin'
|
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{userDetails.user.role === 'super_admin' ? 'Super Admin' : userDetails.user.role}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Badge */}
|
|
||||||
{userDetails.userStatus && (
|
|
||||||
<div className="bg-white rounded-lg px-4 py-3 text-gray-900">
|
|
||||||
<div className="text-xs text-gray-500 mb-1">Current Status</div>
|
|
||||||
<div className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold border ${
|
|
||||||
getStatusBadgeClass(getStatusColor(userDetails.userStatus.status as UserStatus))
|
|
||||||
}`}>
|
|
||||||
{userDetails.userStatus.status.charAt(0).toUpperCase() + userDetails.userStatus.status.slice(1)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin Controls Section */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-6 border border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
||||||
<ShieldCheckIcon className="h-5 w-5 text-indigo-600" />
|
|
||||||
Admin Controls
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{/* Status Dropdown */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Change Status
|
|
||||||
</label>
|
|
||||||
<Listbox value={selectedStatus} onChange={handleStatusChange} disabled={saving}>
|
|
||||||
<div className="relative">
|
|
||||||
<Listbox.Button className="relative w-full cursor-pointer rounded-lg bg-white py-2.5 pl-3 pr-10 text-left border border-gray-300 hover:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-black">
|
|
||||||
<span className="block truncate font-medium text-black">
|
|
||||||
{STATUS_OPTIONS.find(opt => opt.value === selectedStatus)?.label || selectedStatus}
|
|
||||||
</span>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
</Listbox.Button>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
|
||||||
{STATUS_OPTIONS.map((option) => (
|
|
||||||
<Listbox.Option
|
|
||||||
key={option.value}
|
|
||||||
className={({ active }) =>
|
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
|
||||||
active ? 'bg-indigo-100 text-indigo-900' : 'text-gray-900'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={`block truncate ${selected ? 'font-semibold' : 'font-normal'}`}>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-indigo-600">
|
|
||||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
))}
|
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin Verification Toggle */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Admin Verification
|
|
||||||
</label>
|
|
||||||
{userDetails?.userStatus && (
|
|
||||||
<p className="text-xs text-gray-500 mb-2">
|
|
||||||
{userDetails.userStatus.email_verified === 1 && userDetails.userStatus.profile_completed === 1 && userDetails.userStatus.documents_uploaded === 1 && userDetails.userStatus.contract_signed === 1
|
|
||||||
? 'All steps completed. You can verify this user.'
|
|
||||||
: 'User has not yet completed all required steps.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleToggleAdminVerification}
|
|
||||||
disabled={saving || !(userDetails?.userStatus && userDetails.userStatus.email_verified === 1 && userDetails.userStatus.profile_completed === 1 && userDetails.userStatus.documents_uploaded === 1 && userDetails.userStatus.contract_signed === 1)}
|
|
||||||
title={!(userDetails?.userStatus && userDetails.userStatus.email_verified === 1 && userDetails.userStatus.profile_completed === 1 && userDetails.userStatus.documents_uploaded === 1 && userDetails.userStatus.contract_signed === 1) ? 'Complete all steps before admin verification' : undefined}
|
|
||||||
className={`w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
||||||
userDetails.userStatus?.is_admin_verified === 1
|
|
||||||
? 'bg-amber-600 hover:bg-amber-500 text-white focus-visible:outline-amber-600'
|
|
||||||
: 'bg-green-600 hover:bg-green-500 text-white focus-visible:outline-green-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
|
|
||||||
Updating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ShieldCheckIcon className="h-4 w-4" />
|
|
||||||
{userDetails.userStatus?.is_admin_verified === 1 ? 'Unverify User' : 'Verify User'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contract Preview (admin verify flow) */}
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
|
|
||||||
Contract Preview
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={loadContractPreview}
|
|
||||||
disabled={previewLoading}
|
|
||||||
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{previewLoading ? 'Loading…' : (previewHtml ? 'Refresh Preview' : 'Load Preview')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!previewHtml) return
|
|
||||||
const blob = new Blob([previewHtml], { type: 'text/html' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
|
||||||
}}
|
|
||||||
disabled={!previewHtml}
|
|
||||||
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm disabled:opacity-60"
|
|
||||||
>
|
|
||||||
Open in new tab
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
{previewError && (
|
|
||||||
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 mb-4">
|
|
||||||
{previewError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{previewLoading && (
|
|
||||||
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
|
|
||||||
Loading preview…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!previewLoading && previewHtml && (
|
|
||||||
<div className="rounded-md border border-gray-200 overflow-hidden">
|
|
||||||
<iframe
|
|
||||||
title="Contract Preview"
|
|
||||||
className="w-full h-[600px] bg-white"
|
|
||||||
srcDoc={previewHtml}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!previewLoading && !previewHtml && !previewError && (
|
|
||||||
<p className="text-sm text-gray-500">Click "Load Preview" to render the latest active contract template for this user.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Profile Information */}
|
|
||||||
{userDetails.user.user_type === 'personal' && userDetails.personalProfile && (
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<UserIcon className="h-5 w-5 text-gray-600" />
|
|
||||||
Personal Information
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">First Name</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.first_name || 'N/A'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">Last Name</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.last_name || 'N/A'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
|
||||||
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
|
||||||
Phone
|
|
||||||
</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.phone || 'N/A'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
|
||||||
<CalendarIcon className="h-4 w-4 inline mr-1.5" />
|
|
||||||
Date of Birth
|
|
||||||
</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">{formatDate(userDetails.personalProfile.date_of_birth)}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
|
||||||
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
|
||||||
Address
|
|
||||||
</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">
|
|
||||||
{userDetails.personalProfile.address || 'N/A'}
|
|
||||||
{userDetails.personalProfile.city && <>, {userDetails.personalProfile.city}</>}
|
|
||||||
{userDetails.personalProfile.zip_code && <>, {userDetails.personalProfile.zip_code}</>}
|
|
||||||
{userDetails.personalProfile.country && <>, {userDetails.personalProfile.country}</>}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Company Profile Information */}
|
|
||||||
{userDetails.user.user_type === 'company' && userDetails.companyProfile && (
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<BuildingOfficeIcon className="h-5 w-5 text-gray-600" />
|
|
||||||
Company Information
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">Company Name</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.company_name || 'N/A'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">Registration Number</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.registration_number || 'N/A'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">Tax ID</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.tax_id || 'N/A'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
|
||||||
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
|
||||||
Phone
|
|
||||||
</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.phone || 'N/A'}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
|
||||||
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
|
||||||
Address
|
|
||||||
</dt>
|
|
||||||
<dd className="text-sm text-gray-900 font-medium">
|
|
||||||
{userDetails.companyProfile.address || 'N/A'}
|
|
||||||
{userDetails.companyProfile.city && <>, {userDetails.companyProfile.city}</>}
|
|
||||||
{userDetails.companyProfile.zip_code && <>, {userDetails.companyProfile.zip_code}</>}
|
|
||||||
{userDetails.companyProfile.country && <>, {userDetails.companyProfile.country}</>}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Account Status */}
|
|
||||||
{userDetails.userStatus && (
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<CheckCircleIcon className="h-5 w-5 text-gray-600" />
|
|
||||||
Registration Progress
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{userDetails.userStatus.email_verified === 1 ? (
|
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircleIcon className="h-6 w-6 text-gray-300" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium text-gray-700">Email Verified</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{userDetails.userStatus.profile_completed === 1 ? (
|
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircleIcon className="h-6 w-6 text-gray-300" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium text-gray-700">Profile Completed</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{userDetails.userStatus.documents_uploaded === 1 ? (
|
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircleIcon className="h-6 w-6 text-gray-300" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium text-gray-700">Documents Uploaded</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{userDetails.userStatus.contract_signed === 1 ? (
|
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircleIcon className="h-6 w-6 text-gray-300" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium text-gray-700">Contract Signed</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Documents Section */}
|
|
||||||
{(userDetails.documents.length > 0 || userDetails.contracts.length > 0 || userDetails.idDocuments.length > 0) && (
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
|
|
||||||
Documents ({userDetails.documents.length + userDetails.contracts.length + userDetails.idDocuments.length})
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5 space-y-4">
|
|
||||||
{/* Regular Documents */}
|
|
||||||
{userDetails.documents.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Uploaded Documents</h5>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{userDetails.documents.map((doc) => (
|
|
||||||
<div key={doc.id} className="flex items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-200">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<DocumentTextIcon className="h-5 w-5 text-gray-400" />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-gray-900">{doc.file_name}</div>
|
|
||||||
<div className="text-xs text-gray-500">{formatFileSize(doc.file_size)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-500">{formatDate(doc.uploaded_at)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Contracts */}
|
|
||||||
{userDetails.contracts.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Contracts</h5>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{userDetails.contracts.map((contract) => (
|
|
||||||
<div key={contract.id} className="flex items-center justify-between bg-blue-50 p-3 rounded-lg border border-blue-200">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<DocumentTextIcon className="h-5 w-5 text-blue-600" />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-gray-900">{contract.file_name}</div>
|
|
||||||
<div className="text-xs text-gray-500">{formatFileSize(contract.file_size)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-500">{formatDate(contract.uploaded_at)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ID Documents */}
|
|
||||||
{userDetails.idDocuments.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h5 className="text-sm font-medium text-gray-700 mb-3">ID Documents</h5>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{userDetails.idDocuments.map((idDoc) => (
|
|
||||||
<div key={idDoc.id} className="bg-purple-50 p-4 rounded-lg border border-purple-200">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<IdentificationIcon className="h-5 w-5 text-purple-600" />
|
|
||||||
<span className="text-sm font-medium text-gray-900">{idDoc.document_type}</span>
|
|
||||||
<span className="text-xs text-gray-500 ml-auto">{formatDate(idDoc.uploaded_at)}</span>
|
|
||||||
</div>
|
|
||||||
{(idDoc.frontUrl || idDoc.backUrl) && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
{idDoc.frontUrl && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-gray-600 mb-2">Front</p>
|
|
||||||
<img
|
|
||||||
src={idDoc.frontUrl}
|
|
||||||
alt="ID Front"
|
|
||||||
className="w-full h-40 object-cover rounded border border-gray-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{idDoc.backUrl && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-gray-600 mb-2">Back</p>
|
|
||||||
<img
|
|
||||||
src={idDoc.backUrl}
|
|
||||||
alt="ID Back"
|
|
||||||
className="w-full h-40 object-cover rounded border border-gray-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Permissions */}
|
|
||||||
{userDetails.permissions.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<ShieldCheckIcon className="h-5 w-5 text-gray-600" />
|
|
||||||
Permissions ({userDetails.permissions.length})
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{userDetails.permissions.map((perm) => (
|
|
||||||
<div
|
|
||||||
key={perm.id}
|
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg border ${
|
|
||||||
perm.is_active
|
|
||||||
? 'bg-green-50 border-green-200'
|
|
||||||
: 'bg-gray-50 border-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{perm.is_active ? (
|
|
||||||
<CheckCircleIcon className="h-5 w-5 text-green-600 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<XCircleIcon className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-gray-900">{perm.name}</div>
|
|
||||||
{perm.description && (
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{perm.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2.5 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-gray-500"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,860 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Fragment, useState, useEffect } from 'react'
|
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
|
||||||
import {
|
|
||||||
XMarkIcon,
|
|
||||||
UserIcon,
|
|
||||||
DocumentTextIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
EnvelopeIcon,
|
|
||||||
PhoneIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
BuildingOfficeIcon,
|
|
||||||
IdentificationIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
PencilSquareIcon,
|
|
||||||
TrashIcon,
|
|
||||||
ExclamationTriangleIcon
|
|
||||||
} from '@heroicons/react/24/outline'
|
|
||||||
import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
|
||||||
import useAuthStore from '../store/authStore'
|
|
||||||
|
|
||||||
interface UserDetailModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
userId: string | null
|
|
||||||
onUserUpdated?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
|
||||||
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [archiving, setArchiving] = useState(false)
|
|
||||||
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false)
|
|
||||||
const [editedProfile, setEditedProfile] = useState<any>(null)
|
|
||||||
const token = useAuthStore(state => state.accessToken)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && userId && token) {
|
|
||||||
fetchUserDetails()
|
|
||||||
setIsEditing(false)
|
|
||||||
setShowArchiveConfirm(false)
|
|
||||||
setEditedProfile(null)
|
|
||||||
}
|
|
||||||
}, [isOpen, userId, token])
|
|
||||||
|
|
||||||
const fetchUserDetails = async () => {
|
|
||||||
if (!userId || !token) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await AdminAPI.getDetailedUserInfo(token, userId)
|
|
||||||
if (response.success) {
|
|
||||||
setUserDetails(response)
|
|
||||||
// Initialize edited profile with current data
|
|
||||||
if (response.personalProfile) {
|
|
||||||
setEditedProfile(response.personalProfile)
|
|
||||||
} else if (response.companyProfile) {
|
|
||||||
setEditedProfile(response.companyProfile)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to fetch user details')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch user details'
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('UserDetailModal.fetchUserDetails error:', err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleArchiveUser = async () => {
|
|
||||||
if (!userId || !token) return
|
|
||||||
|
|
||||||
setArchiving(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isCurrentlyInactive = userDetails?.userStatus?.status === 'inactive'
|
|
||||||
|
|
||||||
if (isCurrentlyInactive) {
|
|
||||||
// Unarchive user
|
|
||||||
const response = await AdminAPI.unarchiveUser(token, userId)
|
|
||||||
if (response.success) {
|
|
||||||
onClose()
|
|
||||||
if (onUserUpdated) {
|
|
||||||
onUserUpdated()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to unarchive user')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Archive user
|
|
||||||
const response = await AdminAPI.archiveUser(token, userId)
|
|
||||||
if (response.success) {
|
|
||||||
onClose()
|
|
||||||
if (onUserUpdated) {
|
|
||||||
onUserUpdated()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to archive user')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to archive/unarchive user'
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('UserDetailModal.handleArchiveUser error:', err)
|
|
||||||
} finally {
|
|
||||||
setArchiving(false)
|
|
||||||
setShowArchiveConfirm(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
|
||||||
if (!userId || !token || !editedProfile || !userDetails) return
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userType = userDetails.user.user_type
|
|
||||||
const response = await AdminAPI.updateUserProfile(token, userId, editedProfile, userType)
|
|
||||||
if (response.success) {
|
|
||||||
// Refresh user details
|
|
||||||
await fetchUserDetails()
|
|
||||||
setIsEditing(false)
|
|
||||||
if (onUserUpdated) {
|
|
||||||
onUserUpdated()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to update user profile')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update user profile'
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('UserDetailModal.handleSaveProfile error:', err)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleAdminVerification = async () => {
|
|
||||||
if (!userId || !token || !userDetails) return
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newVerificationStatus = userDetails.userStatus?.is_admin_verified === 1 ? 0 : 1
|
|
||||||
// Note: You'll need to implement this API method
|
|
||||||
const response = await AdminAPI.updateUserVerification(token, userId, newVerificationStatus)
|
|
||||||
if (response.success) {
|
|
||||||
// Refresh user details
|
|
||||||
await fetchUserDetails()
|
|
||||||
if (onUserUpdated) {
|
|
||||||
onUserUpdated()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to update verification status')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update verification status'
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('UserDetailModal.handleToggleAdminVerification error:', err)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
|
||||||
if (bytes === 0) return '0 Bytes'
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatusBadge = ({ status, verified }: { status: boolean, verified?: boolean }) => {
|
|
||||||
if (verified) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
|
|
||||||
<CheckCircleIcon className="h-3 w-3" />
|
|
||||||
Verified
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
|
|
||||||
status
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{status ? <CheckCircleIcon className="h-3 w-3" /> : <XCircleIcon className="h-3 w-3" />}
|
|
||||||
{status ? 'Complete' : 'Incomplete'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-6">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white shadow-xl transition-all w-full max-w-4xl max-h-[85vh] flex flex-col">
|
|
||||||
<div className="absolute right-0 top-0 z-10 pr-4 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable Content Area */}
|
|
||||||
<div className="overflow-y-auto px-4 pb-4 pt-5 sm:p-6">
|
|
||||||
<div className="w-full">
|
|
||||||
<Dialog.Title as="h3" className="text-lg font-semibold leading-6 text-gray-900 mb-6 flex items-center gap-2 pr-8">
|
|
||||||
User Details
|
|
||||||
{isEditing && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">
|
|
||||||
<PencilSquareIcon className="h-3 w-3" />
|
|
||||||
Edit Mode
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Dialog.Title>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-blue-500 border-b-transparent animate-spin" />
|
|
||||||
<span className="ml-3 text-gray-600">Loading user details...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md bg-red-50 p-4 mb-6">
|
|
||||||
<div className="text-sm text-red-700">{error}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{userDetails && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Basic User Info */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<UserIcon className="h-5 w-5 text-gray-600" />
|
|
||||||
<h4 className="text-sm font-medium text-gray-900">Basic Information</h4>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Email:</span>
|
|
||||||
<span className="ml-2 text-gray-600">{userDetails.user.email}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Type:</span>
|
|
||||||
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.user_type}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Role:</span>
|
|
||||||
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.role}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Created:</span>
|
|
||||||
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
{userDetails.user.last_login_at && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Last Login:</span>
|
|
||||||
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.last_login_at)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Verification Status */}
|
|
||||||
{userDetails.userStatus && (
|
|
||||||
<div className="bg-blue-50 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<ShieldCheckIcon className="h-5 w-5 text-blue-600" />
|
|
||||||
<h4 className="text-sm font-medium text-gray-900">Verification Status</h4>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-700">Email</span>
|
|
||||||
<StatusBadge status={userDetails.userStatus.email_verified === 1} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-700">Profile</span>
|
|
||||||
<StatusBadge status={userDetails.userStatus.profile_completed === 1} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-700">Documents</span>
|
|
||||||
<StatusBadge status={userDetails.userStatus.documents_uploaded === 1} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-700">Contract</span>
|
|
||||||
<StatusBadge status={userDetails.userStatus.contract_signed === 1} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-700">Admin Verified</span>
|
|
||||||
<StatusBadge
|
|
||||||
status={userDetails.userStatus.is_admin_verified === 1}
|
|
||||||
verified={userDetails.userStatus.is_admin_verified === 1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Profile Information */}
|
|
||||||
{(userDetails.personalProfile || userDetails.companyProfile) && (
|
|
||||||
<div className="bg-green-50 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
{userDetails.user.user_type === 'personal' ? (
|
|
||||||
<UserIcon className="h-5 w-5 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<BuildingOfficeIcon className="h-5 w-5 text-green-600" />
|
|
||||||
)}
|
|
||||||
<h4 className="text-sm font-medium text-gray-900">Profile Information</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEditing && editedProfile ? (
|
|
||||||
// Edit mode - show input fields
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
|
||||||
{userDetails.personalProfile && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">First Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.first_name || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, first_name: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Last Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.last_name || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, last_name: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Phone</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.phone || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, phone: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Date of Birth</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={editedProfile.date_of_birth || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, date_of_birth: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Address</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.address || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, address: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">City</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.city || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, city: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Postal Code</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.zip_code || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, zip_code: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Country</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.country || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, country: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{userDetails.companyProfile && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Company Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.company_name || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, company_name: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Tax ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.tax_id || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, tax_id: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Registration Number</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.registration_number || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, registration_number: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Phone</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.phone || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, phone: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Address</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.address || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, address: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">City</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.city || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, city: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Postal Code</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.zip_code || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, zip_code: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block font-medium text-gray-700 mb-1">Country</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editedProfile.country || ''}
|
|
||||||
onChange={(e) => setEditedProfile({...editedProfile, country: e.target.value})}
|
|
||||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// View mode - show readonly data
|
|
||||||
<>
|
|
||||||
{userDetails.personalProfile && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Name:</span>
|
|
||||||
<span className="ml-2 text-gray-600">
|
|
||||||
{userDetails.personalProfile.first_name} {userDetails.personalProfile.last_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{userDetails.personalProfile.phone && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Phone:</span>
|
|
||||||
<span className="ml-2 text-gray-600">{userDetails.personalProfile.phone}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{userDetails.personalProfile.date_of_birth && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Date of Birth:</span>
|
|
||||||
<span className="ml-2 text-gray-600">{formatDate(userDetails.personalProfile.date_of_birth)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{userDetails.personalProfile.address && (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<span className="font-medium text-gray-700">Address:</span>
|
|
||||||
<span className="ml-2 text-gray-600">
|
|
||||||
{userDetails.personalProfile.address}, {userDetails.personalProfile.zip_code} {userDetails.personalProfile.city}, {userDetails.personalProfile.country}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{userDetails.companyProfile && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Company Name:</span>
|
|
||||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.company_name}</span>
|
|
||||||
</div>
|
|
||||||
{userDetails.companyProfile.tax_id && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Tax ID:</span>
|
|
||||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.tax_id}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{userDetails.companyProfile.registration_number && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Registration Number:</span>
|
|
||||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.registration_number}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{userDetails.companyProfile.phone && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">Phone:</span>
|
|
||||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.phone}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{userDetails.companyProfile.address && (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<span className="font-medium text-gray-700">Address:</span>
|
|
||||||
<span className="ml-2 text-gray-600">
|
|
||||||
{userDetails.companyProfile.address}, {userDetails.companyProfile.zip_code} {userDetails.companyProfile.city}, {userDetails.companyProfile.country}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Documents */}
|
|
||||||
{(userDetails.documents.length > 0 || userDetails.contracts.length > 0 || userDetails.idDocuments.length > 0) && (
|
|
||||||
<div className="bg-purple-50 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<DocumentTextIcon className="h-5 w-5 text-purple-600" />
|
|
||||||
<h4 className="text-sm font-medium text-gray-900">Documents</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Regular Documents */}
|
|
||||||
{userDetails.documents.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h5 className="text-xs font-medium text-gray-700 mb-2">Uploaded Documents</h5>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{userDetails.documents.map((doc) => (
|
|
||||||
<div key={doc.id} className="flex items-center justify-between bg-white p-2 rounded border">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-gray-900">{doc.file_name}</span>
|
|
||||||
<span className="text-xs text-gray-500 ml-2">({formatFileSize(doc.file_size)})</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-500">{formatDate(doc.uploaded_at)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Contracts */}
|
|
||||||
{userDetails.contracts.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h5 className="text-xs font-medium text-gray-700 mb-2">Contracts</h5>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{userDetails.contracts.map((contract) => (
|
|
||||||
<div key={contract.id} className="flex items-center justify-between bg-white p-2 rounded border">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-gray-900">{contract.file_name}</span>
|
|
||||||
<span className="text-xs text-gray-500 ml-2">({formatFileSize(contract.file_size)})</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-500">{formatDate(contract.uploaded_at)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ID Documents */}
|
|
||||||
{userDetails.idDocuments.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h5 className="text-xs font-medium text-gray-700 mb-2">ID Documents</h5>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{userDetails.idDocuments.map((idDoc) => (
|
|
||||||
<div key={idDoc.id} className="bg-white p-3 rounded border">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<IdentificationIcon className="h-4 w-4 text-gray-600" />
|
|
||||||
<span className="text-sm font-medium text-gray-900">{idDoc.document_type}</span>
|
|
||||||
<span className="text-xs text-gray-500">{formatDate(idDoc.uploaded_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
{idDoc.frontUrl && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-700 mb-1">Front:</p>
|
|
||||||
<img
|
|
||||||
src={idDoc.frontUrl}
|
|
||||||
alt="ID Front"
|
|
||||||
className="max-w-full h-32 object-contain border rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{idDoc.backUrl && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-700 mb-1">Back:</p>
|
|
||||||
<img
|
|
||||||
src={idDoc.backUrl}
|
|
||||||
alt="ID Back"
|
|
||||||
className="max-w-full h-32 object-contain border rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Permissions */}
|
|
||||||
{userDetails.permissions.length > 0 && (
|
|
||||||
<div className="bg-indigo-50 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<ShieldCheckIcon className="h-5 w-5 text-indigo-600" />
|
|
||||||
<h4 className="text-sm font-medium text-gray-900">Permissions</h4>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
||||||
{userDetails.permissions.map((permission) => (
|
|
||||||
<div key={permission.id} className="bg-white p-2 rounded border">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{permission.name}</div>
|
|
||||||
{permission.description && (
|
|
||||||
<div className="text-xs text-gray-600">{permission.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 sm:mt-6">
|
|
||||||
{showArchiveConfirm ? (
|
|
||||||
// Archive/Unarchive Confirmation Dialog
|
|
||||||
<div className={`${userDetails?.userStatus?.status === 'inactive' ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'} border rounded-lg p-4 mb-4`}>
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<ExclamationTriangleIcon className={`h-5 w-5 ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-600' : 'text-red-600'}`} />
|
|
||||||
<h4 className={`text-sm font-medium ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-900' : 'text-red-900'}`}>
|
|
||||||
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<p className={`text-sm ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-700' : 'text-red-700'} mb-4`}>
|
|
||||||
{userDetails?.userStatus?.status === 'inactive'
|
|
||||||
? 'Are you sure you want to unarchive this user? This will reactivate their account.'
|
|
||||||
: 'Are you sure you want to archive this user? This action will disable their account but preserve all their data.'}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleArchiveUser}
|
|
||||||
disabled={archiving}
|
|
||||||
className={`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
||||||
userDetails?.userStatus?.status === 'inactive'
|
|
||||||
? 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600'
|
|
||||||
: 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{archiving ? (
|
|
||||||
<>
|
|
||||||
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
|
|
||||||
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchiving...' : 'Archiving...'}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowArchiveConfirm(false)}
|
|
||||||
disabled={archiving}
|
|
||||||
className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Normal action buttons
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSaveProfile}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircleIcon className="h-4 w-4" />
|
|
||||||
Save Changes
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setIsEditing(false)
|
|
||||||
// Reset edited profile to original
|
|
||||||
if (userDetails?.personalProfile) {
|
|
||||||
setEditedProfile(userDetails.personalProfile)
|
|
||||||
} else if (userDetails?.companyProfile) {
|
|
||||||
setEditedProfile(userDetails.companyProfile)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<XCircleIcon className="h-4 w-4" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<PencilSquareIcon className="h-4 w-4" />
|
|
||||||
Edit Profile
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{userDetails?.userStatus && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleToggleAdminVerification}
|
|
||||||
disabled={saving}
|
|
||||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
||||||
userDetails.userStatus.is_admin_verified === 1
|
|
||||||
? 'bg-amber-600 hover:bg-amber-500 text-white focus-visible:outline-amber-600'
|
|
||||||
: 'bg-green-600 hover:bg-green-500 text-white focus-visible:outline-green-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
|
|
||||||
Updating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ShieldCheckIcon className="h-4 w-4" />
|
|
||||||
{userDetails.userStatus.is_admin_verified === 1 ? 'Unverify User' : 'Verify User'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowArchiveConfirm(true)}
|
|
||||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-offset-2 ${
|
|
||||||
userDetails?.userStatus?.status === 'inactive'
|
|
||||||
? 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600'
|
|
||||||
: 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-gray-500"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
import * as Headless from '@headlessui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import type React from 'react'
|
|
||||||
import { Text } from './text'
|
|
||||||
|
|
||||||
const sizes = {
|
|
||||||
xs: 'sm:max-w-xs',
|
|
||||||
sm: 'sm:max-w-sm',
|
|
||||||
md: 'sm:max-w-md',
|
|
||||||
lg: 'sm:max-w-lg',
|
|
||||||
xl: 'sm:max-w-xl',
|
|
||||||
'2xl': 'sm:max-w-2xl',
|
|
||||||
'3xl': 'sm:max-w-3xl',
|
|
||||||
'4xl': 'sm:max-w-4xl',
|
|
||||||
'5xl': 'sm:max-w-5xl',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Alert({
|
|
||||||
size = 'md',
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
|
|
||||||
Headless.DialogProps,
|
|
||||||
'as' | 'className'
|
|
||||||
>) {
|
|
||||||
return (
|
|
||||||
<Headless.Dialog {...props}>
|
|
||||||
<Headless.DialogBackdrop
|
|
||||||
transition
|
|
||||||
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/15 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
|
||||||
<div className="grid min-h-full grid-rows-[1fr_auto_1fr] justify-items-center p-8 sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
|
||||||
<Headless.DialogPanel
|
|
||||||
transition
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
sizes[size],
|
|
||||||
'row-start-2 w-full rounded-2xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:rounded-2xl sm:p-6 dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
|
|
||||||
'transition duration-100 will-change-transform data-closed:opacity-0 data-enter:ease-out data-closed:data-enter:scale-95 data-leave:ease-in'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Headless.DialogPanel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Headless.Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlertTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.DialogTitle
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'text-center text-base/6 font-semibold text-balance text-zinc-950 sm:text-left sm:text-sm/6 sm:text-wrap dark:text-white'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlertDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.Description
|
|
||||||
as={Text}
|
|
||||||
{...props}
|
|
||||||
className={clsx(className, 'mt-2 text-center text-pretty sm:text-left')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlertBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
|
||||||
return <div {...props} className={clsx(className, 'mt-4')} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlertActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'mt-6 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:mt-4 sm:flex-row sm:*:w-auto'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
|
|
||||||
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const DELAY_MS = 200;
|
|
||||||
const EXIT_DURATION = 0.7; // slow the fade/slide-out a bit
|
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [showOverlay, setShowOverlay] = useState(true);
|
|
||||||
const [overlayExit, setOverlayExit] = useState(false);
|
|
||||||
const delayT = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
// Exit overlay shortly after route change (200ms)
|
|
||||||
useEffect(() => {
|
|
||||||
setShowOverlay(true);
|
|
||||||
setOverlayExit(false);
|
|
||||||
if (delayT.current) clearTimeout(delayT.current);
|
|
||||||
delayT.current = window.setTimeout(() => setOverlayExit(true), DELAY_MS);
|
|
||||||
return () => {
|
|
||||||
if (delayT.current) clearTimeout(delayT.current);
|
|
||||||
};
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
// Prevent scroll while overlay is visible
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mounted) return;
|
|
||||||
const prev = document.documentElement.style.overflow;
|
|
||||||
if (showOverlay) document.documentElement.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.documentElement.style.overflow = prev;
|
|
||||||
};
|
|
||||||
}, [showOverlay, mounted]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AnimatePresence mode="wait" onExitComplete={() => window.scrollTo(0, 0)}>
|
|
||||||
<motion.div
|
|
||||||
key={pathname}
|
|
||||||
variants={{
|
|
||||||
hidden: { opacity: 0, x: 0, y: 20 },
|
|
||||||
enter: { opacity: 1, x: 0, y: 0 },
|
|
||||||
exit: { opacity: 0, x: 0, y: -20 },
|
|
||||||
}}
|
|
||||||
initial="hidden"
|
|
||||||
animate="enter"
|
|
||||||
exit="exit"
|
|
||||||
transition={{ type: 'tween', duration: 0.3 }}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Client-only portal overlay with header gradient (no delay, default timing) */}
|
|
||||||
{mounted &&
|
|
||||||
showOverlay &&
|
|
||||||
createPortal(
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={overlayExit ? { y: '-100%', opacity: 0 } : { y: 0, opacity: 1 }}
|
|
||||||
transition={{ duration: EXIT_DURATION, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
onAnimationComplete={() => {
|
|
||||||
if (overlayExit) {
|
|
||||||
setShowOverlay(false);
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="fixed inset-0 z-[999999] flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, #0F1D37 0%, #0A162A 50%, #081224 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<Image
|
|
||||||
src="/images/logos/pp_logo_gold_transparent.png"
|
|
||||||
alt="Profit Planet"
|
|
||||||
width={160}
|
|
||||||
height={160}
|
|
||||||
className="w-32 h-32 object-contain"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
className="mt-6 h-10 w-10 rounded-full border-4 border-[#D4AF37] border-t-transparent animate-spin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageTransitionEffect;
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import type React from 'react'
|
|
||||||
|
|
||||||
export function AuthLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-dvh flex-col p-2">
|
|
||||||
<div className="flex grow items-center justify-center p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
import * as Headless from '@headlessui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import React, { forwardRef } from 'react'
|
|
||||||
import { TouchTarget } from './button'
|
|
||||||
import { Link } from './link'
|
|
||||||
|
|
||||||
type AvatarProps = {
|
|
||||||
src?: string | null
|
|
||||||
square?: boolean
|
|
||||||
initials?: string
|
|
||||||
alt?: string
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Avatar({
|
|
||||||
src = null,
|
|
||||||
square = false,
|
|
||||||
initials,
|
|
||||||
alt = '',
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AvatarProps & React.ComponentPropsWithoutRef<'span'>) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="avatar"
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
// Basic layout
|
|
||||||
'inline-grid shrink-0 align-middle [--avatar-radius:20%] *:col-start-1 *:row-start-1',
|
|
||||||
'outline -outline-offset-1 outline-black/10 dark:outline-white/10',
|
|
||||||
// Border radius
|
|
||||||
square ? 'rounded-(--avatar-radius) *:rounded-(--avatar-radius)' : 'rounded-full *:rounded-full'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{initials && (
|
|
||||||
<svg
|
|
||||||
className="size-full fill-current p-[5%] text-[48px] font-medium uppercase select-none"
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
aria-hidden={alt ? undefined : 'true'}
|
|
||||||
>
|
|
||||||
{alt && <title>{alt}</title>}
|
|
||||||
<text x="50%" y="50%" alignmentBaseline="middle" dominantBaseline="middle" textAnchor="middle" dy=".125em">
|
|
||||||
{initials}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{src && <img className="size-full" src={src} alt={alt} />}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AvatarButton = forwardRef(function AvatarButton(
|
|
||||||
{
|
|
||||||
src,
|
|
||||||
square = false,
|
|
||||||
initials,
|
|
||||||
alt,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AvatarProps &
|
|
||||||
(
|
|
||||||
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
|
||||||
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
|
||||||
),
|
|
||||||
ref: React.ForwardedRef<HTMLButtonElement>
|
|
||||||
) {
|
|
||||||
let classes = clsx(
|
|
||||||
className,
|
|
||||||
square ? 'rounded-[20%]' : 'rounded-full',
|
|
||||||
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
|
|
||||||
)
|
|
||||||
|
|
||||||
return typeof props.href === 'string' ? (
|
|
||||||
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
|
||||||
<TouchTarget>
|
|
||||||
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
|
||||||
</TouchTarget>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Headless.Button {...props} className={classes} ref={ref}>
|
|
||||||
<TouchTarget>
|
|
||||||
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
|
||||||
</TouchTarget>
|
|
||||||
</Headless.Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import * as Headless from '@headlessui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import React, { forwardRef } from 'react'
|
|
||||||
import { TouchTarget } from './button'
|
|
||||||
import { Link } from './link'
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:group-data-hover:bg-red-500/20',
|
|
||||||
orange:
|
|
||||||
'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25 dark:bg-orange-500/10 dark:text-orange-400 dark:group-data-hover:bg-orange-500/20',
|
|
||||||
amber:
|
|
||||||
'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400 dark:group-data-hover:bg-amber-400/15',
|
|
||||||
yellow:
|
|
||||||
'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30 dark:bg-yellow-400/10 dark:text-yellow-300 dark:group-data-hover:bg-yellow-400/15',
|
|
||||||
lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30 dark:bg-lime-400/10 dark:text-lime-300 dark:group-data-hover:bg-lime-400/15',
|
|
||||||
green:
|
|
||||||
'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25 dark:bg-green-500/10 dark:text-green-400 dark:group-data-hover:bg-green-500/20',
|
|
||||||
emerald:
|
|
||||||
'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:group-data-hover:bg-emerald-500/20',
|
|
||||||
teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25 dark:bg-teal-500/10 dark:text-teal-300 dark:group-data-hover:bg-teal-500/20',
|
|
||||||
cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-300 dark:group-data-hover:bg-cyan-400/15',
|
|
||||||
sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:group-data-hover:bg-sky-500/20',
|
|
||||||
blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25 dark:text-blue-400 dark:group-data-hover:bg-blue-500/25',
|
|
||||||
indigo:
|
|
||||||
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25 dark:text-indigo-400 dark:group-data-hover:bg-indigo-500/20',
|
|
||||||
violet:
|
|
||||||
'bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25 dark:text-violet-400 dark:group-data-hover:bg-violet-500/20',
|
|
||||||
purple:
|
|
||||||
'bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25 dark:text-purple-400 dark:group-data-hover:bg-purple-500/20',
|
|
||||||
fuchsia:
|
|
||||||
'bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25 dark:bg-fuchsia-400/10 dark:text-fuchsia-400 dark:group-data-hover:bg-fuchsia-400/20',
|
|
||||||
pink: 'bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25 dark:bg-pink-400/10 dark:text-pink-400 dark:group-data-hover:bg-pink-400/20',
|
|
||||||
rose: 'bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25 dark:bg-rose-400/10 dark:text-rose-400 dark:group-data-hover:bg-rose-400/20',
|
|
||||||
zinc: 'bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20 dark:bg-white/5 dark:text-zinc-400 dark:group-data-hover:bg-white/10',
|
|
||||||
}
|
|
||||||
|
|
||||||
type BadgeProps = { color?: keyof typeof colors }
|
|
||||||
|
|
||||||
export function Badge({ color = 'zinc', className, ...props }: BadgeProps & React.ComponentPropsWithoutRef<'span'>) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5 forced-colors:outline',
|
|
||||||
colors[color]
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BadgeButton = forwardRef(function BadgeButton(
|
|
||||||
{
|
|
||||||
color = 'zinc',
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: BadgeProps & { className?: string; children: React.ReactNode } & (
|
|
||||||
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
|
||||||
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
|
||||||
),
|
|
||||||
ref: React.ForwardedRef<HTMLElement>
|
|
||||||
) {
|
|
||||||
let classes = clsx(
|
|
||||||
className,
|
|
||||||
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
|
|
||||||
)
|
|
||||||
|
|
||||||
return typeof props.href === 'string' ? (
|
|
||||||
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
|
||||||
<TouchTarget>
|
|
||||||
<Badge color={color}>{children}</Badge>
|
|
||||||
</TouchTarget>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Headless.Button {...props} className={classes} ref={ref}>
|
|
||||||
<TouchTarget>
|
|
||||||
<Badge color={color}>{children}</Badge>
|
|
||||||
</TouchTarget>
|
|
||||||
</Headless.Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
import * as Headless from '@headlessui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import React, { forwardRef } from 'react'
|
|
||||||
import { Link } from './link'
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
base: [
|
|
||||||
// Base
|
|
||||||
'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
|
|
||||||
// Sizing
|
|
||||||
'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
|
|
||||||
// Focus
|
|
||||||
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
|
|
||||||
// Disabled
|
|
||||||
'data-disabled:opacity-50',
|
|
||||||
// Icon
|
|
||||||
'*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]',
|
|
||||||
],
|
|
||||||
solid: [
|
|
||||||
// Optical border, implemented as the button background to avoid corner artifacts
|
|
||||||
'border-transparent bg-(--btn-border)',
|
|
||||||
// Dark mode: border is rendered on `after` so background is set to button background
|
|
||||||
'dark:bg-(--btn-bg)',
|
|
||||||
// Button background, implemented as foreground layer to stack on top of pseudo-border layer
|
|
||||||
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)',
|
|
||||||
// Drop shadow, applied to the inset `before` layer so it blends with the border
|
|
||||||
'before:shadow-sm',
|
|
||||||
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
|
||||||
'dark:before:hidden',
|
|
||||||
// Dark mode: Subtle white outline is applied using a border
|
|
||||||
'dark:border-white/5',
|
|
||||||
// Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
|
|
||||||
'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]',
|
|
||||||
// Inner highlight shadow
|
|
||||||
'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
|
||||||
// White overlay on hover
|
|
||||||
'data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)',
|
|
||||||
// Dark mode: `after` layer expands to cover entire button
|
|
||||||
'dark:after:-inset-px dark:after:rounded-lg',
|
|
||||||
// Disabled
|
|
||||||
'data-disabled:before:shadow-none data-disabled:after:shadow-none',
|
|
||||||
],
|
|
||||||
outline: [
|
|
||||||
// Base
|
|
||||||
'border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/2.5 data-hover:bg-zinc-950/2.5',
|
|
||||||
// Dark mode
|
|
||||||
'dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-active:bg-white/5 dark:data-hover:bg-white/5',
|
|
||||||
// Icon
|
|
||||||
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
|
||||||
],
|
|
||||||
plain: [
|
|
||||||
// Base
|
|
||||||
'border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5',
|
|
||||||
// Dark mode
|
|
||||||
'dark:text-white dark:data-active:bg-white/10 dark:data-hover:bg-white/10',
|
|
||||||
// Icon
|
|
||||||
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
|
||||||
],
|
|
||||||
colors: {
|
|
||||||
'dark/zinc': [
|
|
||||||
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
|
||||||
'dark:text-white dark:[--btn-bg:var(--color-zinc-600)] dark:[--btn-hover-overlay:var(--color-white)]/5',
|
|
||||||
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
|
||||||
],
|
|
||||||
light: [
|
|
||||||
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
|
|
||||||
'dark:text-white dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
|
|
||||||
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
|
||||||
],
|
|
||||||
'dark/white': [
|
|
||||||
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
|
||||||
'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
|
|
||||||
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
|
||||||
],
|
|
||||||
dark: [
|
|
||||||
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
|
||||||
'dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
|
|
||||||
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
|
||||||
],
|
|
||||||
white: [
|
|
||||||
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
|
|
||||||
'dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
|
|
||||||
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]',
|
|
||||||
],
|
|
||||||
zinc: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90',
|
|
||||||
'dark:[--btn-hover-overlay:var(--color-white)]/5',
|
|
||||||
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
|
||||||
],
|
|
||||||
indigo: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90',
|
|
||||||
'[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]',
|
|
||||||
],
|
|
||||||
cyan: [
|
|
||||||
'text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25',
|
|
||||||
'[--btn-icon:var(--color-cyan-500)]',
|
|
||||||
],
|
|
||||||
red: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90',
|
|
||||||
'[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]',
|
|
||||||
],
|
|
||||||
orange: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90',
|
|
||||||
'[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]',
|
|
||||||
],
|
|
||||||
amber: [
|
|
||||||
'text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80',
|
|
||||||
'[--btn-icon:var(--color-amber-600)]',
|
|
||||||
],
|
|
||||||
yellow: [
|
|
||||||
'text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80',
|
|
||||||
'[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]',
|
|
||||||
],
|
|
||||||
lime: [
|
|
||||||
'text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80',
|
|
||||||
'[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]',
|
|
||||||
],
|
|
||||||
green: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90',
|
|
||||||
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
|
||||||
],
|
|
||||||
emerald: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90',
|
|
||||||
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
|
||||||
],
|
|
||||||
teal: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90',
|
|
||||||
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
|
||||||
],
|
|
||||||
sky: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80',
|
|
||||||
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
|
||||||
],
|
|
||||||
blue: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-blue-600)] [--btn-border:var(--color-blue-700)]/90',
|
|
||||||
'[--btn-icon:var(--color-blue-400)] data-active:[--btn-icon:var(--color-blue-300)] data-hover:[--btn-icon:var(--color-blue-300)]',
|
|
||||||
],
|
|
||||||
violet: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90',
|
|
||||||
'[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]',
|
|
||||||
],
|
|
||||||
purple: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90',
|
|
||||||
'[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]',
|
|
||||||
],
|
|
||||||
fuchsia: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90',
|
|
||||||
'[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]',
|
|
||||||
],
|
|
||||||
pink: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90',
|
|
||||||
'[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]',
|
|
||||||
],
|
|
||||||
rose: [
|
|
||||||
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90',
|
|
||||||
'[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
type ButtonProps = (
|
|
||||||
| { color?: keyof typeof styles.colors; outline?: never; plain?: never }
|
|
||||||
| { color?: never; outline: true; plain?: never }
|
|
||||||
| { color?: never; outline?: never; plain: true }
|
|
||||||
) & { className?: string; children: React.ReactNode } & (
|
|
||||||
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
|
||||||
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Button = forwardRef(function Button(
|
|
||||||
{ color, outline, plain, className, children, ...props }: ButtonProps,
|
|
||||||
ref: React.ForwardedRef<HTMLElement>
|
|
||||||
) {
|
|
||||||
let classes = clsx(
|
|
||||||
className,
|
|
||||||
styles.base,
|
|
||||||
outline ? styles.outline : plain ? styles.plain : clsx(styles.solid, styles.colors[color ?? 'dark/zinc'])
|
|
||||||
)
|
|
||||||
|
|
||||||
return typeof props.href === 'string' ? (
|
|
||||||
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
|
||||||
<TouchTarget>{children}</TouchTarget>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Headless.Button {...props} className={clsx(classes, 'cursor-default')} ref={ref}>
|
|
||||||
<TouchTarget>{children}</TouchTarget>
|
|
||||||
</Headless.Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expand the hit area to at least 44×44px on touch devices
|
|
||||||
*/
|
|
||||||
export function TouchTarget({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
import * as Headless from '@headlessui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import type React from 'react'
|
|
||||||
|
|
||||||
export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="control"
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
// Basic groups
|
|
||||||
'space-y-3',
|
|
||||||
// With descriptions
|
|
||||||
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CheckboxField({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.Field
|
|
||||||
data-slot="field"
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
// Base layout
|
|
||||||
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
|
|
||||||
// Control layout
|
|
||||||
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
|
|
||||||
// Label layout
|
|
||||||
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
|
|
||||||
// Description layout
|
|
||||||
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
|
|
||||||
// With description
|
|
||||||
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = [
|
|
||||||
// Basic layout
|
|
||||||
'relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4',
|
|
||||||
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
|
||||||
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
|
|
||||||
// Background color when checked
|
|
||||||
'group-data-checked:before:bg-(--checkbox-checked-bg)',
|
|
||||||
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
|
||||||
'dark:before:hidden',
|
|
||||||
// Background color applied to control in dark mode
|
|
||||||
'dark:bg-white/5 dark:group-data-checked:bg-(--checkbox-checked-bg)',
|
|
||||||
// Border
|
|
||||||
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
|
|
||||||
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
|
|
||||||
// Inner highlight shadow
|
|
||||||
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
|
||||||
'dark:after:-inset-px dark:after:hidden dark:after:rounded-[0.3125rem] dark:group-data-checked:after:block',
|
|
||||||
// Focus ring
|
|
||||||
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
|
|
||||||
// Disabled state
|
|
||||||
'group-data-disabled:opacity-50',
|
|
||||||
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
|
|
||||||
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--checkbox-check:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
|
|
||||||
// Forced colors mode
|
|
||||||
'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
|
|
||||||
'dark:forced-colors:[--checkbox-check:HighlightText] dark:forced-colors:[--checkbox-checked-bg:Highlight] dark:forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
|
|
||||||
]
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
'dark/zinc': [
|
|
||||||
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
|
||||||
'dark:[--checkbox-checked-bg:var(--color-zinc-600)]',
|
|
||||||
],
|
|
||||||
'dark/white': [
|
|
||||||
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
|
||||||
'dark:[--checkbox-check:var(--color-zinc-900)] dark:[--checkbox-checked-bg:var(--color-white)] dark:[--checkbox-checked-border:var(--color-zinc-950)]/15',
|
|
||||||
],
|
|
||||||
white:
|
|
||||||
'[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
|
|
||||||
dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
|
||||||
zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
|
|
||||||
red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
|
|
||||||
orange:
|
|
||||||
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
|
|
||||||
amber:
|
|
||||||
'[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
|
|
||||||
yellow:
|
|
||||||
'[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
|
|
||||||
lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
|
|
||||||
green:
|
|
||||||
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
|
|
||||||
emerald:
|
|
||||||
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
|
|
||||||
teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
|
|
||||||
cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
|
|
||||||
sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
|
|
||||||
blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
|
|
||||||
indigo:
|
|
||||||
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
|
|
||||||
violet:
|
|
||||||
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
|
|
||||||
purple:
|
|
||||||
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
|
|
||||||
fuchsia:
|
|
||||||
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
|
|
||||||
pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
|
|
||||||
rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
|
|
||||||
}
|
|
||||||
|
|
||||||
type Color = keyof typeof colors
|
|
||||||
|
|
||||||
export function Checkbox({
|
|
||||||
color = 'dark/zinc',
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
color?: Color
|
|
||||||
className?: string
|
|
||||||
} & Omit<Headless.CheckboxProps, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.Checkbox
|
|
||||||
data-slot="control"
|
|
||||||
{...props}
|
|
||||||
className={clsx(className, 'group inline-flex focus:outline-hidden')}
|
|
||||||
>
|
|
||||||
<span className={clsx([base, colors[color]])}>
|
|
||||||
<svg
|
|
||||||
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
|
|
||||||
viewBox="0 0 14 14"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
{/* Checkmark icon */}
|
|
||||||
<path
|
|
||||||
className="opacity-100 group-data-indeterminate:opacity-0"
|
|
||||||
d="M3 8L6 11L11 3.5"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
{/* Indeterminate icon */}
|
|
||||||
<path
|
|
||||||
className="opacity-0 group-data-indeterminate:opacity-100"
|
|
||||||
d="M3 7H11"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</Headless.Checkbox>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as Headless from '@headlessui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
export function Combobox<T>({
|
|
||||||
options,
|
|
||||||
displayValue,
|
|
||||||
filter,
|
|
||||||
anchor = 'bottom',
|
|
||||||
className,
|
|
||||||
placeholder,
|
|
||||||
autoFocus,
|
|
||||||
'aria-label': ariaLabel,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
options: T[]
|
|
||||||
displayValue: (value: T | null) => string | undefined
|
|
||||||
filter?: (value: T, query: string) => boolean
|
|
||||||
className?: string
|
|
||||||
placeholder?: string
|
|
||||||
autoFocus?: boolean
|
|
||||||
'aria-label'?: string
|
|
||||||
children: (value: NonNullable<T>) => React.ReactElement
|
|
||||||
} & Omit<Headless.ComboboxProps<T, false>, 'as' | 'multiple' | 'children'> & { anchor?: 'top' | 'bottom' }) {
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
|
|
||||||
const filteredOptions =
|
|
||||||
query === ''
|
|
||||||
? options
|
|
||||||
: options.filter((option) =>
|
|
||||||
filter ? filter(option, query) : displayValue(option)?.toLowerCase().includes(query.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Headless.Combobox {...props} multiple={false} virtual={{ options: filteredOptions }} onClose={() => setQuery('')}>
|
|
||||||
<span
|
|
||||||
data-slot="control"
|
|
||||||
className={clsx([
|
|
||||||
className,
|
|
||||||
// Basic layout
|
|
||||||
'relative block w-full',
|
|
||||||
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
|
||||||
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
|
||||||
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
|
||||||
'dark:before:hidden',
|
|
||||||
// Focus ring
|
|
||||||
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
|
||||||
// Disabled state
|
|
||||||
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
|
||||||
// Invalid state
|
|
||||||
'has-data-invalid:before:shadow-red-500/10',
|
|
||||||
])}
|
|
||||||
>
|
|
||||||
<Headless.ComboboxInput
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
data-slot="control"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
displayValue={(option: T) => displayValue(option) ?? ''}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={clsx([
|
|
||||||
className,
|
|
||||||
// Basic layout
|
|
||||||
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
|
||||||
// Horizontal padding
|
|
||||||
'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
|
|
||||||
// Typography
|
|
||||||
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
|
||||||
// Border
|
|
||||||
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
|
||||||
// Background color
|
|
||||||
'bg-transparent dark:bg-white/5',
|
|
||||||
// Hide default focus styles
|
|
||||||
'focus:outline-hidden',
|
|
||||||
// Invalid state
|
|
||||||
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-500 dark:data-invalid:data-hover:border-red-500',
|
|
||||||
// Disabled state
|
|
||||||
'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
|
||||||
// System icons
|
|
||||||
'dark:scheme-dark',
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<Headless.ComboboxButton className="group absolute inset-y-0 right-0 flex items-center px-2">
|
|
||||||
<svg
|
|
||||||
className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 group-data-hover:stroke-zinc-700 sm:size-4 dark:stroke-zinc-400 dark:group-data-hover:stroke-zinc-300 forced-colors:stroke-[CanvasText]"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</Headless.ComboboxButton>
|
|
||||||
</span>
|
|
||||||
<Headless.ComboboxOptions
|
|
||||||
transition
|
|
||||||
anchor={anchor}
|
|
||||||
className={clsx(
|
|
||||||
// Anchor positioning
|
|
||||||
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
|
|
||||||
// Base styles,
|
|
||||||
'isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible',
|
|
||||||
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
|
||||||
'outline outline-transparent focus:outline-hidden',
|
|
||||||
// Handle scrolling when menu won't fit in viewport
|
|
||||||
'overflow-y-scroll overscroll-contain',
|
|
||||||
// Popover background
|
|
||||||
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
|
|
||||||
// Shadows
|
|
||||||
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
|
|
||||||
// Transitions
|
|
||||||
'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{({ option }) => children(option)}
|
|
||||||
</Headless.ComboboxOptions>
|
|
||||||
</Headless.Combobox>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComboboxOption<T>({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string; children?: React.ReactNode } & Omit<
|
|
||||||
Headless.ComboboxOptionProps<'div', T>,
|
|
||||||
'as' | 'className'
|
|
||||||
>) {
|
|
||||||
let sharedClasses = clsx(
|
|
||||||
// Base
|
|
||||||
'flex min-w-0 items-center',
|
|
||||||
// Icons
|
|
||||||
'*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
|
|
||||||
'*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
|
|
||||||
'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
|
|
||||||
// Avatars
|
|
||||||
'*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Headless.ComboboxOption
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
// Basic layout
|
|
||||||
'group/option grid w-full cursor-default grid-cols-[1fr_--spacing(5)] items-baseline gap-x-2 rounded-lg py-2.5 pr-2 pl-3.5 sm:grid-cols-[1fr_--spacing(4)] sm:py-1.5 sm:pr-2 sm:pl-3',
|
|
||||||
// Typography
|
|
||||||
'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
|
||||||
// Focus
|
|
||||||
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
|
|
||||||
// Forced colors mode
|
|
||||||
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
|
|
||||||
// Disabled
|
|
||||||
'data-disabled:opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={clsx(className, sharedClasses)}>{children}</span>
|
|
||||||
<svg
|
|
||||||
className="relative col-start-2 hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</Headless.ComboboxOption>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComboboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
|
||||||
return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComboboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex-1 truncate">{children}</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,870 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import {
|
|
||||||
EnvelopeIcon,
|
|
||||||
DocumentTextIcon,
|
|
||||||
UserPlusIcon,
|
|
||||||
DocumentCheckIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
ExclamationTriangleIcon
|
|
||||||
} from '@heroicons/react/24/outline'
|
|
||||||
import useAuthStore from '../../store/authStore'
|
|
||||||
import { useUserStatus } from '../../hooks/useUserStatus'
|
|
||||||
|
|
||||||
interface QuickAction {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
icon: any
|
|
||||||
color: string
|
|
||||||
status: 'pending' | 'completed' | 'unavailable'
|
|
||||||
onClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserStatus interface is now imported from useUserStatus hook
|
|
||||||
|
|
||||||
export default function QuickActions() {
|
|
||||||
const { userStatus, loading, error, refreshStatus } = useUserStatus()
|
|
||||||
const [showEmailVerification, setShowEmailVerification] = useState(false)
|
|
||||||
const [showDocumentUpload, setShowDocumentUpload] = useState(false)
|
|
||||||
const [showProfileCompletion, setShowProfileCompletion] = useState(false)
|
|
||||||
const [showContractSigning, setShowContractSigning] = useState(false)
|
|
||||||
const [isClient, setIsClient] = useState(false)
|
|
||||||
|
|
||||||
const user = useAuthStore(state => state.user)
|
|
||||||
|
|
||||||
// Handle SSR hydration
|
|
||||||
useEffect(() => {
|
|
||||||
setIsClient(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Debug logging (can be removed in production)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isClient && process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('🔍 [QuickActions] userStatus changed:', userStatus)
|
|
||||||
console.log('🔍 [QuickActions] loading state:', loading)
|
|
||||||
console.log('🔍 [QuickActions] error state:', error)
|
|
||||||
}
|
|
||||||
}, [isClient, userStatus, loading, error])
|
|
||||||
|
|
||||||
// Don't render until client-side hydration is complete
|
|
||||||
if (!isClient) {
|
|
||||||
return (
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Account Setup</h2>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<div key={i} className="bg-white rounded-lg p-6 shadow-sm border border-gray-200">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="bg-gray-200 rounded-lg p-3 w-12 h-12"></div>
|
|
||||||
<div className="ml-4 flex-1">
|
|
||||||
<div className="h-4 bg-gray-200 rounded mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActionStatus = (action: string): 'pending' | 'completed' | 'unavailable' => {
|
|
||||||
// If loading or no userStatus, show initial states (email is pending, others unavailable)
|
|
||||||
if (!userStatus) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log(`🔍 [getActionStatus] No userStatus for action: ${action}`)
|
|
||||||
}
|
|
||||||
switch (action) {
|
|
||||||
case 'email':
|
|
||||||
return 'pending'
|
|
||||||
default:
|
|
||||||
return 'unavailable'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log(`🔍 [getActionStatus] ${action}:`, {
|
|
||||||
email_verified: userStatus.email_verified,
|
|
||||||
documents_uploaded: userStatus.documents_uploaded,
|
|
||||||
profile_completed: userStatus.profile_completed,
|
|
||||||
contract_signed: userStatus.contract_signed
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'email':
|
|
||||||
return userStatus.email_verified ? 'completed' : 'pending'
|
|
||||||
case 'documents':
|
|
||||||
return !userStatus.email_verified ? 'unavailable' :
|
|
||||||
userStatus.documents_uploaded ? 'completed' : 'pending'
|
|
||||||
case 'profile':
|
|
||||||
return !userStatus.documents_uploaded ? 'unavailable' :
|
|
||||||
userStatus.profile_completed ? 'completed' : 'pending'
|
|
||||||
case 'contract':
|
|
||||||
return !userStatus.profile_completed ? 'unavailable' :
|
|
||||||
userStatus.contract_signed ? 'completed' : 'pending'
|
|
||||||
default:
|
|
||||||
return 'pending'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const quickActions: QuickAction[] = [
|
|
||||||
{
|
|
||||||
id: 'email',
|
|
||||||
title: 'Verify Email',
|
|
||||||
description: 'Confirm your email address to activate your account',
|
|
||||||
icon: EnvelopeIcon,
|
|
||||||
color: 'bg-blue-500',
|
|
||||||
status: getActionStatus('email'),
|
|
||||||
onClick: () => setShowEmailVerification(true)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'documents',
|
|
||||||
title: 'Upload ID Documents',
|
|
||||||
description: user?.userType === 'company' ? 'Upload company registration documents' : 'Upload personal identification documents',
|
|
||||||
icon: DocumentTextIcon,
|
|
||||||
color: 'bg-green-500',
|
|
||||||
status: getActionStatus('documents'),
|
|
||||||
onClick: () => setShowDocumentUpload(true)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'profile',
|
|
||||||
title: 'Complete Profile',
|
|
||||||
description: 'Add additional information to complete your profile',
|
|
||||||
icon: UserPlusIcon,
|
|
||||||
color: 'bg-purple-500',
|
|
||||||
status: getActionStatus('profile'),
|
|
||||||
onClick: () => setShowProfileCompletion(true)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'contract',
|
|
||||||
title: 'Sign Contract',
|
|
||||||
description: 'Review and sign your service agreement',
|
|
||||||
icon: DocumentCheckIcon,
|
|
||||||
color: 'bg-orange-500',
|
|
||||||
status: getActionStatus('contract'),
|
|
||||||
onClick: () => setShowContractSigning(true)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed':
|
|
||||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />
|
|
||||||
case 'unavailable':
|
|
||||||
return <XCircleIcon className="h-5 w-5 text-gray-400" />
|
|
||||||
default:
|
|
||||||
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusText = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed':
|
|
||||||
return 'Completed'
|
|
||||||
case 'unavailable':
|
|
||||||
return 'Locked'
|
|
||||||
default:
|
|
||||||
return 'Pending'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show error state if there's an error
|
|
||||||
if (error && !userStatus) {
|
|
||||||
return (
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Account Setup</h2>
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800">Error loading account status</h3>
|
|
||||||
<div className="mt-2 text-sm text-red-700">
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={refreshStatus}
|
|
||||||
className="bg-red-100 px-2 py-1 text-xs font-semibold text-red-800 hover:bg-red-200 rounded"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Account Setup</h2>
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center text-sm text-gray-500">
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#8D6B1D] mr-2"></div>
|
|
||||||
Updating status...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{quickActions.map((action) => (
|
|
||||||
<button
|
|
||||||
key={action.id}
|
|
||||||
onClick={action.onClick}
|
|
||||||
disabled={action.status === 'unavailable' || loading}
|
|
||||||
className={`
|
|
||||||
bg-white rounded-lg p-6 shadow-sm border text-left group transition-all duration-200
|
|
||||||
${loading ? 'opacity-75' : ''}
|
|
||||||
${action.status === 'unavailable'
|
|
||||||
? 'border-gray-200 cursor-not-allowed opacity-60'
|
|
||||||
: action.status === 'completed'
|
|
||||||
? 'border-green-200 hover:border-green-300'
|
|
||||||
: 'border-gray-200 hover:shadow-md hover:border-gray-300'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className={`
|
|
||||||
${loading ? 'animate-pulse bg-gray-300' : action.color} rounded-lg p-3 transition-transform
|
|
||||||
${action.status === 'unavailable' ? 'opacity-60' : 'group-hover:scale-105'}
|
|
||||||
${action.status === 'completed' && !loading ? 'bg-green-500' : ''}
|
|
||||||
`}>
|
|
||||||
<action.icon className="h-6 w-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex-1">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<h3 className={`
|
|
||||||
text-lg font-medium transition-colors
|
|
||||||
${loading ? 'text-gray-400' : ''}
|
|
||||||
${action.status === 'unavailable'
|
|
||||||
? 'text-gray-400'
|
|
||||||
: action.status === 'completed'
|
|
||||||
? 'text-green-700'
|
|
||||||
: 'text-gray-900 group-hover:text-[#8D6B1D]'
|
|
||||||
}
|
|
||||||
`}>
|
|
||||||
{action.title}
|
|
||||||
</h3>
|
|
||||||
{loading ? (
|
|
||||||
<div className="animate-pulse bg-gray-300 rounded-full h-5 w-5"></div>
|
|
||||||
) : (
|
|
||||||
getStatusIcon(action.status)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className={`
|
|
||||||
text-sm mt-1
|
|
||||||
${loading ? 'text-gray-400' : ''}
|
|
||||||
${action.status === 'unavailable' ? 'text-gray-400' : 'text-gray-600'}
|
|
||||||
`}>
|
|
||||||
{action.description}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2">
|
|
||||||
{loading ? (
|
|
||||||
<div className="animate-pulse bg-gray-200 rounded-full h-5 w-16"></div>
|
|
||||||
) : (
|
|
||||||
<span className={`
|
|
||||||
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
|
||||||
${action.status === 'completed'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: action.status === 'unavailable'
|
|
||||||
? 'bg-gray-100 text-gray-800'
|
|
||||||
: 'bg-yellow-100 text-yellow-800'
|
|
||||||
}
|
|
||||||
`}>
|
|
||||||
{getStatusText(action.status)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
{showEmailVerification && (
|
|
||||||
<EmailVerificationModal
|
|
||||||
onClose={() => setShowEmailVerification(false)}
|
|
||||||
onSuccess={refreshStatus}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showDocumentUpload && (
|
|
||||||
<DocumentUploadModal
|
|
||||||
userType={user?.userType || 'personal'}
|
|
||||||
onClose={() => setShowDocumentUpload(false)}
|
|
||||||
onSuccess={refreshStatus}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showProfileCompletion && (
|
|
||||||
<ProfileCompletionModal
|
|
||||||
userType={user?.userType || 'personal'}
|
|
||||||
onClose={() => setShowProfileCompletion(false)}
|
|
||||||
onSuccess={refreshStatus}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showContractSigning && (
|
|
||||||
<ContractSigningModal
|
|
||||||
userType={user?.userType || 'personal'}
|
|
||||||
onClose={() => setShowContractSigning(false)}
|
|
||||||
onSuccess={refreshStatus}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email Verification Modal Component
|
|
||||||
function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, onSuccess: () => void }) {
|
|
||||||
const [verificationCode, setVerificationCode] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const token = useAuthStore(state => state.accessToken)
|
|
||||||
|
|
||||||
const sendVerificationEmail = async () => {
|
|
||||||
if (!token) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
setMessage('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setMessage('Verification email sent! Check your inbox.')
|
|
||||||
} else {
|
|
||||||
setError(data.message || 'Failed to send verification email')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError('Network error occurred')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyCode = async () => {
|
|
||||||
if (!token || !verificationCode.trim()) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/verify-email-code`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ code: verificationCode })
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setMessage('Email verified successfully!')
|
|
||||||
setTimeout(() => {
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
}, 1500)
|
|
||||||
} else {
|
|
||||||
setError(data.message || 'Invalid verification code')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError('Network error occurred')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Email Verification</h3>
|
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
||||||
<XCircleIcon className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
We'll send a verification code to your email address.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={sendVerificationEmail}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{loading ? 'Sending...' : 'Send Verification Email'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="code" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Verification Code
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="code"
|
|
||||||
value={verificationCode}
|
|
||||||
onChange={(e) => setVerificationCode(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
placeholder="Enter 6-digit code"
|
|
||||||
maxLength={6}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={verifyCode}
|
|
||||||
disabled={loading || !verificationCode.trim()}
|
|
||||||
className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{loading ? 'Verifying...' : 'Verify Email'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className="text-green-600 text-sm text-center">{message}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-red-600 text-sm text-center">{error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Document Upload Modal Component
|
|
||||||
function DocumentUploadModal({ userType, onClose, onSuccess }: {
|
|
||||||
userType: string,
|
|
||||||
onClose: () => void,
|
|
||||||
onSuccess: () => void
|
|
||||||
}) {
|
|
||||||
const [frontFile, setFrontFile] = useState<File | null>(null)
|
|
||||||
const [backFile, setBackFile] = useState<File | null>(null)
|
|
||||||
const [idType, setIdType] = useState('')
|
|
||||||
const [idNumber, setIdNumber] = useState('')
|
|
||||||
const [expiryDate, setExpiryDate] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
const token = useAuthStore(state => state.accessToken)
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (!token || !frontFile || !idType || !idNumber || !expiryDate) {
|
|
||||||
setError('Please fill in all required fields and select front image')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
setMessage('')
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('front', frontFile)
|
|
||||||
if (backFile) {
|
|
||||||
formData.append('back', backFile)
|
|
||||||
}
|
|
||||||
formData.append('idType', idType)
|
|
||||||
formData.append('idNumber', idNumber)
|
|
||||||
formData.append('expiryDate', expiryDate)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const endpoint = userType === 'company' ? '/api/upload/company-id' : '/api/upload/personal-id'
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${endpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setMessage('Documents uploaded successfully!')
|
|
||||||
setTimeout(() => {
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
}, 1500)
|
|
||||||
} else {
|
|
||||||
setError(data.message || 'Failed to upload documents')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError('Network error occurred')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
Upload {userType === 'company' ? 'Company' : 'Personal'} Documents
|
|
||||||
</h3>
|
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
||||||
<XCircleIcon className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{userType === 'company' ? 'Company Registration (Front)' : 'ID Document (Front)'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*,.pdf"
|
|
||||||
onChange={(e) => setFrontFile(e.target.files?.[0] || null)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{userType === 'company' ? 'Additional Documents (Optional)' : 'ID Document (Back)'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*,.pdf"
|
|
||||||
onChange={(e) => setBackFile(e.target.files?.[0] || null)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{userType === 'company' ? 'Document Type' : 'ID Type'} <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={idType}
|
|
||||||
onChange={(e) => setIdType(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="">Select Document Type</option>
|
|
||||||
{userType === 'company' ? (
|
|
||||||
<>
|
|
||||||
<option value="business_registration">Business Registration</option>
|
|
||||||
<option value="tax_certificate">Tax Certificate</option>
|
|
||||||
<option value="business_license">Business License</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<option value="passport">Passport</option>
|
|
||||||
<option value="driver_license">Driver's License</option>
|
|
||||||
<option value="national_id">National ID</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{userType === 'company' ? 'Registration/Document Number' : 'ID Number'} <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={idNumber}
|
|
||||||
onChange={(e) => setIdNumber(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
placeholder={userType === 'company' ? 'Enter registration number' : 'Enter ID number'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Expiry Date <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={expiryDate}
|
|
||||||
onChange={(e) => setExpiryDate(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={loading || !frontFile || !idType || !idNumber || !expiryDate}
|
|
||||||
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{loading ? 'Uploading...' : 'Upload Documents'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className="text-green-600 text-sm text-center">{message}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-red-600 text-sm text-center">{error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Profile Completion Modal Component
|
|
||||||
function ProfileCompletionModal({ userType, onClose, onSuccess }: {
|
|
||||||
userType: string,
|
|
||||||
onClose: () => void,
|
|
||||||
onSuccess: () => void
|
|
||||||
}) {
|
|
||||||
const [formData, setFormData] = useState<any>({})
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
const token = useAuthStore(state => state.accessToken)
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!token) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
setMessage('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const endpoint = userType === 'company' ? '/api/profile/company/complete' : '/api/profile/personal/complete'
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${endpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formData)
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setMessage('Profile completed successfully!')
|
|
||||||
setTimeout(() => {
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
}, 1500)
|
|
||||||
} else {
|
|
||||||
setError(data.message || 'Failed to complete profile')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError('Network error occurred')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Complete Profile</h3>
|
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
||||||
<XCircleIcon className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{userType === 'company' ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.companyName || ''}
|
|
||||||
onChange={(e) => setFormData({...formData, companyName: e.target.value})}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Industry</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.industry || ''}
|
|
||||||
onChange={(e) => setFormData({...formData, industry: e.target.value})}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Phone Number</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={formData.phone || ''}
|
|
||||||
onChange={(e) => setFormData({...formData, phone: e.target.value})}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Address</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.address || ''}
|
|
||||||
onChange={(e) => setFormData({...formData, address: e.target.value})}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{loading ? 'Saving...' : 'Complete Profile'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className="text-green-600 text-sm text-center">{message}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-red-600 text-sm text-center">{error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contract Signing Modal Component
|
|
||||||
function ContractSigningModal({ userType, onClose, onSuccess }: {
|
|
||||||
userType: string,
|
|
||||||
onClose: () => void,
|
|
||||||
onSuccess: () => void
|
|
||||||
}) {
|
|
||||||
const [contractFile, setContractFile] = useState<File | null>(null)
|
|
||||||
const [agreed, setAgreed] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
const token = useAuthStore(state => state.accessToken)
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (!token || !contractFile || !agreed) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
setMessage('')
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('contract', contractFile)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const endpoint = userType === 'company' ? '/api/upload/contract/company' : '/api/upload/contract/personal'
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${endpoint}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setMessage('Contract signed successfully!')
|
|
||||||
setTimeout(() => {
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
}, 1500)
|
|
||||||
} else {
|
|
||||||
setError(data.message || 'Failed to upload contract')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError('Network error occurred')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Sign Contract</h3>
|
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
||||||
<XCircleIcon className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Please review and upload your signed service agreement.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Signed Contract Document
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*,.pdf"
|
|
||||||
onChange={(e) => setContractFile(e.target.files?.[0] || null)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="agreement"
|
|
||||||
checked={agreed}
|
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
|
||||||
className="mt-1 h-4 w-4 text-[#8D6B1D] focus:ring-[#8D6B1D] border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
<label htmlFor="agreement" className="ml-2 text-sm text-gray-600">
|
|
||||||
I have read, understood, and agree to the terms and conditions of this service agreement.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={loading || !contractFile || !agreed}
|
|
||||||
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{loading ? 'Uploading...' : 'Upload Signed Contract'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className="text-green-600 text-sm text-center">{message}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-red-600 text-sm text-center">{error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
type DeleteConfirmationModalProps = {
|
|
||||||
open: boolean;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DeleteConfirmationModal({
|
|
||||||
open,
|
|
||||||
title = "Delete Item",
|
|
||||||
description = "Are you sure you want to delete this item? This action cannot be undone.",
|
|
||||||
confirmText = "Delete",
|
|
||||||
cancelText = "Cancel",
|
|
||||||
loading = false,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
children,
|
|
||||||
}: DeleteConfirmationModalProps) {
|
|
||||||
if (!open) return null;
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50">
|
|
||||||
<div className="absolute inset-0 bg-black/40" onClick={onCancel} />
|
|
||||||
<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="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-red-100">
|
|
||||||
<svg width="24" height="24" fill="none" stroke="currentColor" className="text-red-600">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-700 mb-4">{description}</p>
|
|
||||||
{children}
|
|
||||||
<div className="flex items-center justify-end gap-2 mt-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="text-sm px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{cancelText}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={loading}
|
|
||||||
className="text-sm px-4 py-2 rounded-lg bg-red-600 hover:bg-red-500 text-white disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{loading ? "Deleting…" : confirmText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
export function DescriptionList({ className, ...props }: React.ComponentPropsWithoutRef<'dl'>) {
|
|
||||||
return (
|
|
||||||
<dl
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'grid grid-cols-1 text-base/6 sm:grid-cols-[min(50%,--spacing(80))_auto] sm:text-sm/6'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DescriptionTerm({ className, ...props }: React.ComponentPropsWithoutRef<'dt'>) {
|
|
||||||
return (
|
|
||||||
<dt
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'col-start-1 border-t border-zinc-950/5 pt-3 text-zinc-500 first:border-none sm:border-t sm:border-zinc-950/5 sm:py-3 dark:border-white/5 dark:text-zinc-400 sm:dark:border-white/5'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DescriptionDetails({ className, ...props }: React.ComponentPropsWithoutRef<'dd'>) {
|
|
||||||
return (
|
|
||||||
<dd
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'pt-1 pb-3 text-zinc-950 sm:border-t sm:border-zinc-950/5 sm:py-3 sm:nth-2:border-none dark:text-white dark:sm:border-white/5'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import * as Headless from '@headlessui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import type React from 'react'
|
|
||||||
import { Text } from './text'
|
|
||||||
|
|
||||||
const sizes = {
|
|
||||||
xs: 'sm:max-w-xs',
|
|
||||||
sm: 'sm:max-w-sm',
|
|
||||||
md: 'sm:max-w-md',
|
|
||||||
lg: 'sm:max-w-lg',
|
|
||||||
xl: 'sm:max-w-xl',
|
|
||||||
'2xl': 'sm:max-w-2xl',
|
|
||||||
'3xl': 'sm:max-w-3xl',
|
|
||||||
'4xl': 'sm:max-w-4xl',
|
|
||||||
'5xl': 'sm:max-w-5xl',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Dialog({
|
|
||||||
size = 'lg',
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
|
|
||||||
Headless.DialogProps,
|
|
||||||
'as' | 'className'
|
|
||||||
>) {
|
|
||||||
return (
|
|
||||||
<Headless.Dialog {...props}>
|
|
||||||
<Headless.DialogBackdrop
|
|
||||||
transition
|
|
||||||
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
|
||||||
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
|
||||||
<Headless.DialogPanel
|
|
||||||
transition
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
sizes[size],
|
|
||||||
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
|
|
||||||
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Headless.DialogPanel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Headless.Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.DialogTitle
|
|
||||||
{...props}
|
|
||||||
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
|
|
||||||
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
|
||||||
return <div {...props} className={clsx(className, 'mt-6')} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
export function Divider({
|
|
||||||
soft = false,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { soft?: boolean } & React.ComponentPropsWithoutRef<'hr'>) {
|
|
||||||
return (
|
|
||||||
<hr
|
|
||||||
role="presentation"
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'w-full border-t',
|
|
||||||
soft && 'border-zinc-950/5 dark:border-white/5',
|
|
||||||
!soft && 'border-zinc-950/10 dark:border-white/10'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as Headless from '@headlessui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import type React from 'react'
|
|
||||||
import { Button } from './button'
|
|
||||||
import { Link } from './link'
|
|
||||||
|
|
||||||
export function Dropdown(props: Headless.MenuProps) {
|
|
||||||
return <Headless.Menu {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownButton<T extends React.ElementType = typeof Button>({
|
|
||||||
as = Button,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.MenuButtonProps<T>, 'className'>) {
|
|
||||||
return <Headless.MenuButton as={as} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownMenu({
|
|
||||||
anchor = 'bottom',
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.MenuItemsProps, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.MenuItems
|
|
||||||
{...props}
|
|
||||||
transition
|
|
||||||
anchor={anchor}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
// Anchor positioning
|
|
||||||
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(1)] data-[anchor~=end]:[--anchor-offset:6px] data-[anchor~=start]:[--anchor-offset:-6px] sm:data-[anchor~=end]:[--anchor-offset:4px] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
|
|
||||||
// Base styles
|
|
||||||
'isolate w-max rounded-xl p-1',
|
|
||||||
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
|
||||||
'outline outline-transparent focus:outline-hidden',
|
|
||||||
// Handle scrolling when menu won't fit in viewport
|
|
||||||
'overflow-y-auto',
|
|
||||||
// Popover background
|
|
||||||
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
|
|
||||||
// Shadows
|
|
||||||
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
|
|
||||||
// Define grid at the menu level if subgrid is supported
|
|
||||||
'supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]',
|
|
||||||
// Transitions
|
|
||||||
'transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & (
|
|
||||||
| ({ href?: never } & Omit<Headless.MenuItemProps<'button'>, 'as' | 'className'>)
|
|
||||||
| ({ href: string } & Omit<Headless.MenuItemProps<typeof Link>, 'as' | 'className'>)
|
|
||||||
)) {
|
|
||||||
let classes = clsx(
|
|
||||||
className,
|
|
||||||
// Base styles
|
|
||||||
'group cursor-default rounded-lg px-3.5 py-2.5 focus:outline-hidden sm:px-3 sm:py-1.5',
|
|
||||||
// Text styles
|
|
||||||
'text-left text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
|
||||||
// Focus
|
|
||||||
'data-focus:bg-blue-500 data-focus:text-white',
|
|
||||||
// Disabled state
|
|
||||||
'data-disabled:opacity-50',
|
|
||||||
// Forced colors mode
|
|
||||||
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText] forced-colors:data-focus:*:data-[slot=icon]:text-[HighlightText]',
|
|
||||||
// Use subgrid when available but fallback to an explicit grid layout if not
|
|
||||||
'col-span-full grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] items-center supports-[grid-template-columns:subgrid]:grid-cols-subgrid',
|
|
||||||
// Icons
|
|
||||||
'*:data-[slot=icon]:col-start-1 *:data-[slot=icon]:row-start-1 *:data-[slot=icon]:mr-2.5 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:mr-2 sm:*:data-[slot=icon]:size-4',
|
|
||||||
'*:data-[slot=icon]:text-zinc-500 data-focus:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400 dark:data-focus:*:data-[slot=icon]:text-white',
|
|
||||||
// Avatar
|
|
||||||
'*:data-[slot=avatar]:mr-2.5 *:data-[slot=avatar]:-ml-1 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:mr-2 sm:*:data-[slot=avatar]:size-5'
|
|
||||||
)
|
|
||||||
|
|
||||||
return typeof props.href === 'string' ? (
|
|
||||||
<Headless.MenuItem as={Link} {...props} className={classes} />
|
|
||||||
) : (
|
|
||||||
<Headless.MenuItem as="button" type="button" {...props} className={classes} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
|
||||||
return <div {...props} className={clsx(className, 'col-span-5 px-3.5 pt-2.5 pb-1 sm:px-3')} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownSection({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.MenuSectionProps, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.MenuSection
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
// Define grid at the section level instead of the item level if subgrid is supported
|
|
||||||
'col-span-full supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownHeading({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.MenuHeadingProps, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.MenuHeading
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'col-span-full grid grid-cols-[1fr_auto] gap-x-12 px-3.5 pt-2 pb-1 text-sm/5 font-medium text-zinc-500 sm:px-3 sm:text-xs/5 dark:text-zinc-400'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownDivider({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.MenuSeparatorProps, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.MenuSeparator
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'col-span-full mx-3.5 my-1 h-px border-0 bg-zinc-950/5 sm:mx-3 dark:bg-white/10 forced-colors:bg-[CanvasText]'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownLabel({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
|
||||||
return <div {...props} data-slot="label" className={clsx(className, 'col-start-2 row-start-1')} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.Description
|
|
||||||
data-slot="description"
|
|
||||||
{...props}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
'col-span-2 col-start-2 row-start-2 text-sm/5 text-zinc-500 group-data-focus:text-white sm:text-xs/5 dark:text-zinc-400 forced-colors:group-data-focus:text-[HighlightText]'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropdownShortcut({
|
|
||||||
keys,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: { keys: string | string[]; className?: string } & Omit<Headless.DescriptionProps<'kbd'>, 'as' | 'className'>) {
|
|
||||||
return (
|
|
||||||
<Headless.Description
|
|
||||||
as="kbd"
|
|
||||||
{...props}
|
|
||||||
className={clsx(className, 'col-start-5 row-start-1 flex justify-self-end')}
|
|
||||||
>
|
|
||||||
{(Array.isArray(keys) ? keys : keys.split('')).map((char, index) => (
|
|
||||||
<kbd
|
|
||||||
key={index}
|
|
||||||
className={clsx([
|
|
||||||
'min-w-[2ch] text-center font-sans text-zinc-400 capitalize group-data-focus:text-white forced-colors:group-data-focus:text-[HighlightText]',
|
|
||||||
// Make sure key names that are longer than one character (like "Tab") have extra space
|
|
||||||
index > 0 && char.length > 1 && 'pl-1',
|
|
||||||
])}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
</kbd>
|
|
||||||
))}
|
|
||||||
</Headless.Description>
|
|
||||||
)
|
|
||||||
}
|
|
||||||