Merge pull request 'Git auf Vordermann bringen 🫡' (#8) from dev into main

Reviewed-on: #8
This commit is contained in:
Seazn 2026-01-28 21:05:31 +00:00
commit 40e9f0e7b6
214 changed files with 49471 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

64
ToDo.txt Normal file
View File

@ -0,0 +1,64 @@
================================================================================
PROJECT PLANNING - JANUARY 2026
================================================================================
Last updated: 2026-01-20
=== DK404 TODOS ===
(Loggin / Translate / Refactor)
• [ ] Logging for the First Part aka Quickaction Login Logout edit profile (SAT)
• [ ] Loggin Instance Setup (SAT)
• [ ] Log Collector Setup (SAT)
• [ ] Refactor Login Logout Register Quickactions - CLEAN UP all possible ai bullshit
• [ ] Document till User Verify
• [ ] Privacy Policy, Imprint, ToS (SAT)
• [ ] Backup of our Instances currently Raw :(
• [ ] profit-planet.com Redirects to profit-planet.partners
• [ ] Mail HTML instead of TXT
• [ ] IP Loopback Login (SAT)
• [ ] Referral Management anzeigen wenn die Permission gesetzt ist + Navbar + Dashboard
=== SEAZN TODOS ===
(Compromised User / Pool )
• [x] Compromised User Fix (SAT)
• [x] Pools Complete Setup check and refactor -- Implementing Logging Layout from Alex -- Talk with him (SAT)
• [x] Adjust and add Functionality for Download Acc Data and Delete Acc (SAT)
• [ ] News Management (own pages for news) + Adjust the Dashboard to Display Latest news
• [ ] Unified Modal Design
• [ ] Autorefresh of Site??
• [ ] UserMgmt table refactor with actions and filter options (SAT?)
• [ ] Remove irrelevant statuses in userverify filter
• [ ] User Status 1 Feld das wir nicht benutzen
• [ ] Pool mulit user actions (select 5 -> add to pool)
• [ ] reset edit templates
================================================================================
QUICK SHARED / CROSSOVER ITEMS
================================================================================
• [ ] Security Headers
• [ ] Dependency Management
• [ ] SYSTEM ABSCHALTUNG VERHINDERN -- Exoscale Role? / Security Konzept
• [ ] new npm version
================================================================================
ICEBOX / LATER / BACKLOG
================================================================================
• Matrix
• Banking
• Coffee Abos
• Admin only accessible via PC
• Old iOS/Safari Functionality
• branch and number_of_employees are unused in company_profiles
• X button mobile toast notif too small
• Languages
• Light- / Darkmode
• Finance Management?
================================================================================

24
components.json Normal file
View File

@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@react-bits": "https://reactbits.dev/r/{name}.json"
}
}

34
ecosystem.config.js Normal file
View File

@ -0,0 +1,34 @@
module.exports = {
apps: [
{
name: 'profit-planet-frontend',
cwd: __dirname,
script: './node_modules/next/dist/bin/next',
args: 'start -p 3000',
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
time: true,
env: {
// runtime
NODE_ENV: 'production',
PORT: '3000',
// inlined env (keep in sync with .env used during `npm run build`)
NEXT_PUBLIC_API_BASE_URL: 'https://api.profit-planet.partners',
NEXT_PUBLIC_MODE: 'production',
NEXT_PUBLIC_I18N_DEBUG_MODE: 'false',
NEXT_PUBLIC_GEO_FALLBACK_COUNTRY: 'false',
NEXT_PUBLIC_SHOW_SHOP: 'false',
NEXT_PUBLIC_DISPLAY_NEWS: 'false',
NEXT_PUBLIC_DISPLAY_MEMBERSHIP: 'false',
NEXT_PUBLIC_DISPLAY_ABOUT_US: 'false',
NEXT_PUBLIC_DISPLAY_MATRIX: 'false',
NEXT_PUBLIC_DISPLAY_ABONEMENTS: 'false',
NEXT_PUBLIC_DISPLAY_POOLS: 'false',
},
},
],
}

25
eslint.config.mjs Normal file
View File

@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

29
middleware.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* Next.js middleware to protect admin routes.
* - Runs for paths matched by the config `matcher` (see bottom).
* - Checks for the `refreshToken` cookie; if missing, redirects to `/login` before any page renders.
* - No manual import/use neededNext.js automatically executes this for matching requests.
*/
import { NextRequest, NextResponse } from 'next/server'
// Backend sets 'refreshToken' cookie on login; use it as auth presence
const AUTH_COOKIES = ['refreshToken', '__Secure-refreshToken']
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
// Only guard admin routes
if (pathname.startsWith('/admin')) {
const hasAuthCookie = AUTH_COOKIES.some((name) => !!req.cookies.get(name)?.value)
if (!hasAuthCookie) {
const loginUrl = new URL('/login', req.url)
return NextResponse.redirect(loginUrl)
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/admin/:path*'],
}

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11043
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "profit-planet-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@gsap/react": "^2.1.2",
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
"@lottiefiles/react-lottie-player": "^3.6.0",
"@react-pdf/renderer": "^4.3.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindplus/elements": "^1.0.15",
"@tailwindui/react": "^0.1.1",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.5.21",
"country-select-js": "^2.1.0",
"gsap": "^3.14.2",
"intl-tel-input": "^25.15.0",
"lucide-react": "^0.562.0",
"motion": "^12.23.22",
"next": "^16.0.7",
"pdfjs-dist": "^5.4.149",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.63.0",
"react-hot-toast": "^2.6.0",
"react-pdf": "^10.1.0",
"react-phone-number-input": "^3.4.12",
"react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.167.1",
"winston": "^3.17.0",
"yup": "^1.7.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@eslint/js": "^9.36.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.14",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"eslint-plugin-react-hooks": "^5.2.0",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"postcss-preset-env": "^10.4.0",
"tailwindcss": "^4.1.13",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 MiB

BIN
public/images/misc/cow1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

14
src/app/ClientWrapper.tsx Normal file
View File

@ -0,0 +1,14 @@
'use client';
import { I18nProvider } from './i18n/useTranslation';
import AuthInitializer from './components/AuthInitializer';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
return (
<I18nProvider>
<AuthInitializer>
{children}
</AuthInitializer>
</I18nProvider>
);
}

392
src/app/about-us/page.tsx Normal file
View File

@ -0,0 +1,392 @@
'use client'
import {
AcademicCapIcon,
CheckCircleIcon,
HandRaisedIcon,
RocketLaunchIcon,
SparklesIcon,
SunIcon,
UserGroupIcon,
} from '@heroicons/react/20/solid'
import PageLayout from '../components/PageLayout'
const stats = [
{ label: 'Business was founded', value: '2024' },
{ label: 'People on the team', value: '10+' },
{ label: 'Users on the platform', value: '250k' },
{ label: 'Paid out gold members', value: '$70M' },
]
const values = [
{
name: 'Be world-class.',
description: 'Lorem ipsum, dolor sit amet consectetur adipisicing elit aute id magna.',
icon: RocketLaunchIcon,
},
{
name: 'Take responsibility.',
description: 'Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo.',
icon: HandRaisedIcon,
},
{
name: 'Be supportive.',
description: 'Ac tincidunt sapien vehicula erat auctor pellentesque rhoncus voluptas blanditiis et.',
icon: UserGroupIcon,
},
{
name: 'Always learning.',
description: 'Iure sed ab. Aperiam optio placeat dolor facere. Officiis pariatur eveniet atque et dolor.',
icon: AcademicCapIcon,
},
{
name: 'Share everything you know.',
description: 'Laudantium tempora sint ut consectetur ratione. Ut illum ut rem numquam fuga delectus.',
icon: SparklesIcon,
},
{
name: 'Enjoy downtime.',
description: 'Culpa dolorem voluptatem velit autem rerum qui et corrupti. Quibusdam quo placeat.',
icon: SunIcon,
},
]
const team = [
{
name: 'Leslie Alexander',
role: 'Co-Founder / CEO',
imageUrl:
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
location: 'Toronto, Canada',
},
{
name: 'Michael Foster',
role: 'Co-Founder / CTO',
imageUrl:
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
location: 'Glasgow, Scotland',
},
{
name: 'Dries Vincent',
role: 'Business Relations',
imageUrl:
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
location: 'Niagara Falls, Canada',
},
{
name: 'Lindsay Walton',
role: 'Front-end Developer',
imageUrl:
'https://images.unsplash.com/photo-1517841905240-472988babdf9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
location: 'London, England',
},
{
name: 'Courtney Henry',
role: 'Designer',
imageUrl:
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
location: 'Toronto, Canada',
},
{
name: 'Tom Cook',
role: 'Director of Product',
imageUrl:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
location: 'Toronto, Canada',
},
{
name: 'Whitney Francis',
role: 'Copywriter',
imageUrl:
'https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
location: 'Toronto, Canada',
},
{
name: 'Leonard Krasner',
role: 'Senior Designer',
imageUrl:
'https://images.unsplash.com/photo-1519345182560-3f2917c472ef?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=1024&h=1024&q=80',
location: 'Toronto, Canada',
},
]
const benefits = [
'Competitive salaries',
'Flexible work hours',
'0 days of paid vacation',
'Annual team retreats',
'Benefits for you and your family',
'A great work environment',
]
const footerNavigation = {
solutions: [
{ name: 'Marketing', href: '#' },
{ name: 'Analytics', href: '#' },
{ name: 'Automation', href: '#' },
{ name: 'Commerce', href: '#' },
{ name: 'Insights', href: '#' },
],
support: [
{ name: 'Submit ticket', href: '#' },
{ name: 'Documentation', href: '#' },
{ name: 'Guides', href: '#' },
],
company: [
{ name: 'About', href: '#' },
{ name: 'Blog', href: '#' },
{ name: 'Jobs', href: '#' },
{ name: 'Press', href: '#' },
],
legal: [
{ name: 'Terms of service', href: '#' },
{ name: 'Privacy policy', href: '#' },
{ name: 'License', href: '#' },
],
social: [
{
name: 'Facebook',
href: '#',
icon: (props: any) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
fillRule="evenodd"
d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z"
clipRule="evenodd"
/>
</svg>
),
},
{
name: 'Instagram',
href: '#',
icon: (props: any) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
fillRule="evenodd"
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
clipRule="evenodd"
/>
</svg>
),
},
{
name: 'X',
href: '#',
icon: (props: any) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path d="M13.6823 10.6218L20.2391 3H18.6854L12.9921 9.61788L8.44486 3H3.2002L10.0765 13.0074L3.2002 21H4.75404L10.7663 14.0113L15.5685 21H20.8131L13.6819 10.6218H13.6823ZM11.5541 13.0956L10.8574 12.0991L5.31391 4.16971H7.70053L12.1742 10.5689L12.8709 11.5655L18.6861 19.8835H16.2995L11.5541 13.096V13.0956Z" />
</svg>
),
},
{
name: 'GitHub',
href: '#',
icon: (props: any) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
),
},
{
name: 'YouTube',
href: '#',
icon: (props: any) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
fillRule="evenodd"
d="M19.812 5.418c.861.23 1.538.907 1.768 1.768C21.998 8.746 22 12 22 12s0 3.255-.418 4.814a2.504 2.504 0 0 1-1.768 1.768c-1.56.419-7.814.419-7.814.419s-6.255 0-7.814-.419a2.505 2.505 0 0 1-1.768-1.768C2 15.255 2 12 2 12s0-3.255.417-4.814a2.507 2.507 0 0 1 1.768-1.768C5.744 5 11.998 5 11.998 5s6.255 0 7.814.418ZM15.194 12 10 15V9l5.194 3Z"
clipRule="evenodd"
/>
</svg>
),
},
],
}
export default function AboutUsPage() {
return (
<PageLayout>
<div className="bg-gray-900 pb-24 sm:pb-32">
<main className="relative isolate">
{/* Background */}
<div
aria-hidden="true"
className="absolute inset-x-0 top-4 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl"
>
<div
style={{
clipPath:
'polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)',
}}
className="aspect-1108/632 w-277 flex-none bg-linear-to-r from-[#80caff] to-[#4f46e5] opacity-25"
/>
</div>
{/* Header section */}
<div className="px-6 pt-14 lg:px-8">
<div className="mx-auto max-w-2xl pt-24 text-center sm:pt-40">
<h1 className="text-5xl font-semibold tracking-tight text-white sm:text-7xl">We are a community</h1>
<p className="mt-8 text-lg font-medium text-pretty text-gray-400 sm:text-xl/8">
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
fugiat veniam occaecat fugiat.
</p>
</div>
</div>
{/* Stat section */}
<div className="mx-auto mt-20 max-w-7xl px-6 lg:px-8">
<div className="mx-auto max-w-2xl lg:mx-0 lg:max-w-none">
<div className="grid max-w-xl grid-cols-1 gap-8 text-base/7 text-gray-300 lg:max-w-none lg:grid-cols-2">
<div>
<p>
Faucibus commodo massa rhoncus, volutpat. Dignissim sed eget risus enim. Mattis mauris semper sed amet
vitae sed turpis id. Id dolor praesent donec est. Odio penatibus risus viverra tellus varius sit neque
erat velit. Faucibus commodo massa rhoncus, volutpat. Dignissim sed eget risus enim. Mattis mauris
semper sed amet vitae sed turpis id.
</p>
<p className="mt-8">
Et vitae blandit facilisi magna lacus commodo. Vitae sapien duis odio id et. Id blandit molestie
auctor fermentum dignissim. Lacus diam tincidunt ac cursus in vel. Mauris varius vulputate et ultrices
hac adipiscing egestas.
</p>
</div>
<div>
<p>
Erat pellentesque dictumst ligula porttitor risus eget et eget. Ultricies tellus felis id dignissim
eget. Est augue maecenas risus nulla ultrices congue nunc tortor. Enim et nesciunt doloremque nesciunt
voluptate.
</p>
<p className="mt-8">
Et vitae blandit facilisi magna lacus commodo. Vitae sapien duis odio id et. Id blandit molestie
auctor fermentum dignissim. Lacus diam tincidunt ac cursus in vel. Mauris varius vulputate et ultrices
hac adipiscing egestas. Iaculis convallis ac tempor et ut. Ac lorem vel integer orci.
</p>
</div>
</div>
<dl className="mt-16 grid grid-cols-1 gap-x-8 gap-y-12 sm:mt-20 sm:grid-cols-2 sm:gap-y-16 lg:mt-28 lg:grid-cols-4">
{stats.map((stat, statIdx) => (
<div key={statIdx} className="flex flex-col-reverse gap-y-3 border-l border-white/20 pl-6">
<dt className="text-base/7 text-gray-300">{stat.label}</dt>
<dd className="text-3xl font-semibold tracking-tight text-white">{stat.value}</dd>
</div>
))}
</dl>
</div>
</div>
{/* Image section */}
<div className="mt-32 sm:mt-40 xl:mx-auto xl:max-w-7xl xl:px-8">
<img
alt=""
src="https://images.unsplash.com/photo-1521737852567-6949f3f9f2b5?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2894&q=80"
className="aspect-9/4 w-full object-cover outline-1 -outline-offset-1 outline-white/10 xl:rounded-3xl"
/>
</div>
{/* Feature section */}
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
<div className="mx-auto max-w-2xl lg:mx-0">
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">Our values</h2>
<p className="mt-6 text-lg/8 text-gray-300">
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste
dolor cupiditate blanditiis.
</p>
</div>
<dl className="mx-auto mt-16 grid max-w-2xl grid-cols-1 gap-8 text-base/7 text-gray-400 sm:grid-cols-2 lg:mx-0 lg:max-w-none lg:grid-cols-3 lg:gap-x-16">
{values.map((value) => (
<div key={value.name} className="relative pl-9">
<dt className="inline font-semibold text-white">
<value.icon aria-hidden="true" className="absolute top-1 left-1 size-5 text-indigo-500" />
{value.name}
</dt>{' '}
<dd className="inline">{value.description}</dd>
</div>
))}
</dl>
</div>
{/* Team section */}
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
<div className="mx-auto max-w-2xl lg:mx-0">
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">Our team</h2>
<p className="mt-6 text-lg/8 text-gray-400">
Were 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">&rarr;</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>
)
}

View File

@ -0,0 +1,132 @@
'use client'
import React, { useState, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import { Point, Area } from 'react-easy-crop'
interface 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>
)
}

View File

@ -0,0 +1,84 @@
import { authFetch } from '../../../utils/authFetch';
export type AddAffiliatePayload = {
name: string;
description: string;
url: string;
category: string;
commissionRate?: string;
isActive?: boolean;
logoFile?: File;
};
export async function addAffiliate(payload: AddAffiliatePayload) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/affiliates`;
// Use FormData if there's a logo file, otherwise JSON
let body: FormData | string;
let headers: Record<string, string>;
if (payload.logoFile) {
const formData = new FormData();
formData.append('name', payload.name);
formData.append('description', payload.description);
formData.append('url', payload.url);
formData.append('category', payload.category);
if (payload.commissionRate) formData.append('commission_rate', payload.commissionRate);
formData.append('is_active', String(payload.isActive ?? true));
formData.append('logo', payload.logoFile);
body = formData;
headers = { Accept: 'application/json' }; // Don't set Content-Type, browser will set it with boundary
} else {
body = JSON.stringify({
name: payload.name,
description: payload.description,
url: payload.url,
category: payload.category,
commission_rate: payload.commissionRate,
is_active: payload.isActive ?? true,
});
headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
const res = await authFetch(url, {
method: 'POST',
headers,
body,
});
let responseBody: any = null;
try {
responseBody = await res.json();
} catch {
responseBody = null;
}
const ok = res.status === 201 || res.ok;
const message =
responseBody?.message ||
(res.status === 409
? 'Affiliate already exists.'
: res.status === 400
? 'Invalid request. Check affiliate data.'
: res.status === 401
? 'Unauthorized.'
: res.status === 403
? 'Forbidden.'
: res.status === 500
? 'Internal server error.'
: !ok
? `Request failed (${res.status}).`
: '');
return {
ok,
status: res.status,
body: responseBody,
message,
};
}

View File

@ -0,0 +1,34 @@
import { authFetch } from '../../../utils/authFetch';
export async function deleteAffiliate(id: string) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/affiliates/${id}`;
const res = await authFetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
},
});
let body: any = null;
try {
body = await res.json();
} catch {
body = null;
}
const ok = res.ok;
const message =
body?.message ||
(res.status === 404
? 'Affiliate not found.'
: res.status === 403
? 'Forbidden.'
: res.status === 500
? 'Server error.'
: !ok
? `Request failed (${res.status}).`
: 'Affiliate deleted successfully.');
return { ok, status: res.status, body, message };
}

View File

@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { authFetch } from '../../../utils/authFetch';
import { log } from '../../../utils/logger';
export type AdminAffiliate = {
id: string;
name: string;
description: string;
url: string;
logoUrl?: string;
category: string;
isActive: boolean;
commissionRate?: string;
createdAt: string;
};
export function useAdminAffiliates() {
const [affiliates, setAffiliates] = useState<AdminAffiliate[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError('');
const url = `${BASE_URL}/api/admin/affiliates`;
log("🌐 Affiliates: GET", url);
try {
const headers = { Accept: 'application/json' };
log("📤 Affiliates: Request headers:", headers);
const res = await authFetch(url, { headers });
log("📡 Affiliates: Response status:", res.status);
let body: any = null;
try {
body = await res.clone().json();
const preview = JSON.stringify(body).slice(0, 600);
log("📦 Affiliates: Response body preview:", preview);
} catch {
log("📦 Affiliates: Response body is not JSON or failed to parse");
}
if (res.status === 401) {
if (!cancelled) setError('Unauthorized. Please log in.');
return;
}
if (res.status === 403) {
if (!cancelled) setError('Forbidden. Admin access required.');
return;
}
if (!res.ok) {
if (!cancelled) setError('Failed to load affiliates.');
return;
}
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
log("🔧 Affiliates: Mapping items count:", apiItems.length);
const mapped: AdminAffiliate[] = apiItems.map(item => ({
id: String(item.id),
name: String(item.name ?? 'Unnamed Affiliate'),
description: String(item.description ?? ''),
url: String(item.url ?? ''),
logoUrl: item.logoUrl ? String(item.logoUrl) : undefined,
category: String(item.category ?? 'Other'),
isActive: Boolean(item.is_active),
commissionRate: item.commission_rate ? String(item.commission_rate) : undefined,
createdAt: String(item.created_at ?? new Date().toISOString()),
}));
log("✅ Affiliates: Mapped sample:", mapped.slice(0, 3));
if (!cancelled) setAffiliates(mapped);
} catch (e: any) {
log("❌ Affiliates: Network or parsing error:", e?.message || e);
if (!cancelled) setError('Network error while loading affiliates.');
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [BASE_URL]);
return {
affiliates,
loading,
error,
refresh: async () => {
const url = `${BASE_URL}/api/admin/affiliates`;
log("🔁 Affiliates: Refresh GET", url);
const res = await authFetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) {
log("❌ Affiliates: Refresh failed status:", res.status);
return false;
}
const body = await res.json();
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
setAffiliates(apiItems.map(item => ({
id: String(item.id),
name: String(item.name ?? 'Unnamed Affiliate'),
description: String(item.description ?? ''),
url: String(item.url ?? ''),
logoUrl: item.logoUrl ? String(item.logoUrl) : undefined,
category: String(item.category ?? 'Other'),
isActive: Boolean(item.is_active),
commissionRate: item.commission_rate ? String(item.commission_rate) : undefined,
createdAt: String(item.created_at ?? new Date().toISOString()),
})));
log("✅ Affiliates: Refresh succeeded, items:", apiItems.length);
return true;
}
};
}

View File

@ -0,0 +1,80 @@
import { authFetch } from '../../../utils/authFetch';
export type UpdateAffiliatePayload = {
id: string;
name: string;
description: string;
url: string;
category: string;
commissionRate?: string;
isActive: boolean;
logoFile?: File;
removeLogo?: boolean;
};
export async function updateAffiliate(payload: UpdateAffiliatePayload) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/affiliates/${payload.id}`;
// Use FormData if there's a logo file or removeLogo flag, otherwise JSON
let body: FormData | string;
let headers: Record<string, string>;
if (payload.logoFile || payload.removeLogo) {
const formData = new FormData();
formData.append('name', payload.name);
formData.append('description', payload.description);
formData.append('url', payload.url);
formData.append('category', payload.category);
if (payload.commissionRate) formData.append('commission_rate', payload.commissionRate);
formData.append('is_active', String(payload.isActive));
if (payload.logoFile) formData.append('logo', payload.logoFile);
if (payload.removeLogo) formData.append('removeLogo', 'true');
body = formData;
headers = { Accept: 'application/json' };
} else {
body = JSON.stringify({
name: payload.name,
description: payload.description,
url: payload.url,
category: payload.category,
commission_rate: payload.commissionRate,
is_active: payload.isActive,
});
headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
const res = await authFetch(url, {
method: 'PATCH',
headers,
body,
});
let responseBody: any = null;
try {
responseBody = await res.json();
} catch {
responseBody = null;
}
const ok = res.ok;
const message =
responseBody?.message ||
(res.status === 404
? 'Affiliate not found.'
: res.status === 400
? 'Invalid request.'
: res.status === 403
? 'Forbidden.'
: res.status === 500
? 'Server error.'
: !ok
? `Request failed (${res.status}).`
: '');
return { ok, status: res.status, body: responseBody, message };
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,367 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement';
type Props = {
editingTemplateId?: string | null;
onCancelEdit?: () => void;
onSaved?: (info?: { action: 'created' | 'revised'; templateId: string }) => void;
};
export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdit }: Props) {
const [name, setName] = useState('');
const [htmlCode, setHtmlCode] = useState('');
const [isPreview, setIsPreview] = useState(false);
const [saving, setSaving] = useState(false);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [lang, setLang] = useState<'en' | 'de'>('en');
const [type, setType] = useState<'contract' | 'bill' | 'other'>('contract');
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract');
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
const [description, setDescription] = useState<string>('');
const [editingMeta, setEditingMeta] = useState<{ id: string; version: number; state: string } | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const { uploadTemplate, updateTemplateState, getTemplate, reviseTemplate } = useContractManagement();
const resetEditorFields = () => {
setName('');
setHtmlCode('');
setDescription('');
setIsPreview(false);
setEditingMeta(null);
};
// Load template into editor when editing
useEffect(() => {
const load = async () => {
if (!editingTemplateId) {
setEditingMeta(null);
return;
}
setSaving(true);
setStatusMsg(null);
try {
const tpl = await getTemplate(editingTemplateId);
setName(tpl.name || '');
setHtmlCode(tpl.html || '');
setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
setLang((tpl.lang as any) || 'en');
setType((tpl.type as any) || 'contract');
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
setEditingMeta({
id: editingTemplateId,
version: Number(tpl.version || 1),
state: String(tpl.state || 'inactive')
});
setStatusMsg(`Loaded template for editing (v${Number(tpl.version || 1)}).`);
} catch (e: any) {
setStatusMsg(e?.message || 'Failed to load template.');
} finally {
setSaving(false);
}
};
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingTemplateId]);
// Build a full HTML doc if user pasted only a snippet
const wrapIfNeeded = (src: string) => {
const hasDoc = /<!DOCTYPE|<html[\s>]/i.test(src);
if (hasDoc) return src;
// Minimal A4 skeleton so snippets render and print correctly
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Preview</title>
<style>
@page { size: A4; margin: 0; }
html, body { margin:0; padding:0; background:#eee; }
body { display:flex; justify-content:center; }
.a4 { width:210mm; min-height:297mm; background:#fff; box-shadow:0 0 5mm rgba(0,0,0,0.1); box-sizing:border-box; padding:20mm; }
@media print {
html, body { background:#fff; }
.a4 { box-shadow:none; margin:0; }
}
</style>
</head>
<body>
<div class="a4">${src}</div>
</body>
</html>`;
};
// Write/refresh iframe preview
useEffect(() => {
if (!isPreview) return;
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc) return;
const html = wrapIfNeeded(htmlCode);
doc.open();
doc.write(html);
doc.close();
const resize = () => {
// Allow time for layout/styles
requestAnimationFrame(() => {
const h = doc.body ? Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight) : 1200;
iframe.style.height = Math.min(Math.max(h, 1123), 2000) + 'px'; // clamp for UX
});
};
// Initial resize and after load
resize();
const onLoad = () => resize();
iframe.addEventListener('load', onLoad);
// Also observe mutations to adjust height if content changes
const mo = new MutationObserver(resize);
mo.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
return () => {
iframe.removeEventListener('load', onLoad);
mo.disconnect();
};
}, [isPreview, htmlCode]);
const printPreview = () => {
const w = iframeRef.current?.contentWindow;
w?.focus();
w?.print();
};
const slug = (s: string) =>
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'template';
// NEW: all-fields-required guard
const canSave = Boolean(
name.trim() &&
htmlCode.trim() &&
type &&
(type === 'contract' ? contractType : true) &&
userType &&
lang
)
const save = async (publish: boolean) => {
const html = htmlCode.trim();
// NEW: validate all fields
if (!canSave) {
setStatusMsg('Please fill all required fields (name, HTML, type, user type, language).');
return;
}
if (publish && type === 'contract') {
const kind = contractType === 'gdpr' ? 'GDPR' : 'Contract';
const ok = window.confirm(
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
);
if (!ok) return;
}
setSaving(true);
setStatusMsg(null);
try {
// Build a file from HTML code
const file = new File([html], `${slug(name)}.html`, { type: 'text/html' });
// If editing: revise (new object + version bump) and deactivate previous
if (editingTemplateId) {
const revised = await reviseTemplate(editingTemplateId, {
file,
name,
type,
contract_type: type === 'contract' ? contractType : undefined,
lang,
description: description || undefined,
user_type: userType,
state: publish ? 'active' : 'inactive',
});
setStatusMsg(publish ? 'New version created and activated (previous deactivated).' : 'New version created (previous deactivated).');
if (onSaved && revised?.id) onSaved({ action: 'revised', templateId: String(revised.id) });
resetEditorFields();
return;
}
// Otherwise: create new
const created = await uploadTemplate({
file,
name,
type,
contract_type: type === 'contract' ? contractType : undefined,
lang,
description: description || undefined,
user_type: userType,
});
if (publish && created?.id) {
await updateTemplateState(created.id, 'active');
}
setStatusMsg(publish ? 'Template created and activated.' : 'Template created.');
if (onSaved && created?.id) onSaved({ action: 'created', templateId: String(created.id) });
// Reset so another template can be created immediately
resetEditorFields();
} catch (e: any) {
setStatusMsg(e?.message || 'Save failed.');
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
{editingMeta && (
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-3 flex items-center justify-between gap-3">
<div className="text-sm text-indigo-900">
<span className="font-semibold">Editing:</span> {name || 'Untitled'} (v{editingMeta.version}) state: {editingMeta.state}
</div>
{onCancelEdit && (
<button
type="button"
onClick={() => {
resetEditorFields();
onCancelEdit();
}}
className="inline-flex items-center rounded-lg bg-white hover:bg-gray-50 text-gray-900 px-3 py-1.5 text-sm font-medium shadow border border-gray-200 transition"
>
Cancel editing
</button>
)}
</div>
)}
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4">
<input
type="text"
placeholder="Template name"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full sm:w-1/2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsPreview((v) => !v)}
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow transition"
>
{isPreview ? 'Switch to Code' : 'Preview HTML'}
</button>
{isPreview && (
<button
type="button"
onClick={printPreview}
className="inline-flex items-center rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-4 py-2 text-sm font-medium shadow transition"
>
Print
</button>
)}
</div>
</div>
{/* New metadata inputs */}
<div className="flex flex-col sm:flex-row gap-4">
<select
value={type}
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'other')}
required
className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
>
<option value="contract">Contract</option>
<option value="bill">Bill</option>
<option value="other">Other</option>
</select>
{type === 'contract' && (
<select
value={contractType}
onChange={(e) => setContractType(e.target.value as 'contract' | 'gdpr')}
required
className="w-full sm:w-40 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
>
<option value="contract">Contract</option>
<option value="gdpr">GDPR</option>
</select>
)}
<select
value={userType}
onChange={(e) => setUserType(e.target.value as 'personal' | 'company' | 'both')}
required
className="w-full sm:w-40 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
>
<option value="personal">Personal</option>
<option value="company">Company</option>
<option value="both">Both</option>
</select>
<select
value={lang}
onChange={(e) => setLang(e.target.value as 'en' | 'de')}
required
className="w-full sm:w-32 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
>
<option value="en">English (en)</option>
<option value="de">Deutsch (de)</option>
</select>
<input
type="text"
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
/>
</div>
</div>
{!isPreview && (
<textarea
value={htmlCode}
onChange={(e) => setHtmlCode(e.target.value)}
placeholder="Paste your full HTML (or snippet) here…"
required
className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow"
/>
)}
{isPreview && (
<div className="rounded-lg border border-gray-300 bg-white shadow">
<iframe
ref={iframeRef}
title="Contract Preview"
className="w-full rounded-lg"
style={{ height: 1200, background: 'transparent' }}
/>
</div>
)}
<div className="flex items-center gap-4">
<button
onClick={() => save(false)}
disabled={saving || !canSave}
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
>
Create (inactive)
</button>
<button
onClick={() => save(true)}
disabled={saving || !canSave}
className="inline-flex items-center rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
>
Create & Activate
</button>
{/* NEW: helper text */}
{!canSave && <span className="text-xs text-red-600">Fill all fields to proceed.</span>}
{saving && <span className="text-xs text-gray-500">Saving</span>}
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
</div>
</div>
);
}

View File

@ -0,0 +1,193 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement';
type Props = {
refreshKey?: number;
onEdit?: (id: string) => void;
};
type ContractTemplate = {
id: string;
name: string;
type?: string;
contract_type?: string | null;
user_type?: string | null;
version: number;
status: 'draft' | 'published' | 'archived' | string;
updatedAt?: string;
};
function Pill({ children, className }: { children: React.ReactNode; className: string }) {
return <span className={`px-2 py-0.5 rounded text-xs font-semibold border ${className}`}>{children}</span>;
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800 border border-gray-300',
published: 'bg-green-100 text-green-800 border border-green-300',
archived: 'bg-yellow-100 text-yellow-800 border border-yellow-300',
};
const cls = map[status] || 'bg-blue-100 text-blue-800 border border-blue-300';
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{status}</span>;
}
export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) {
const [items, setItems] = useState<ContractTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState('');
const {
listTemplates,
openPreviewInNewTab,
generatePdf,
downloadPdf,
updateTemplateState,
downloadBlobFile,
} = useContractManagement();
const filtered = useMemo(() => {
const term = q.trim().toLowerCase();
if (!term) return items;
return items.filter((i) => i.name.toLowerCase().includes(term) || String(i.version).includes(term) || i.status.toLowerCase().includes(term));
}, [items, q]);
const load = async () => {
setLoading(true);
try {
const data = await listTemplates();
const mapped: ContractTemplate[] = data.map((x: any) => ({
id: x.id ?? x._id ?? x.uuid,
name: x.name ?? 'Untitled',
type: x.type,
contract_type: x.contract_type ?? x.contractType ?? null,
user_type: x.user_type ?? x.userType ?? null,
version: Number(x.version ?? 1),
status: (x.state === 'active') ? 'published' : 'draft',
updatedAt: x.updatedAt ?? x.modifiedAt ?? x.updated_at,
}));
setItems(mapped);
} catch {
setItems((prev) => prev.length ? prev : [
{ id: 'ex1', name: 'Sample Contract A', version: 1, status: 'draft', updatedAt: new Date().toISOString() },
{ id: 'ex2', name: 'NDA Template', version: 3, status: 'published', updatedAt: new Date().toISOString() },
]);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshKey]);
const onToggleState = async (id: string, current: string) => {
const target = current === 'published' ? 'inactive' : 'active';
// Confirmation: activating a contract/GDPR will deactivate other active templates of the same kind
if (target === 'active') {
const tpl = items.find((i) => i.id === id);
if (tpl?.type === 'contract') {
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract';
const ok = window.confirm(
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
);
if (!ok) return;
}
}
try {
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
// Update clicked item immediately, then refresh list to reflect any auto-deactivations.
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
await load();
} catch {}
};
const onPreview = (id: string) => openPreviewInNewTab(id);
const onGenPdf = async (id: string) => {
try {
const blob = await generatePdf(id, { preview: true });
downloadBlobFile(blob, `${id}-preview.pdf`);
} catch {}
};
const onDownloadPdf = async (id: string) => {
try {
const blob = await downloadPdf(id);
downloadBlobFile(blob, `${id}.pdf`);
} catch {}
};
return (
<div className="space-y-4">
<div className="flex gap-2 items-center">
<input
placeholder="Search templates…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
/>
<button
onClick={load}
disabled={loading}
className="rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-4 py-2 text-sm font-medium shadow disabled:opacity-60"
>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((c) => (
<div key={c.id} className="rounded-xl border border-gray-100 bg-white shadow-sm p-4 flex flex-col gap-2 hover:shadow-md transition">
<div className="flex items-center gap-2">
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
<StatusBadge status={c.status} />
{c.type && (
<Pill className="bg-slate-50 text-slate-800 border-slate-200">
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : 'Other'}
</Pill>
)}
{c.type === 'contract' && (
<Pill className="bg-indigo-50 text-indigo-800 border-indigo-200">
{c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'}
</Pill>
)}
{c.user_type && (
<Pill className="bg-emerald-50 text-emerald-800 border-emerald-200">
{c.user_type === 'personal' ? 'Personal' : c.user_type === 'company' ? 'Company' : 'Both'}
</Pill>
)}
</div>
<p className="text-xs text-gray-500">Version {c.version}{c.updatedAt ? ` • Updated ${new Date(c.updatedAt).toLocaleString()}` : ''}</p>
<div className="flex flex-wrap gap-2 mt-2">
{onEdit && (
<button
onClick={() => onEdit(c.id)}
className="px-3 py-1 text-xs rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 transition"
>
Edit
</button>
)}
<button onClick={() => onPreview(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Preview</button>
<button onClick={() => onGenPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">PDF</button>
<button onClick={() => onDownloadPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Download</button>
<button onClick={() => onToggleState(c.id, c.status)} className={`px-3 py-1 text-xs rounded-lg font-semibold transition
${c.status === 'published'
? 'bg-red-100 hover:bg-red-200 text-red-700 border border-red-200'
: 'bg-indigo-600 hover:bg-indigo-500 text-white border border-indigo-600'}`}>
{c.status === 'published' ? 'Deactivate' : 'Activate'}
</button>
</div>
</div>
))}
{!filtered.length && (
<div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,435 @@
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import useContractManagement, { CompanyStamp } from '../hooks/useContractManagement';
import DeleteConfirmationModal from '../../../components/delete/deleteConfirmationModal';
type Props = {
onUploaded?: () => void;
};
export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
const [file, setFile] = useState<File | null>(null);
const [label, setLabel] = useState<string>('');
const [uploading, setUploading] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [stamps, setStamps] = useState<CompanyStamp[]>([]);
const [showModal, setShowModal] = useState(false);
const [modalFile, setModalFile] = useState<File | null>(null);
const [modalLabel, setModalLabel] = useState<string>('');
const [modalError, setModalError] = useState<string | null>(null);
const [modalUploading, setModalUploading] = useState(false);
const [deleteModal, setDeleteModal] = useState<{ open: boolean; id?: string; label?: string; active?: boolean }>({ open: false });
const [deleteLoading, setDeleteLoading] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const {
uploadCompanyStamp,
listStampsAll,
activateCompanyStamp,
deactivateCompanyStamp,
deleteCompanyStamp,
} = useContractManagement();
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);
const modalPreviewUrl = useMemo(() => (modalFile ? URL.createObjectURL(modalFile) : null), [modalFile]);
const onPick = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) setFile(f);
};
const loadStamps = async () => {
try {
const { stamps, active, activeId } = await listStampsAll();
console.debug('[CM/UI] loadStamps (/all):', {
count: stamps.length,
activeId,
withImgCount: stamps.filter((s) => !!s.base64).length,
});
setStamps(stamps);
} catch (e) {
console.warn('[CM/UI] loadStamps error:', e);
setStamps([]);
}
};
useEffect(() => {
loadStamps();
}, []);
const upload = async () => {
if (!file) {
setMsg('Select an image first.');
return;
}
setUploading(true);
setMsg(null);
try {
await uploadCompanyStamp(file, label || undefined);
console.debug('[CM/UI] upload success, refreshing list');
setMsg('Company stamp uploaded.');
if (onUploaded) onUploaded();
await loadStamps();
// Optional clear
// setFile(null);
// setLabel('');
// if (inputRef.current) inputRef.current.value = '';
} catch (e: any) {
console.warn('[CM/UI] upload error:', e);
setMsg(e?.message || 'Upload failed.');
} finally {
setUploading(false);
}
};
const onActivate = async (id: string) => {
try {
await activateCompanyStamp(id);
console.debug('[CM/UI] activated:', id);
await loadStamps();
setMsg('Activated company stamp.');
} catch (e: any) {
console.warn('[CM/UI] activate error:', e);
setMsg(e?.message || 'Activation failed.');
}
};
const onDeactivate = async (id: string) => {
try {
await deactivateCompanyStamp(id);
console.debug('[CM/UI] deactivated:', id);
await loadStamps();
setMsg('Deactivated company stamp.');
} catch (e: any) {
console.warn('[CM/UI] deactivate error:', e);
setMsg(e?.message || 'Deactivation failed.');
}
};
const onDelete = (id: string, active?: boolean, label?: string) => {
setDeleteModal({ open: true, id, label, active });
};
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setDeleteLoading(true);
try {
await deleteCompanyStamp(deleteModal.id);
console.debug('[CM/UI] deleted:', deleteModal.id);
await loadStamps();
setMsg('Deleted company stamp.');
} catch (e: any) {
console.warn('[CM/UI] delete error:', e);
setMsg(e?.message || 'Delete failed.');
} finally {
setDeleteLoading(false);
setDeleteModal({ open: false });
}
};
// Validation helpers for modal
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
const MAX_BYTES = 5 * 1024 * 1024; // 5MB
const validateFile = (f: File) => {
if (!ACCEPTED_TYPES.includes(f.type)) return `Invalid file type (${f.type}). Allowed: PNG, JPEG, WebP, SVG.`;
if (f.size > MAX_BYTES) return `File too large (${Math.round(f.size / 1024 / 1024)}MB). Max 5MB.`;
return null;
};
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setModalError(null);
const f = e.dataTransfer.files?.[0];
if (!f) return;
const err = validateFile(f);
if (err) { setModalError(err); setModalFile(null); return; }
setModalFile(f);
};
const onBrowse = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setModalError(null);
const err = validateFile(f);
if (err) { setModalError(err); setModalFile(null); return; }
setModalFile(f);
};
const openModal = () => {
setModalLabel('');
setModalFile(null);
setModalError(null);
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setModalUploading(false);
};
const confirmUpload = async () => {
if (!modalFile) {
setModalError('Please select an image.');
return;
}
setModalUploading(true);
setModalError(null);
try {
await uploadCompanyStamp(modalFile, modalLabel || undefined);
if (onUploaded) onUploaded();
await loadStamps();
closeModal();
} catch (e: any) {
setModalError(e?.message || 'Upload failed.');
} finally {
setModalUploading(false);
}
};
const activeStamp = stamps.find((s) => s.active);
useEffect(() => {
if (activeStamp) {
console.debug('[CM/UI] activeStamp:', {
id: activeStamp.id,
label: activeStamp.label,
hasImg: !!activeStamp.base64,
mime: activeStamp.mimeType,
});
if (!activeStamp.base64) {
console.warn('[CM/UI] Active stamp has no image data; preview will show placeholder.');
}
}
}, [activeStamp?.id, activeStamp?.base64]);
const toImgSrc = (s: CompanyStamp) => {
if (!s?.base64) {
console.warn('[CM/UI] toImgSrc: missing base64 for stamp', s?.id);
return '';
}
return s.base64.startsWith('data:') ? s.base64 : `data:${s.mimeType || 'image/png'};base64,${s.base64}`;
};
return (
<div className="space-y-6">
{/* Header with Add New Stamp modal trigger */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-700">Manage your company stamps. One active at a time.</p>
<button
type="button"
onClick={openModal}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-semibold shadow transition"
>
<svg width="18" height="18" fill="currentColor" className="opacity-90"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>
Add New Stamp
</button>
</div>
{/* Emphasized Active stamp */}
{activeStamp && (
<div className="relative rounded-2xl p-[2px] bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 shadow-lg">
<div className="rounded-2xl bg-white p-5 flex items-center gap-6">
<div className="relative">
{activeStamp.base64 ? (
<img
src={toImgSrc(activeStamp)}
alt="Active stamp"
className="h-24 w-24 object-contain rounded-xl ring-2 ring-indigo-200 shadow"
/>
) : (
<div className="h-24 w-24 flex items-center justify-center rounded-xl ring-2 ring-gray-200 bg-gray-50 text-xs text-gray-500">
no image
</div>
)}
<span className="absolute -top-2 -right-2 rounded-full bg-green-600 text-white text-xs px-3 py-1 shadow font-bold">
Active
</span>
</div>
<div className="min-w-0">
<p className="text-base font-semibold text-gray-900 truncate">{activeStamp.label || activeStamp.id}</p>
<p className="text-xs text-gray-500">Auto-applied to documents where applicable.</p>
</div>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => onDeactivate(activeStamp.id)}
className="text-xs px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 shadow transition"
>
Deactivate
</button>
</div>
</div>
</div>
)}
{/* Stamps list */}
{!!stamps.length && (
<div className="mt-2">
<p className="text-sm font-medium text-gray-900 mb-2">Your Company Stamps</p>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{stamps.map((s) => {
const src = toImgSrc(s);
const activeCls = s.active
? 'border-green-300 bg-green-50 shadow'
: 'border-gray-200 hover:border-indigo-300 transition-colors';
return (
<li
key={s.id}
className={`flex items-center justify-between gap-3 p-4 border rounded-xl ${activeCls}`}
>
<div className="flex items-center gap-3">
{s.base64 ? (
<img
src={src}
alt="Stamp"
className="h-14 w-14 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
/>
) : (
<div className="h-14 w-14 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-xs text-gray-500">
no image
</div>
)}
<div className="flex flex-col">
<span className="text-sm text-gray-900">{s.label || s.id}</span>
{s.active && (
<span className="text-xs mt-1 px-2 py-0.5 rounded bg-green-100 text-green-800 w-fit font-semibold">
Active
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{s.active ? (
<button
onClick={() => onDeactivate(s.id)}
className="text-xs px-3 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 transition"
>
Deactivate
</button>
) : (
<button
onClick={() => onActivate(s.id)}
className="text-xs px-3 py-2 rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 transition"
>
Activate
</button>
)}
<button
onClick={() => onDelete(s.id, s.active, s.label ?? undefined)}
className="text-xs px-3 py-2 rounded-lg bg-red-50 hover:bg-red-100 text-red-700 border border-red-200 transition"
>
Delete
</button>
</div>
</li>
);
})}
</ul>
</div>
)}
{/* Modal: Add New Stamp */}
{showModal && (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40" onClick={closeModal} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5">
<div className="p-6 border-b border-gray-100">
<h3 className="text-lg font-bold text-indigo-700">Add New Stamp</h3>
<p className="mt-1 text-xs text-gray-500">
Accepted types: PNG, JPEG, WebP, SVG. Max size: 5MB.
</p>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Label</label>
<input
type="text"
value={modalLabel}
onChange={(e) => setModalLabel(e.target.value)}
placeholder="e.g., Company Seal 2025"
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
/>
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
className="rounded-xl border-2 border-dashed border-indigo-300 hover:border-indigo-400 transition-colors p-4 bg-indigo-50"
>
<div className="flex items-center gap-4">
{modalPreviewUrl ? (
<img
src={modalPreviewUrl}
alt="Preview"
className="h-20 w-20 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
/>
) : (
<div className="h-20 w-20 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-xs text-gray-500">
No image
</div>
)}
<div className="min-w-0">
<p className="text-sm text-gray-900">Drag and drop your stamp here</p>
<p className="text-xs text-gray-500">or click to browse</p>
<div className="mt-2">
<label className="inline-block">
<input
type="file"
accept={ACCEPTED_TYPES.join(',')}
onChange={onBrowse}
className="hidden"
/>
<span className="cursor-pointer text-xs px-3 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 inline-flex items-center gap-1">
<svg width="14" height="14" fill="currentColor"><path d="M12 10v2H2v-2H0v4h14v-4h-2zM7 0l4 4H9v5H5V4H3l4-4z"/></svg>
Choose file
</span>
</label>
</div>
</div>
</div>
</div>
{modalError && <p className="text-xs text-red-600">{modalError}</p>}
</div>
<div className="px-6 py-4 border-t border-gray-100 flex items-center justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="text-sm px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 transition"
>
Cancel
</button>
<button
type="button"
onClick={confirmUpload}
disabled={modalUploading || !modalFile}
className="text-sm px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60 transition"
>
{modalUploading ? 'Uploading…' : 'Upload'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
<DeleteConfirmationModal
open={deleteModal.open}
title="Delete Company Stamp"
description={
deleteModal.active
? `This stamp (${deleteModal.label || deleteModal.id}) is currently active. Are you sure you want to delete it? This action cannot be undone.`
: `Are you sure you want to delete the stamp "${deleteModal.label || deleteModal.id}"? This action cannot be undone.`
}
confirmText="Delete"
cancelText="Cancel"
loading={deleteLoading}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ open: false })}
/>
</div>
);
}

View File

@ -0,0 +1,472 @@
import { useCallback } from 'react';
import useAuthStore from '../../../store/authStore';
export type DocumentTemplate = {
id: string;
name: string;
type?: string;
contract_type?: 'contract' | 'gdpr' | null | string;
lang?: 'en' | 'de' | string;
user_type?: 'personal' | 'company' | 'both' | string;
state?: 'active' | 'inactive' | string;
version?: number;
previewUrl?: string | null;
fileUrl?: string | null;
html?: string | null;
updatedAt?: string;
};
export type CompanyStamp = {
id: string;
label?: string | null;
mimeType?: string;
base64?: string | null; // normalized base64 or data URI
active?: boolean;
createdAt?: string;
// ...other metadata...
};
type Json = Record<string, any>;
function isFormData(body: any): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData;
}
export default function useContractManagement() {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
const getState = useAuthStore.getState;
const authorizedFetch = useCallback(
async <T = any>(
path: string,
init: RequestInit = {},
responseType: 'json' | 'text' | 'blob' = 'json'
): Promise<T> => {
let token = getState().accessToken;
if (!token) {
const ok = await getState().refreshAuthToken();
if (ok) token = getState().accessToken;
}
const headers: Record<string, string> = {
...(init.headers as Record<string, string> || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
// Do not set Content-Type for FormData; browser will set proper boundary
if (!isFormData(init.body) && init.method && init.method !== 'GET') {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
// Debug (safe)
try {
console.debug('[CM] fetch ->', {
url: `${base}${path}`,
method: init.method || 'GET',
hasAuth: !!token,
tokenPrefix: token ? `${token.substring(0, 12)}...` : null,
});
} catch {}
// Include cookies + Authorization on all requests
const res = await fetch(`${base}${path}`, {
credentials: 'include',
...init,
headers,
});
try {
console.debug('[CM] fetch <-', { path, status: res.status, ok: res.ok, ct: res.headers.get('content-type') });
} catch {}
if (!res.ok) {
const text = await res.text().catch(() => '');
console.warn('[CM] fetch error body:', text?.slice(0, 2000));
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
}
// Log and return body by responseType
if (responseType === 'blob') {
const len = res.headers.get('content-length');
try {
console.debug('[CM] fetch body (blob):', { contentType: res.headers.get('content-type'), contentLength: len ? Number(len) : null });
} catch {}
return (await res.blob()) as unknown as T;
}
if (responseType === 'text') {
const text = await res.text();
try {
console.debug('[CM] fetch body (text):', text.slice(0, 2000));
} catch {}
return text as unknown as T;
}
// json (default): read text once, log, then parse
const text = await res.text();
try {
console.debug('[CM] fetch body (json):', text.slice(0, 2000));
} catch {}
try {
return JSON.parse(text) as T;
} catch {
console.warn('[CM] failed to parse JSON, returning empty object');
return {} as T;
}
},
[base]
);
// Document templates
const listTemplates = useCallback(async (): Promise<DocumentTemplate[]> => {
const data = await authorizedFetch<DocumentTemplate[]>('/api/document-templates', { method: 'GET' });
return Array.isArray(data) ? data : [];
}, [authorizedFetch]);
const getTemplate = useCallback(async (id: string): Promise<DocumentTemplate> => {
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}`, { method: 'GET' });
}, [authorizedFetch]);
const previewTemplateHtml = useCallback(async (id: string): Promise<string> => {
return authorizedFetch<string>(`/api/document-templates/${id}/preview`, { method: 'GET' }, 'text');
}, [authorizedFetch]);
const openPreviewInNewTab = useCallback(async (id: string) => {
const html = await previewTemplateHtml(id);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
// No revoke here to keep the tab content; browser will clean up eventually
}, [previewTemplateHtml]);
const generatePdf = useCallback(async (id: string, opts?: { preview?: boolean; sanitize?: boolean }): Promise<Blob> => {
const params = new URLSearchParams();
if (opts?.preview) params.set('preview', '1');
if (opts?.sanitize) params.set('sanitize', '1');
const qs = params.toString() ? `?${params.toString()}` : '';
return authorizedFetch<Blob>(`/api/document-templates/${id}/generate-pdf${qs}`, { method: 'GET' }, 'blob');
}, [authorizedFetch]);
const downloadPdf = useCallback(async (id: string): Promise<Blob> => {
return authorizedFetch<Blob>(`/api/document-templates/${id}/download-pdf`, { method: 'GET' }, 'blob');
}, [authorizedFetch]);
const uploadTemplate = useCallback(async (payload: {
file: File | Blob;
name: string;
type: string;
contract_type?: 'contract' | 'gdpr';
lang: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
}): Promise<DocumentTemplate> => {
const fd = new FormData();
const file = payload.file instanceof File ? payload.file : new File([payload.file], `${payload.name || 'template'}.html`, { type: 'text/html' });
fd.append('file', file);
fd.append('name', payload.name);
fd.append('type', payload.type);
if (payload.type === 'contract' && payload.contract_type) {
fd.append('contract_type', payload.contract_type);
}
fd.append('lang', payload.lang);
if (payload.description) fd.append('description', payload.description);
fd.append('user_type', (payload.user_type ?? 'both'));
return authorizedFetch<DocumentTemplate>('/api/document-templates', { method: 'POST', body: fd });
}, [authorizedFetch]);
const updateTemplate = useCallback(async (id: string, payload: {
file?: File | Blob;
name?: string;
type?: string;
contract_type?: 'contract' | 'gdpr';
lang?: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
}): Promise<DocumentTemplate> => {
const fd = new FormData();
if (payload.file) {
const f = payload.file instanceof File ? payload.file : new File([payload.file], `${payload.name || 'template'}.html`, { type: 'text/html' });
fd.append('file', f);
}
if (payload.name) fd.append('name', payload.name);
if (payload.type) fd.append('type', payload.type);
if ((payload.type === 'contract' || payload.contract_type) && payload.contract_type) {
fd.append('contract_type', payload.contract_type);
}
if (payload.lang) fd.append('lang', payload.lang);
if (payload.description !== undefined) fd.append('description', payload.description);
if (payload.user_type) fd.append('user_type', payload.user_type);
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}`, { method: 'PUT', body: fd });
}, [authorizedFetch]);
// NEW: revise template (create a new template record + bump version + deactivate previous)
const reviseTemplate = useCallback(async (id: string, payload: {
file: File | Blob;
name?: string;
type?: string;
contract_type?: 'contract' | 'gdpr';
lang?: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
state?: 'active' | 'inactive';
}): Promise<DocumentTemplate> => {
const fd = new FormData();
const file = payload.file instanceof File
? payload.file
: new File([payload.file], `${payload.name || 'template'}.html`, { type: 'text/html' });
fd.append('file', file);
if (payload.name !== undefined) fd.append('name', payload.name);
if (payload.type !== undefined) fd.append('type', payload.type);
if (payload.contract_type !== undefined) fd.append('contract_type', payload.contract_type);
if (payload.lang !== undefined) fd.append('lang', payload.lang);
if (payload.description !== undefined) fd.append('description', payload.description);
if (payload.user_type !== undefined) fd.append('user_type', payload.user_type);
if (payload.state !== undefined) fd.append('state', payload.state);
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/revise`, { method: 'POST', body: fd });
}, [authorizedFetch]);
const updateTemplateState = useCallback(async (id: string, state: 'active' | 'inactive'): Promise<DocumentTemplate> => {
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/state`, {
method: 'PATCH',
body: JSON.stringify({ state }),
});
}, [authorizedFetch]);
const generatePdfWithSignature = useCallback(async (id: string, payload: {
signatureImage?: string; signature?: string; signatureData?: string;
userData?: Json; user?: Json; context?: Json;
currentDate?: string;
}): Promise<Blob> => {
const body: Json = {};
if (payload.signatureImage) body.signatureImage = payload.signatureImage;
if (payload.signature) body.signature = payload.signature;
if (payload.signatureData) body.signatureData = payload.signatureData;
if (payload.userData) body.userData = payload.userData;
if (payload.user) body.user = payload.user;
if (payload.context) body.context = payload.context;
if (payload.currentDate) body.currentDate = payload.currentDate;
return authorizedFetch<Blob>(`/api/document-templates/${id}/generate-pdf-with-signature`, {
method: 'POST',
body: JSON.stringify(body),
}, 'blob');
}, [authorizedFetch]);
// Helper: convert various base64 forms into a clean data URI
const toDataUri = useCallback((raw: any, mime?: string | null): string | null => {
if (!raw) return null;
try {
let s = String(raw);
if (s.startsWith('data:')) return s; // already a data URI
// Remove optional "base64," prefix
s = s.replace(/^base64,/, '');
// Remove whitespace/newlines
s = s.replace(/\s+/g, '');
// Convert URL-safe base64 to standard
s = s.replace(/-/g, '+').replace(/_/g, '/');
// Pad to a multiple of 4
while (s.length % 4 !== 0) s += '=';
const m = mime || 'image/png';
return `data:${m};base64,${s}`;
} catch {
return null;
}
}, []);
// Helper: unwrap arrays from common API envelope shapes
const unwrapList = useCallback((raw: any): any[] => {
if (Array.isArray(raw)) return raw;
if (Array.isArray(raw?.data)) return raw.data;
if (Array.isArray(raw?.items)) return raw.items;
if (Array.isArray(raw?.results)) return raw.results;
return [];
}, []);
// Add image_base64 and other common variants
const normalizeStamp = useCallback((s: any, forceActive = false): CompanyStamp => {
const mime = s?.mime_type ?? s?.mimeType ?? s?.mimetype ?? s?.type ?? 'image/png';
const imgRaw =
s?.image ??
s?.image_data ??
s?.image_base64 ?? // backend key seen in logs
s?.imageBase64 ??
s?.base64 ??
s?.data ??
null;
try {
const presentKeys = Object.keys(s || {}).filter(k =>
['image', 'image_data', 'image_base64', 'imageBase64', 'base64', 'data', 'mime_type', 'mimeType'].includes(k)
);
console.debug('[CM] normalizeStamp keys:', presentKeys, 'hasImg:', !!imgRaw);
} catch {}
const dataUri = toDataUri(imgRaw, mime);
return {
id: s?.id ?? s?._id ?? s?.uuid ?? s?.stamp_id ?? String(Math.random()),
label: s?.label ?? null,
mimeType: mime,
base64: dataUri,
active: forceActive ? true : !!(s?.is_active ?? s?.active ?? s?.isActive),
createdAt: s?.createdAt ?? s?.created_at,
};
}, [toDataUri]);
// New: fetch all stamps and the active one in one request
const listStampsAll = useCallback(async (): Promise<{ stamps: CompanyStamp[]; active: CompanyStamp | null; activeId?: string | null; }> => {
const raw = await authorizedFetch<any>('/api/company-stamps/all', { method: 'GET' });
try {
console.debug('[CM] /api/company-stamps/all raw:', {
isArray: Array.isArray(raw),
topKeys: raw && !Array.isArray(raw) ? Object.keys(raw) : [],
dataKeys: raw?.data ? Object.keys(raw.data) : [],
});
// Log first item keys to confirm field names like image_base64
const sample = Array.isArray(raw) ? raw[0] : (raw?.data?.[0] ?? raw?.items?.[0] ?? raw?.stamps?.[0]);
if (sample) console.debug('[CM] /api/company-stamps/all sample keys:', Object.keys(sample));
} catch {}
const container = raw?.data ?? raw;
const rawList: any[] =
Array.isArray(container?.items) ? container.items
: Array.isArray(container?.stamps) ? container.stamps
: Array.isArray(container?.list) ? container.list
: Array.isArray(container) ? container
: Array.isArray(raw?.items) ? raw.items
: Array.isArray(raw) ? raw
: [];
const rawActive = container?.active ?? container?.current ?? container?.activeStamp ?? null;
const stamps = rawList.map((s: any) => normalizeStamp(s));
let active = rawActive ? normalizeStamp(rawActive, true) : null;
// Derive active from list if not provided separately
if (!active) {
const fromList = stamps.find(s => s.active);
if (fromList) active = { ...fromList, active: true };
}
// Mark the active in stamps if present
const activeId = active?.id ?? (container?.active_id ?? container?.activeId ?? null);
const stampsMarked = activeId
? stamps.map((s) => (s.id === activeId ? { ...s, active: true, base64: s.base64 || active?.base64, mimeType: s.mimeType || active?.mimeType } : s))
: stamps;
try {
console.debug('[CM] /api/company-stamps/all normalized:', {
total: stampsMarked.length,
withImg: stampsMarked.filter(s => !!s.base64).length,
activeId: activeId || active?.id || null,
hasActiveImg: !!active?.base64,
});
} catch {}
return { stamps: stampsMarked, active, activeId: activeId || active?.id || null };
}, [authorizedFetch, normalizeStamp]);
const listMyCompanyStamps = useCallback(async (): Promise<CompanyStamp[]> => {
const { stamps } = await listStampsAll();
return stamps;
}, [listStampsAll]);
const getActiveCompanyStamp = useCallback(async (): Promise<CompanyStamp | null> => {
const { active } = await listStampsAll();
return active ?? null;
}, [listStampsAll]);
// helper: convert File/Blob to base64 string and mime
const fileToBase64 = useCallback(
(file: File | Blob) =>
new Promise<{ base64: string; mime: string }>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read file'));
reader.onload = () => {
const result = String(reader.result || '');
const m = result.match(/^data:(.+?);base64,(.*)$/);
if (m) {
resolve({ mime: m[1], base64: m[2] });
} else {
resolve({ mime: (file as File).type || 'application/octet-stream', base64: result });
}
};
reader.readAsDataURL(file);
}),
[]
);
// Upload expects JSON { base64, mime_type/mimeType, label? }
const uploadCompanyStamp = useCallback(async (file: File | Blob, label?: string) => {
const { base64, mime } = await fileToBase64(file);
try {
console.debug('[CM] uploadCompanyStamp payload:', { mime, base64Len: base64?.length || 0, hasLabel: !!label });
} catch {}
return authorizedFetch<CompanyStamp>('/api/company-stamps', {
method: 'POST',
body: JSON.stringify({
base64,
mimeType: mime,
mime_type: mime,
...(label ? { label } : {}),
}),
});
}, [authorizedFetch, fileToBase64]);
const activateCompanyStamp = useCallback(async (id: string) => {
console.debug('[CM] activateCompanyStamp ->', id);
return authorizedFetch<{ success?: boolean }>(`/api/company-stamps/${id}/activate`, { method: 'PATCH' });
}, [authorizedFetch]);
const deactivateCompanyStamp = useCallback(async (id: string) => {
console.debug('[CM] deactivateCompanyStamp ->', id);
return authorizedFetch<{ success?: boolean }>(`/api/company-stamps/${id}/deactivate`, { method: 'PATCH' });
}, [authorizedFetch]);
// Delete a company stamp (strict: no fallback)
const deleteCompanyStamp = useCallback(async (id: string) => {
console.debug('[CM] deleteCompanyStamp ->', id);
return authorizedFetch<{ success?: boolean }>(`/api/company-stamps/${id}`, { method: 'DELETE' });
}, [authorizedFetch]);
const downloadBlobFile = useCallback((blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, []);
const listTemplatesPublic = useCallback(async (): Promise<DocumentTemplate[]> => {
const data = await authorizedFetch<DocumentTemplate[]>('/api/document-templates/public', { method: 'GET' });
return Array.isArray(data) ? data : [];
}, [authorizedFetch]);
return {
// templates
listTemplates,
getTemplate,
previewTemplateHtml,
openPreviewInNewTab,
generatePdf,
downloadPdf,
uploadTemplate,
reviseTemplate,
updateTemplate,
updateTemplateState,
generatePdfWithSignature,
listTemplatesPublic,
// stamps
listStampsAll,
listMyCompanyStamps,
getActiveCompanyStamp,
uploadCompanyStamp,
activateCompanyStamp,
deactivateCompanyStamp,
deleteCompanyStamp,
// utils
downloadBlobFile,
};
}

View File

@ -0,0 +1,134 @@
'use client';
import React, { useState, useEffect } from 'react';
import PageLayout from '../../components/PageLayout';
import ContractEditor from './components/contractEditor';
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
import ContractTemplateList from './components/contractTemplateList';
import useAuthStore from '../../store/authStore';
import { useRouter } from 'next/navigation';
const NAV = [
{ key: 'stamp', label: 'Company Stamp', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> },
{ key: 'templates', label: 'Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg> },
{ key: 'editor', label: 'Create Template', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> },
];
export default function ContractManagementPage() {
const [refreshKey, setRefreshKey] = useState(0);
const user = useAuthStore((s) => s.user);
const [mounted, setMounted] = useState(false);
const router = useRouter();
const [section, setSection] = useState('templates');
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
useEffect(() => { setMounted(true); }, []);
// Only allow admin
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin'))
);
useEffect(() => {
if (mounted && !isAdmin) {
router.replace('/');
}
}, [mounted, isAdmin, router]);
if (!mounted) return null;
if (!isAdmin) return null;
const bumpRefresh = () => setRefreshKey((k) => k + 1);
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
{/* tighter horizontal padding on mobile */}
<div className="flex flex-col md:flex-row max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 md:py-8 gap-6 md:gap-8">
{/* Sidebar Navigation (mobile = horizontal scroll tabs, desktop = vertical) */}
<nav className="md:w-56 w-full md:self-start md:sticky md:top-6">
<div className="flex md:flex-col flex-row gap-2 md:gap-3 overflow-x-auto md:overflow-visible -mx-4 px-4 md:mx-0 md:px-0 pb-2 md:pb-0">
{NAV.map((item) => (
<button
key={item.key}
onClick={() => setSection(item.key)}
className={`flex flex-shrink-0 items-center gap-2 px-4 py-2 rounded-lg font-medium transition whitespace-nowrap text-sm md:text-base
${section === item.key
? 'bg-blue-900 text-blue-50 shadow'
: 'bg-white text-blue-900 hover:bg-blue-50 hover:text-blue-900 border border-blue-200'}`}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</div>
</nav>
{/* Main Content */}
<main className="flex-1 space-y-6 md:space-y-8">
{/* sticky only on md+; smaller padding/title on mobile */}
<header className="md:sticky md:top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-5 px-4 md:py-10 md:px-8 rounded-2xl shadow-lg flex flex-col gap-3 md:gap-4 mb-2 md:mb-4">
<h1 className="text-2xl md:text-4xl font-extrabold text-blue-900 tracking-tight">Contract Management</h1>
<p className="text-sm md:text-lg text-blue-700">
Manage contract templates, company stamp, and create new templates.
</p>
</header>
{/* Section Panels (compact padding on mobile) */}
{section === 'stamp' && (
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
Company Stamp
</h2>
<ContractUploadCompanyStamp onUploaded={bumpRefresh} />
</section>
)}
{section === 'templates' && (
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
Templates
</h2>
<ContractTemplateList
refreshKey={refreshKey}
onEdit={(id) => {
setEditingTemplateId(id);
setSection('editor');
}}
/>
</section>
)}
{section === 'editor' && (
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
Create Template
</h2>
<ContractEditor
editingTemplateId={editingTemplateId}
onCancelEdit={() => {
setEditingTemplateId(null);
setSection('templates');
}}
onSaved={(info) => {
bumpRefresh();
if (info?.action === 'revised') {
setEditingTemplateId(null);
setSection('templates');
}
}}
/>
</section>
)}
</main>
</div>
</div>
</PageLayout>
);
}

View File

@ -0,0 +1,49 @@
import { authFetch } from '../../../utils/authFetch';
import { log } from '../../../utils/logger';
export type SqlExecutionData = {
result?: any;
isMulti?: boolean;
};
export type SqlExecutionMeta = {
durationMs?: number;
};
export async function importSqlDump(file: File): Promise<{ ok: boolean; data?: SqlExecutionData; meta?: SqlExecutionMeta; message?: string }>{
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/sql`;
log('🧪 Dev SQL Import: POST', url);
try {
const form = new FormData();
form.append('file', file);
const res = await authFetch(url, {
method: 'POST',
headers: { Accept: 'application/json' },
body: form
});
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'SQL execution failed.' };
}
return { ok: true, data: body?.data, meta: body?.meta };
} catch (e: any) {
log('❌ Dev SQL: network error', e?.message || e);
return { ok: false, message: 'Network error while executing SQL.' };
}
}

View File

@ -0,0 +1,212 @@
import { authFetch } from '../../../utils/authFetch';
import { log } from '../../../utils/logger';
export type FolderStructureIssueUser = {
userId: number;
email: string;
userType: string;
name?: string | null;
contractCategory: string;
basePrefix: string;
totalObjects: number;
hasContractFolder: boolean;
hasGdprFolder: boolean;
};
export type LooseFileUser = {
userId: number;
email: string;
userType: string;
name?: string | null;
contractCategory: string;
basePrefix: string;
looseObjects: number;
sampleKeys?: string[];
};
export type GhostDirectory = {
userId: number;
contractCategory: string;
basePrefix: string;
};
export type FixResult = {
userId: number;
contractCategory?: string;
created?: number;
moved: number;
skipped: number;
errors?: { key: string; destKey?: string; message?: string }[];
};
export type FixMeta = {
processedUsers?: number;
createdTotal?: number;
movedTotal?: number;
errorCount?: number;
};
export async function listFolderStructureIssues(): Promise<{ ok: boolean; data?: FolderStructureIssueUser[]; meta?: any; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/folder-structure-issues`;
log('🧪 Dev Exoscale: GET', url);
try {
const res = await authFetch(url, { method: 'GET', headers: { Accept: 'application/json' } });
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to load folder structure issues.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while loading invalid folders.' };
}
}
export async function createFolderStructure(userId?: number): Promise<{ ok: boolean; data?: FixResult[]; meta?: FixMeta; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/create-folder-structure`;
log('🧪 Dev Exoscale: POST', url);
try {
const res = await authFetch(url, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(userId ? { userId } : {})
});
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to create folder structure.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while creating folder structure.' };
}
}
export async function listLooseFiles(): Promise<{ ok: boolean; data?: LooseFileUser[]; meta?: any; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/loose-files`;
log('🧪 Dev Exoscale: GET', url);
try {
const res = await authFetch(url, { method: 'GET', headers: { Accept: 'application/json' } });
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to load loose files.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while loading loose files.' };
}
}
export async function moveLooseFilesToContract(userId?: number): Promise<{ ok: boolean; data?: FixResult[]; meta?: FixMeta; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/move-loose-files`;
log('🧪 Dev Exoscale: POST', url);
try {
const res = await authFetch(url, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(userId ? { userId } : {})
});
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to move loose files.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while moving loose files.' };
}
}
export async function listGhostDirectories(): Promise<{ ok: boolean; data?: GhostDirectory[]; meta?: any; message?: string }> {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/dev/exoscale/ghost-directories`;
log('🧪 Dev Exoscale: GET', url);
try {
const res = await authFetch(url, { method: 'GET', headers: { Accept: 'application/json' } });
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = null;
}
if (res.status === 401) {
return { ok: false, message: 'Unauthorized. Please log in.' };
}
if (res.status === 403) {
return { ok: false, message: body?.error || 'Forbidden. Admin access required.' };
}
if (!res.ok) {
return { ok: false, message: body?.error || 'Failed to load ghost directories.' };
}
return { ok: true, data: body?.data || [], meta: body?.meta };
} catch (e: any) {
log('❌ Dev Exoscale: network error', e?.message || e);
return { ok: false, message: 'Network error while loading ghost directories.' };
}
}

View File

@ -0,0 +1,710 @@
'use client'
import React from 'react'
import Header from '../../components/nav/Header'
import Footer from '../../components/Footer'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import { CommandLineIcon, PlayIcon, TrashIcon, ExclamationTriangleIcon, ArrowUpTrayIcon, WrenchScrewdriverIcon, FolderOpenIcon, ArrowPathIcon } from '@heroicons/react/24/outline'
import useAuthStore from '../../store/authStore'
import { useRouter } from 'next/navigation'
import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/executeSql'
import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance'
export default function DevManagementPage() {
const router = useRouter()
const user = useAuthStore(s => s.user)
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin')) ||
(user as any)?.role === 'super_admin' ||
(user as any)?.userType === 'super_admin' ||
(user as any)?.isSuperAdmin === true ||
((user as any)?.roles?.includes?.('super_admin'))
)
const [authChecked, setAuthChecked] = React.useState(false)
React.useEffect(() => {
if (user === null) {
router.replace('/login')
return
}
if (user && !isAdmin) {
router.replace('/admin')
return
}
setAuthChecked(true)
}, [user, isAdmin, router])
const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('')
const [result, setResult] = React.useState<SqlExecutionData | null>(null)
const [meta, setMeta] = React.useState<SqlExecutionMeta | null>(null)
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
const [activeTab, setActiveTab] = React.useState<'sql' | 'structure' | 'loose' | 'ghost'>('sql')
const [structureUsers, setStructureUsers] = React.useState<FolderStructureIssueUser[]>([])
const [structureMeta, setStructureMeta] = React.useState<{ scannedUsers?: number; invalidCount?: number } | null>(null)
const [looseUsers, setLooseUsers] = React.useState<LooseFileUser[]>([])
const [looseMeta, setLooseMeta] = React.useState<{ scannedUsers?: number; looseCount?: number } | null>(null)
const [ghostDirs, setGhostDirs] = React.useState<GhostDirectory[]>([])
const [ghostMeta, setGhostMeta] = React.useState<{ ghostCount?: number } | null>(null)
const [exoscaleLoading, setExoscaleLoading] = React.useState(false)
const [exoscaleError, setExoscaleError] = React.useState('')
const [fixingUserId, setFixingUserId] = React.useState<number | null>(null)
const [fixingAll, setFixingAll] = React.useState(false)
const [fixResults, setFixResults] = React.useState<Record<number, FixResult>>({})
const [structureActionMeta, setStructureActionMeta] = React.useState<{ processedUsers?: number; createdTotal?: number; errorCount?: number } | null>(null)
const [structureActionResults, setStructureActionResults] = React.useState<FixResult[]>([])
const [looseActionMeta, setLooseActionMeta] = React.useState<{ processedUsers?: number; movedTotal?: number; errorCount?: number } | null>(null)
const [looseActionResults, setLooseActionResults] = React.useState<FixResult[]>([])
const [structureStatus, setStructureStatus] = React.useState('')
const [looseStatus, setLooseStatus] = React.useState('')
const [ghostStatus, setGhostStatus] = React.useState('')
const formatNow = () => new Date().toLocaleString()
const runImport = async () => {
setError('')
if (!selectedFile) {
setError('Please select a SQL dump file.')
return
}
setLoading(true)
try {
const res = await importSqlDump(selectedFile)
if (!res.ok) {
setError(res.message || 'Failed to import SQL dump.')
return
}
setResult(res.data || null)
setMeta(res.meta || null)
} finally {
setLoading(false)
}
}
const clearResults = () => {
setResult(null)
setMeta(null)
setError('')
}
const loadStructureIssues = async () => {
setExoscaleError('')
setExoscaleLoading(true)
setStructureStatus('Refreshing list...')
try {
const res = await listFolderStructureIssues()
if (!res.ok) {
setExoscaleError(res.message || 'Failed to load folder structure issues.')
return
}
setStructureUsers(res.data || [])
setStructureMeta(res.meta || null)
setStructureStatus(`Last refresh: ${formatNow()}`)
} finally {
setExoscaleLoading(false)
}
}
const loadLooseFiles = async () => {
setExoscaleError('')
setExoscaleLoading(true)
setLooseStatus('Refreshing list...')
try {
const res = await listLooseFiles()
if (!res.ok) {
setExoscaleError(res.message || 'Failed to load loose files.')
return
}
setLooseUsers(res.data || [])
setLooseMeta(res.meta || null)
setLooseStatus(`Last refresh: ${formatNow()}`)
} finally {
setExoscaleLoading(false)
}
}
const loadGhostDirectories = async () => {
setExoscaleError('')
setExoscaleLoading(true)
setGhostStatus('Refreshing list...')
try {
const res = await listGhostDirectories()
if (!res.ok) {
setExoscaleError(res.message || 'Failed to load ghost directories.')
return
}
setGhostDirs(res.data || [])
setGhostMeta(res.meta || null)
setGhostStatus(`Last refresh: ${formatNow()}`)
} finally {
setExoscaleLoading(false)
}
}
const runCreateStructure = async (userId?: number) => {
setExoscaleError('')
if (userId) {
setFixingUserId(userId)
setStructureStatus(`Creating folders for #${userId}...`)
} else {
setFixingAll(true)
setStructureStatus('Creating folders for each user...')
setStructureActionResults([])
setStructureActionMeta({ processedUsers: 0, createdTotal: 0, errorCount: 0 })
}
try {
const targets = userId ? [{ userId }] : structureUsers.map(u => ({ userId: u.userId }))
for (const target of targets) {
const res = await createFolderStructure(target.userId)
if (!res.ok) {
setExoscaleError(res.message || 'Failed to create folder structure.')
break
}
const batch = res.data || []
setStructureActionResults(prev => [...prev, ...batch])
setStructureActionMeta(prev => ({
processedUsers: (prev?.processedUsers || 0) + (res.meta?.processedUsers || 0),
createdTotal: (prev?.createdTotal || 0) + (res.meta?.createdTotal || 0),
errorCount: (prev?.errorCount || 0) + (res.meta?.errorCount || 0)
}))
setFixResults(prev => {
const next = { ...prev }
for (const item of batch) {
next[item.userId] = item
}
return next
})
setStructureStatus(`Last create: ${formatNow()} (processed #${target.userId})`)
}
await loadStructureIssues()
} finally {
setFixingUserId(null)
setFixingAll(false)
}
}
const runMoveLooseFiles = async (userId?: number) => {
setExoscaleError('')
if (userId) {
setFixingUserId(userId)
setLooseStatus(`Moving loose files for #${userId}...`)
} else {
setFixingAll(true)
setLooseStatus('Moving loose files for each user...')
setLooseActionResults([])
setLooseActionMeta({ processedUsers: 0, movedTotal: 0, errorCount: 0 })
}
try {
const targets = userId ? [{ userId }] : looseUsers.map(u => ({ userId: u.userId }))
for (const target of targets) {
const res = await moveLooseFilesToContract(target.userId)
if (!res.ok) {
setExoscaleError(res.message || 'Failed to move loose files.')
break
}
const batch = res.data || []
setLooseActionResults(prev => [...prev, ...batch])
setLooseActionMeta(prev => ({
processedUsers: (prev?.processedUsers || 0) + (res.meta?.processedUsers || 0),
movedTotal: (prev?.movedTotal || 0) + (res.meta?.movedTotal || 0),
errorCount: (prev?.errorCount || 0) + (res.meta?.errorCount || 0)
}))
setFixResults(prev => {
const next = { ...prev }
for (const item of batch) {
next[item.userId] = item
}
return next
})
setLooseStatus(`Last move: ${formatNow()} (processed #${target.userId})`)
}
await loadLooseFiles()
} finally {
setFixingUserId(null)
setFixingAll(false)
}
}
React.useEffect(() => {
if (activeTab === 'structure') loadStructureIssues()
if (activeTab === 'loose') loadLooseFiles()
if (activeTab === 'ghost') loadGhostDirectories()
}, [activeTab])
const onImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setSelectedFile(file)
e.target.value = ''
}
if (!authChecked) return null
return (
<PageTransitionEffect>
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-50 via-white to-blue-50">
<Header />
{/* tighter padding on mobile */}
<main className="flex-1 py-6 md:py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<header className="bg-white/90 backdrop-blur border border-blue-100 py-6 md:py-8 px-4 sm:px-6 rounded-2xl shadow-lg flex flex-col gap-4">
{/* stack on mobile */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="h-11 w-11 sm:h-12 sm:w-12 rounded-xl bg-blue-100 border border-blue-200 flex items-center justify-center flex-shrink-0">
<CommandLineIcon className="h-6 w-6 text-blue-700" />
</div>
<div className="min-w-0">
<h1 className="text-2xl sm:text-3xl font-extrabold text-blue-900">Dev Management</h1>
<p className="text-sm sm:text-base text-blue-700">
Import SQL dump files to run database migrations.
</p>
</div>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5 flex-shrink-0" />
<div>
<div className="font-semibold">Use with caution</div>
<div>SQL dumps run immediately and can modify production data.</div>
</div>
</div>
</header>
<div className="mt-6">
<div className="inline-flex w-full sm:w-auto rounded-xl border border-blue-100 bg-white/90 p-1 shadow-sm">
<button
onClick={() => setActiveTab('sql')}
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
activeTab === 'sql' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
}`}
>
<CommandLineIcon className="h-4 w-4" /> SQL Import
</button>
<button
onClick={() => setActiveTab('structure')}
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
activeTab === 'structure' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
}`}
>
<WrenchScrewdriverIcon className="h-4 w-4" /> Folder Structure
</button>
<button
onClick={() => setActiveTab('loose')}
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
activeTab === 'loose' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
}`}
>
<FolderOpenIcon className="h-4 w-4" /> Loose Files
</button>
<button
onClick={() => setActiveTab('ghost')}
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
activeTab === 'ghost' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
}`}
>
<FolderOpenIcon className="h-4 w-4" /> Ghost Directories
</button>
</div>
</div>
{activeTab === 'sql' && (
<>
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold text-blue-900">SQL Dump Import</h2>
{/* actions: stack on mobile, full width */}
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
<button
onClick={() => fileInputRef.current?.click()}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<ArrowUpTrayIcon className="h-4 w-4" /> Import SQL
</button>
<button
onClick={clearResults}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<TrashIcon className="h-4 w-4" /> Clear
</button>
<button
onClick={runImport}
disabled={loading}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
>
<PlayIcon className="h-4 w-4" /> {loading ? 'Importing...' : 'Import'}
</button>
</div>
</div>
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-4 sm:px-6 py-8 sm:py-10 text-center">
<div className="text-sm text-gray-600">Select a .sql dump file using Import SQL.</div>
<div className="mt-2 text-xs text-gray-500">Only SQL dump files are supported.</div>
{selectedFile && (
<div className="mt-4 text-sm text-blue-900 font-semibold break-words">
Selected: {selectedFile.name}
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".sql,text/plain"
className="hidden"
onChange={onImportFile}
/>
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
</div>
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<h3 className="text-lg font-semibold text-blue-900 mb-3">Result Summary</h3>
<div className="space-y-2 text-sm text-gray-700">
<div className="flex justify-between">
<span>Result Sets</span>
<span className="font-semibold text-blue-900">
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
</span>
</div>
<div className="flex justify-between">
<span>Multi-statement</span>
<span className="font-semibold text-blue-900">{result?.isMulti ? 'Yes' : 'No'}</span>
</div>
<div className="flex justify-between">
<span>Duration</span>
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
</div>
</div>
<div className="mt-6 text-xs text-gray-500">
Multi-statement SQL and dump files are supported. Use with caution.
</div>
</div>
</section>
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-blue-900">Import Results</h2>
</div>
{!result && (
<div className="text-sm text-gray-500">No results yet. Import a SQL dump to see output.</div>
)}
{result?.result && (
<pre className="mt-2 rounded-lg bg-slate-50 border border-gray-200 p-3 md:p-4 text-xs text-gray-800 overflow-auto">
{JSON.stringify(result.result, null, 2)}
</pre>
)}
</section>
</>
)}
{activeTab === 'structure' && (
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-blue-900">Exoscale Folder Structure</h2>
<p className="text-sm text-gray-600">
Ensures both contract and gdpr folders exist for each user.
</p>
{structureStatus && (
<div className="text-xs text-slate-500">{structureStatus}</div>
)}
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<button
onClick={loadStructureIssues}
disabled={exoscaleLoading}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => runCreateStructure()}
disabled={fixingAll || exoscaleLoading || structureUsers.length === 0}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
>
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingAll ? 'Creating...' : 'Create All'}
</button>
</div>
</div>
<div className="mb-4 flex flex-wrap gap-3 text-xs text-gray-600">
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
<FolderOpenIcon className="h-4 w-4 text-slate-500" />
Scanned: {structureMeta?.scannedUsers ?? '-'}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
Missing: {structureMeta?.invalidCount ?? structureUsers.length}
</span>
</div>
{exoscaleError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{exoscaleError}
</div>
)}
{exoscaleLoading ? (
<div className="text-sm text-gray-500">Loading folder issues...</div>
) : structureUsers.length === 0 ? (
<div className="text-sm text-gray-500">No missing folders found. Run Refresh to scan again.</div>
) : (
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
{structureUsers.map(user => {
const fix = fixResults[user.userId]
const displayName = user.name || user.email
const missing = [!user.hasContractFolder ? 'contract' : null, !user.hasGdprFolder ? 'gdpr' : null].filter(Boolean).join(', ')
return (
<div key={user.userId} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-blue-900 truncate">{displayName}</div>
<div className="text-xs text-gray-500 truncate">
#{user.userId} {user.email} {user.userType} {user.contractCategory}
</div>
<div className="mt-1 text-xs text-gray-600">Missing: <span className="font-semibold text-blue-900">{missing || 'none'}</span></div>
{fix && (
<div className="mt-2 text-xs text-emerald-700">
Created {fix.created || 0}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => runCreateStructure(user.userId)}
disabled={fixingUserId === user.userId || fixingAll}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingUserId === user.userId ? 'Creating...' : 'Create'}
</button>
</div>
</div>
)
})}
</div>
)}
{(structureActionMeta || structureActionResults.length > 0) && (
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">Last Folder Structure Action</div>
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
<span>Processed: {structureActionMeta?.processedUsers ?? '-'}</span>
<span>Created: {structureActionMeta?.createdTotal ?? '-'}</span>
<span>Errors: {structureActionMeta?.errorCount ?? '-'}</span>
</div>
{structureActionResults.length > 0 && (
<div className="mt-3 space-y-2 text-xs text-gray-700">
{structureActionResults.map(item => (
<div key={item.userId} className="flex flex-wrap gap-2">
<span className="font-semibold text-blue-900">#{item.userId}</span>
{item.contractCategory && (
<span className="text-gray-500">{item.contractCategory}</span>
)}
<span>Created: {item.created ?? 0}</span>
{item.errors && item.errors.length > 0 && (
<span className="text-red-700">Errors: {item.errors.length}</span>
)}
</div>
))}
</div>
)}
</div>
)}
</section>
)}
{activeTab === 'loose' && (
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-blue-900">Loose Files</h2>
<p className="text-sm text-gray-600">
Shows files directly under the user folder that are not in contract or gdpr.
</p>
{looseStatus && (
<div className="text-xs text-slate-500">{looseStatus}</div>
)}
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<button
onClick={loadLooseFiles}
disabled={exoscaleLoading}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => runMoveLooseFiles()}
disabled={fixingAll || exoscaleLoading || looseUsers.length === 0}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
>
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingAll ? 'Moving...' : 'Move All to Contract'}
</button>
</div>
</div>
<div className="mb-4 flex flex-wrap gap-3 text-xs text-gray-600">
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
<FolderOpenIcon className="h-4 w-4 text-slate-500" />
Scanned: {looseMeta?.scannedUsers ?? '-'}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-1">
Loose: {looseMeta?.looseCount ?? looseUsers.length}
</span>
</div>
{exoscaleError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{exoscaleError}
</div>
)}
{exoscaleLoading ? (
<div className="text-sm text-gray-500">Loading loose files...</div>
) : looseUsers.length === 0 ? (
<div className="text-sm text-gray-500">No loose files found. Run Refresh to scan again.</div>
) : (
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
{looseUsers.map(user => {
const fix = fixResults[user.userId]
const displayName = user.name || user.email
return (
<div key={user.userId} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-blue-900 truncate">{displayName}</div>
<div className="text-xs text-gray-500 truncate">
#{user.userId} {user.email} {user.userType} {user.contractCategory}
</div>
<div className="mt-1 text-xs text-gray-600">
Loose files: <span className="font-semibold text-blue-900">{user.looseObjects}</span>
</div>
{user.sampleKeys && user.sampleKeys.length > 0 && (
<div className="mt-2 text-[11px] text-gray-400 break-all">
Sample: {user.sampleKeys.join(', ')}
</div>
)}
{fix && (
<div className="mt-2 text-xs text-emerald-700">
Moved {fix.moved}, skipped {fix.skipped}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => runMoveLooseFiles(user.userId)}
disabled={fixingUserId === user.userId || fixingAll}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingUserId === user.userId ? 'Moving...' : 'Move'}
</button>
</div>
</div>
)
})}
</div>
)}
{(looseActionMeta || looseActionResults.length > 0) && (
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">Last Loose Files Action</div>
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
<span>Processed: {looseActionMeta?.processedUsers ?? '-'}</span>
<span>Moved: {looseActionMeta?.movedTotal ?? '-'}</span>
<span>Errors: {looseActionMeta?.errorCount ?? '-'}</span>
</div>
{looseActionResults.length > 0 && (
<div className="mt-3 space-y-2 text-xs text-gray-700">
{looseActionResults.map(item => (
<div key={item.userId} className="flex flex-wrap gap-2">
<span className="font-semibold text-blue-900">#{item.userId}</span>
{item.contractCategory && (
<span className="text-gray-500">{item.contractCategory}</span>
)}
<span>Moved: {item.moved}</span>
<span>Skipped: {item.skipped}</span>
{item.errors && item.errors.length > 0 && (
<span className="text-red-700">Errors: {item.errors.length}</span>
)}
</div>
))}
</div>
)}
</div>
)}
</section>
)}
{activeTab === 'ghost' && (
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-blue-900">Ghost Directories</h2>
<p className="text-sm text-gray-600">
Exoscale directories that do not have a matching user in the database.
</p>
{ghostStatus && (
<div className="text-xs text-slate-500">{ghostStatus}</div>
)}
</div>
<button
onClick={loadGhostDirectories}
disabled={exoscaleLoading}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
>
<ArrowPathIcon className="h-4 w-4" /> {exoscaleLoading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
<div className="mb-4 text-xs text-gray-600">
Ghost directories: {ghostMeta?.ghostCount ?? ghostDirs.length}
</div>
{exoscaleError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{exoscaleError}
</div>
)}
{exoscaleLoading ? (
<div className="text-sm text-gray-500">Loading ghost directories...</div>
) : ghostDirs.length === 0 ? (
<div className="text-sm text-gray-500">No ghost directories found. Run Refresh to scan again.</div>
) : (
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
{ghostDirs.map(dir => (
<div key={`${dir.contractCategory}-${dir.userId}`} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-blue-900 truncate">#{dir.userId}</div>
<div className="text-xs text-gray-500 truncate">
{dir.contractCategory} {dir.basePrefix}
</div>
</div>
</div>
))}
</div>
)}
</section>
)}
</div>
</main>
<Footer />
</div>
</PageTransitionEffect>
)
}

View File

@ -0,0 +1,93 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import useAuthStore from '../../../store/authStore';
export type AdminInvoice = {
id: string | number;
invoice_number?: string | null;
user_id?: string | number | null;
buyer_name?: string | null;
buyer_email?: string | null;
buyer_street?: string | null;
buyer_postal_code?: string | null;
buyer_city?: string | null;
buyer_country?: string | null;
currency?: string | null;
total_net?: number | null;
total_tax?: number | null;
total_gross?: number | null;
vat_rate?: number | null;
status?: string;
issued_at?: string | null;
due_at?: string | null;
pdf_storage_key?: string | null;
context?: any | null;
created_at?: string | null;
updated_at?: string | null;
};
export function useAdminInvoices(params?: { status?: string; limit?: number; offset?: number }) {
const accessToken = useAuthStore(s => s.accessToken);
const [invoices, setInvoices] = useState<AdminInvoice[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
const inFlight = useRef<AbortController | null>(null);
const fetchInvoices = useCallback(async () => {
setError('');
// Abort previous
inFlight.current?.abort();
const controller = new AbortController();
inFlight.current = controller;
try {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
const qp = new URLSearchParams();
if (params?.status) qp.set('status', params.status);
qp.set('limit', String(params?.limit ?? 200));
qp.set('offset', String(params?.offset ?? 0));
const url = `${base}/api/admin/invoices${qp.toString() ? `?${qp.toString()}` : ''}`;
setLoading(true);
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
signal: controller.signal,
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body?.success === false) {
setInvoices([]);
setError(body?.message || `Failed to load invoices (${res.status})`);
return;
}
const list: AdminInvoice[] = Array.isArray(body?.data) ? body.data : [];
// sort fallback (issued_at DESC then created_at DESC)
list.sort((a, b) => {
const ad = new Date(a.issued_at ?? a.created_at ?? 0).getTime();
const bd = new Date(b.issued_at ?? b.created_at ?? 0).getTime();
return bd - ad;
});
setInvoices(list);
} catch (e: any) {
if (e?.name === 'AbortError') return;
setError(e?.message || 'Network error');
setInvoices([]);
} finally {
setLoading(false);
if (inFlight.current === controller) inFlight.current = null;
}
}, [accessToken, params?.status, params?.limit, params?.offset]);
useEffect(() => {
if (accessToken) fetchInvoices();
return () => inFlight.current?.abort();
}, [accessToken, fetchInvoices]);
return { invoices, loading, error, reload: fetchInvoices };
}

View File

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { authFetch } from '../../../utils/authFetch'
import useAuthStore from '../../../store/authStore'
export type VatRate = {
country_code: string
country_name: string
standard_rate?: number | null
reduced_rate_1?: number | null
reduced_rate_2?: number | null
super_reduced_rate?: number | null
parking_rate?: number | null
effective_year?: number | null
}
export function useVatRates() {
const [rates, setRates] = useState<VatRate[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const reload = () => setRefreshKey(k => k + 1)
useEffect(() => {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/tax/vat-rates`
const token = useAuthStore.getState().accessToken
setLoading(true)
setError(null)
authFetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
})
.then(async (res) => {
const ct = res.headers.get('content-type') || ''
if (!res.ok || !ct.includes('application/json')) {
const txt = await res.text().catch(() => '')
throw new Error(`Request failed: ${res.status} ${txt.slice(0, 160)}`)
}
const json = await res.json()
const arr: VatRate[] = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []
setRates(arr)
})
.catch((e: any) => {
setError(e?.message || 'Failed to load VAT rates')
setRates([])
})
.finally(() => setLoading(false))
}, [refreshKey])
return { rates, loading, error, reload }
}

View File

@ -0,0 +1,246 @@
'use client'
import React, { useMemo, useState } from 'react'
import PageLayout from '../../components/PageLayout'
import { useRouter } from 'next/navigation'
import { useVatRates } from './hooks/getTaxes'
import { useAdminInvoices } from './hooks/getInvoices'
export default function FinanceManagementPage() {
const router = useRouter()
const { rates, loading: vatLoading, error: vatError } = useVatRates()
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' })
// NEW: fetch invoices from backend
const {
invoices,
loading: invLoading,
error: invError,
reload,
} = useAdminInvoices({
status: billFilter.status !== 'all' ? billFilter.status : undefined,
limit: 200,
offset: 0,
})
// NEW: totals from backend invoices
const totals = useMemo(() => {
const now = new Date()
const inRange = (d: Date) => {
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
if (timeframe === '7d') return diff <= 7
if (timeframe === '30d') return diff <= 30
if (timeframe === '90d') return diff <= 90
return true
}
const range = invoices.filter(inv => {
const dStr = inv.issued_at ?? inv.created_at
if (!dStr) return false
const d = new Date(dStr)
return inRange(d)
})
const totalAll = invoices.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
const totalRange = range.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
return { totalAll, totalRange }
}, [invoices, timeframe])
// NEW: filtered rows for table
const filteredBills = useMemo(() => {
const q = billFilter.query.trim().toLowerCase()
const from = billFilter.from ? new Date(billFilter.from) : null
const to = billFilter.to ? new Date(billFilter.to) : null
return invoices.filter(inv => {
const byQuery =
!q ||
String(inv.invoice_number ?? inv.id).toLowerCase().includes(q) ||
String(inv.buyer_name ?? '').toLowerCase().includes(q)
const issued = inv.issued_at ? new Date(inv.issued_at) : (inv.created_at ? new Date(inv.created_at) : null)
const byFrom = from ? (issued ? issued >= from : false) : true
const byTo = to ? (issued ? issued <= to : false) : true
return byQuery && byFrom && byTo
})
}, [invoices, billFilter])
const exportBills = (format: 'csv' | 'pdf') => {
console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
}
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
<header className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex flex-col gap-2">
<h1 className="text-3xl font-extrabold text-blue-900">Finance Management</h1>
<p className="text-sm text-blue-700">Overview of taxes, revenue, and invoices.</p>
</header>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
<div className="text-xs text-gray-500 mb-1">Total revenue (all time)</div>
<div className="text-2xl font-semibold text-[#1C2B4A]">{totals.totalAll.toFixed(2)}</div>
</div>
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
<div className="text-xs text-gray-500 mb-1">Revenue (range)</div>
<div className="text-2xl font-semibold text-[#1C2B4A]">{totals.totalRange.toFixed(2)}</div>
</div>
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
<div className="text-xs text-gray-500 mb-1">Invoices (range)</div>
<div className="text-2xl font-semibold text-[#1C2B4A]">{filteredBills.length}</div>
</div>
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
<div className="text-xs text-gray-500 mb-1">Timeframe</div>
<select
value={timeframe}
onChange={e => setTimeframe(e.target.value as any)}
className="mt-2 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent"
>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="ytd">YTD</option>
</select>
</div>
</div>
{/* VAT summary */}
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[#1C2B4A]">Manage VAT rates</h2>
<p className="text-xs text-gray-600">Live data from backend; edit on a separate page.</p>
</div>
<button
onClick={() => router.push('/admin/finance-management/vat-edit')}
className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90"
>
Edit VAT
</button>
</div>
<div className="text-sm text-gray-700">
{vatLoading && 'Loading VAT rates...'}
{vatError && <span className="text-red-600">{vatError}</span>}
{!vatLoading && !vatError && (
<>Active countries: {rates.length} Examples: {rates.slice(0, 5).map(r => r.country_code).join(', ')}</>
)}
</div>
</section>
{/* Bills list & filters */}
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-[#1C2B4A]">Invoices</h2>
<div className="flex flex-wrap gap-2 text-sm">
<button onClick={() => exportBills('csv')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export CSV</button>
<button onClick={() => exportBills('pdf')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export PDF</button>
<button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-4 text-sm">
<input
placeholder="Search (invoice no., customer)"
value={billFilter.query}
onChange={e => setBillFilter(f => ({ ...f, query: e.target.value }))}
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
<select
value={billFilter.status}
onChange={e => setBillFilter(f => ({ ...f, status: e.target.value }))}
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
>
<option value="all">Status: All</option>
<option value="draft">Draft</option>
<option value="issued">Issued</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
<option value="canceled">Canceled</option>
</select>
<input
type="date"
value={billFilter.from}
onChange={e => setBillFilter(f => ({ ...f, from: e.target.value }))}
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
<input
type="date"
value={billFilter.to}
onChange={e => setBillFilter(f => ({ ...f, to: e.target.value }))}
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
</div>
<div className="overflow-x-auto">
{invError && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3">
{invError}
</div>
)}
<table className="min-w-full text-sm">
<thead>
<tr className="bg-blue-50 text-left text-blue-900">
<th className="px-3 py-2 font-semibold">Invoice</th>
<th className="px-3 py-2 font-semibold">Customer</th>
<th className="px-3 py-2 font-semibold">Issued</th>
<th className="px-3 py-2 font-semibold">Amount</th>
<th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{invLoading ? (
<>
<tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
<tr><td colSpan={6} className="px-3 py-3"><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
</>
) : filteredBills.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-gray-500">
Keine Rechnungen gefunden.
</td>
</tr>
) : (
filteredBills.map(inv => (
<tr key={inv.id} className="border-b last:border-0">
<td className="px-3 py-2">{inv.invoice_number ?? inv.id}</td>
<td className="px-3 py-2">{inv.buyer_name ?? '—'}</td>
<td className="px-3 py-2">{inv.issued_at ? new Date(inv.issued_at).toLocaleDateString() : '—'}</td>
<td className="px-3 py-2">
{Number(inv.total_gross ?? 0).toFixed(2)}{' '}
<span className="text-xs text-gray-500">{inv.currency ?? 'EUR'}</span>
</td>
<td className="px-3 py-2">
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
inv.status === 'paid'
? 'bg-green-100 text-green-700'
: inv.status === 'issued'
? 'bg-indigo-100 text-indigo-700'
: inv.status === 'draft'
? 'bg-gray-100 text-gray-700'
: inv.status === 'overdue'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}
>
{inv.status}
</span>
</td>
<td className="px-3 py-2 space-x-2">
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">View</button>
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</div>
</div>
</PageLayout>
)
}

View File

@ -0,0 +1,94 @@
import { VatRate } from '../../hooks/getTaxes'
const toCsvValue = (v: unknown) => {
if (v === null || v === undefined) return '""'
const s = String(v).replace(/"/g, '""')
return `"${s}"`
}
const fmt = (v?: number | null) =>
v === null || v === undefined || Number.isNaN(Number(v)) ? 'NULL' : Number(v).toFixed(3)
// Header format: Country,"Super-Reduced Rate (%)","Reduced Rate (%)","Parking Rate (%)","Standard Rate (%)"
export function exportVatCsv(rates: VatRate[]) {
const headers = [
'Country',
'Super-Reduced Rate (%)',
'Reduced Rate (%)',
'Parking Rate (%)',
'Standard Rate (%)',
]
const rows = rates.map(r => [
r.country_name,
r.super_reduced_rate ?? '',
r.reduced_rate_1 ?? '',
r.parking_rate ?? '',
r.standard_rate ?? '',
].map(toCsvValue).join(','))
const csv = [headers.map(toCsvValue).join(','), ...rows].join('\r\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vat-rates_${new Date().toISOString().slice(0,10)}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
export function exportVatPdf(rates: VatRate[]) {
const lines = [
'VAT Rates',
`Generated: ${new Date().toLocaleString()}`,
'',
'Country | Super-Reduced | Reduced | Parking | Standard',
'-----------------------------------------------------',
...rates.map(r =>
`${r.country_name} (${r.country_code}) SR:${fmt(r.super_reduced_rate)} R:${fmt(r.reduced_rate_1)} P:${fmt(r.parking_rate)} Std:${fmt(r.standard_rate)}`
),
]
const textContent = lines.join('\n')
const pdfText = textContent.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
const contentStream = `BT /F1 10 Tf 50 780 Td (${pdfText.replace(/\n/g, ') Tj\n0 -14 Td (')}) Tj ET`
const encoder = new TextEncoder()
const streamBytes = encoder.encode(contentStream)
const len = streamBytes.length
const header = [
'%PDF-1.4',
'1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj',
'2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj',
'3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj',
`4 0 obj << /Length ${len} >> stream`,
].join('\n')
const footer = [
'endstream endobj',
'5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj',
'xref',
'0 6',
'0000000000 65535 f ',
'0000000010 00000 n ',
'0000000060 00000 n ',
'0000000115 00000 n ',
'0000000256 00000 n ',
'0000000400 00000 n ',
'trailer << /Size 6 /Root 1 0 R >>',
'startxref',
'480',
'%%EOF',
].join('\n')
const blob = new Blob([header, '\n', streamBytes, '\n', footer], { type: 'application/pdf' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vat-rates_${new Date().toISOString().slice(0,10)}.pdf`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}

View File

@ -0,0 +1,45 @@
import { authFetch } from '../../../../utils/authFetch'
import useAuthStore from '../../../../store/authStore'
export type ImportSummary = {
created?: number
updated?: number
skipped?: number
message?: string
}
export async function importVatCsv(file: File): Promise<{ ok: boolean; summary?: ImportSummary; message?: string }> {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/tax/vat-rates/import`
const form = new FormData()
form.append('file', file)
const token = useAuthStore.getState().accessToken
const user = useAuthStore.getState().user
const userId =
(user as any)?.id ??
(user as any)?._id ??
(user as any)?.userId ??
(user as any)?.uid
if (userId != null) {
form.append('userId', String(userId))
}
try {
const res = await authFetch(url, {
method: 'POST',
body: form,
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const ct = res.headers.get('content-type') || ''
if (!res.ok || !ct.includes('application/json')) {
const txt = await res.text().catch(() => '')
throw new Error(`Import failed: ${res.status} ${txt.slice(0, 160)}`)
}
const json = await res.json()
return { ok: true, summary: json?.data || json, message: json?.message }
} catch (e: any) {
return { ok: false, message: e?.message || 'Import failed' }
}
}

View File

@ -0,0 +1,158 @@
'use client'
import React, { useState } from 'react'
import PageLayout from '../../../components/PageLayout'
import { useRouter } from 'next/navigation'
import { useVatRates } from '../hooks/getTaxes'
import { importVatCsv } from './hooks/TaxImporter'
import { exportVatCsv, exportVatPdf } from './hooks/TaxExporter'
export default function VatEditPage() {
const router = useRouter()
const { rates, loading, error, reload } = useVatRates()
const [filter, setFilter] = useState('')
const [importResult, setImportResult] = useState<string | null>(null)
const [importing, setImporting] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const onImport = async (file?: File | null) => {
if (!file) return
setImportResult(null)
setImporting(true)
const res = await importVatCsv(file)
if (res.ok) {
setImportResult(res.summary ? JSON.stringify(res.summary) : res.message || 'Import successful')
await reload()
} else {
setImportResult(res.message || 'Import failed')
}
setImporting(false)
}
const filtered = rates.filter(v =>
v.country_name.toLowerCase().includes(filter.toLowerCase()) ||
v.country_code.toLowerCase().includes(filter.toLowerCase())
)
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
const pageData = filtered.slice((page - 1) * pageSize, page * pageSize)
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
<div className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-blue-900">Edit VAT rates</h1>
<p className="text-sm text-blue-700">Import, export, and review (dummy data).</p>
</div>
<button
onClick={() => router.push('/admin/finance-management')}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-blue-900 hover:bg-gray-50"
>
Back
</button>
</div>
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 space-y-3">
<div className="flex flex-wrap gap-2 text-sm items-center">
<label className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50 cursor-pointer">
<input
type="file"
accept=".csv"
className="hidden"
onChange={e => onImport(e.target.files?.[0] || null)}
disabled={importing}
/>
{importing ? 'Importing...' : 'Import CSV'}
</label>
<button
onClick={() => exportVatCsv(rates)}
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
>
Export CSV
</button>
<button
onClick={() => exportVatPdf(rates)}
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
>
Export PDF
</button>
{importResult && <span className="text-xs text-blue-900 break-all">{importResult}</span>}
</div>
<div className="text-sm">
{error && <div className="mb-3 text-red-600">{error}</div>}
<input
value={filter}
onChange={e => { setFilter(e.target.value); setPage(1); }}
placeholder="Filter by country or code"
className="w-full rounded-lg border border-gray-200 px-3 py-2 mb-3 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
/>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-blue-50 text-left text-blue-900">
<th className="px-3 py-2 font-semibold">Country</th>
<th className="px-3 py-2 font-semibold">Code</th>
<th className="px-3 py-2 font-semibold">Standard</th>
<th className="px-3 py-2 font-semibold">Reduced</th>
<th className="px-3 py-2 font-semibold">Super reduced</th>
<th className="px-3 py-2 font-semibold">Parking</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading && (
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">Loading VAT rates</td></tr>
)}
{!loading && pageData.map(v => (
<tr key={v.country_code} className="border-b last:border-0">
<td className="px-3 py-2">{v.country_name}</td>
<td className="px-3 py-2">{v.country_code}</td>
<td className="px-3 py-2">{v.standard_rate ?? '—'}</td>
<td className="px-3 py-2">{v.reduced_rate_1 ?? '—'}</td>
<td className="px-3 py-2">{v.super_reduced_rate ?? '—'}</td>
<td className="px-3 py-2">{v.parking_rate ?? '—'}</td>
</tr>
))}
{!loading && !error && pageData.length === 0 && (
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">No entries found.</td></tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mt-4 text-sm text-gray-700">
<div className="flex items-center gap-2">
<span>Rows per page:</span>
<select
value={pageSize}
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
className="rounded border border-gray-300 px-2 py-1 text-sm"
>
{[10, 20, 50, 100].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 rounded border border-gray-300 bg-white disabled:opacity-50"
>
Prev
</button>
<span>Page {page} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1 rounded border border-gray-300 bg-white disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</PageLayout>
)
}

96
src/app/admin/layout.tsx Normal file
View File

@ -0,0 +1,96 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import useAuthStore from '../store/authStore'
function isUserAdmin(user: any): boolean {
if (!user) return false
if (user.user && typeof user.user === 'object') {
return isUserAdmin(user.user)
}
const role = user.role ?? user.userType ?? user.user_type
if (role === 'admin' || role === 'super_admin') return true
if (user.isAdmin === true || user.isSuperAdmin === true) return true
if (Array.isArray(user.roles) && (user.roles.includes('admin') || user.roles.includes('super_admin'))) return true
return false
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const user = useAuthStore(s => s.user)
const isAuthReady = useAuthStore(s => s.isAuthReady)
const refreshAuthToken = useAuthStore(s => s.refreshAuthToken)
const [mounted, setMounted] = useState(false)
const isAdmin = useMemo(() => isUserAdmin(user), [user])
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
let cancelled = false
const guard = async () => {
if (!mounted || !isAuthReady) return
console.log('🔐 AdminLayout guard:start', {
mounted,
isAuthReady,
hasUser: !!user,
userRole: (user && (user.role ?? user.userType ?? user.user_type)) || null
})
if (!user) {
try {
console.log('🔐 AdminLayout: no user, attempting refresh')
await refreshAuthToken?.()
} catch {}
}
const currentUser = useAuthStore.getState().user
const ok = isUserAdmin(currentUser)
console.log('🔐 AdminLayout guard:resolved', {
hasUser: !!currentUser,
userRole: currentUser && (currentUser.role ?? currentUser.userType ?? currentUser.user_type),
isAdmin: ok
})
if (!currentUser) {
router.replace('/login')
return
}
if (!ok) {
router.replace('/dashboard')
return
}
if (!cancelled) {
// allowed
}
}
guard()
return () => { cancelled = true }
}, [mounted, isAuthReady, user, refreshAuthToken, router])
if (!mounted || !isAuthReady) {
return (
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="text-center">
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-blue-900">Loading...</p>
</div>
</div>
)
}
if (!isAdmin) {
return null
}
return <>{children}</>
}

View File

@ -0,0 +1,527 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MagnifyingGlassIcon, XMarkIcon, BuildingOffice2Icon, UserIcon } from '@heroicons/react/24/outline'
import { getUserCandidates } from '../hooks/search-candidate'
import { addUserToMatrix } from '../hooks/addUsertoMatrix'
import type { MatrixUser, UserType } from '../hooks/getStats'
type Props = {
open: boolean
onClose: () => void
matrixName: string
rootUserId?: number
matrixId?: string | number
topNodeEmail?: string
existingUsers: MatrixUser[]
onAdd: (u: { id: number; name: string; email: string; type: UserType }) => void
policyMaxDepth?: number | null // NEW
}
export default function SearchModal({
open,
onClose,
matrixName,
rootUserId,
matrixId,
topNodeEmail,
existingUsers,
onAdd,
policyMaxDepth // NEW
}: Props) {
const [query, setQuery] = useState('')
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>('')
const [items, setItems] = useState<Array<{ userId: number; name: string; email: string; userType: UserType }>>([])
const [total, setTotal] = useState(0)
const [limit] = useState(20)
const [selected, setSelected] = useState<{ userId: number; name: string; email: string; userType: UserType } | null>(null) // NEW
const [advanced, setAdvanced] = useState(false) // NEW
const [parentId, setParentId] = useState<number | undefined>(undefined) // NEW
const [forceFallback, setForceFallback] = useState<boolean>(true) // NEW
const [adding, setAdding] = useState(false) // NEW
const [addError, setAddError] = useState<string>('') // NEW
const [addSuccess, setAddSuccess] = useState<string>('') // NEW
const [hasSearched, setHasSearched] = useState(false) // NEW
const [closing, setClosing] = useState(false) // NEW: animated closing state
const formRef = useRef<HTMLFormElement | null>(null)
const reqIdRef = useRef(0) // request guard to avoid applying stale results
const doSearch = useCallback(async () => {
setError('')
// Preserve list during refresh to avoid jumpiness
const shouldPreserve = hasSearched && items.length > 0
if (!shouldPreserve) {
setItems([])
setTotal(0)
}
const qTrim = query.trim()
if (qTrim.length < 3) {
console.warn('[SearchModal] Skip search: need >=3 chars')
setHasSearched(false)
return
}
setHasSearched(true)
const myReqId = ++reqIdRef.current
try {
setLoading(true)
const data = await getUserCandidates({
q: qTrim,
type: typeFilter === 'all' ? undefined : typeFilter,
rootUserId,
matrixId,
topNodeEmail,
limit,
offset: 0
})
// Ignore stale responses
if (myReqId !== reqIdRef.current) return
const existingIds = new Set(existingUsers.map(u => String(u.id)))
const filtered = (data.items || []).filter(i => !existingIds.has(String(i.userId)))
setItems(filtered)
setTotal(data.total || 0)
console.info('[SearchModal] Search success', {
q: data.q,
returned: filtered.length,
original: data.items.length,
total: data.total,
combo: (data as any)?._debug?.combo
})
if (filtered.length === 0 && data.total > 0) {
console.warn('[SearchModal] All backend results filtered out as duplicates')
}
} catch (e: any) {
if (myReqId !== reqIdRef.current) return
console.error('[SearchModal] Search error', e)
setError(e?.message || 'Search failed')
} finally {
if (myReqId === reqIdRef.current) setLoading(false)
}
}, [query, typeFilter, rootUserId, matrixId, topNodeEmail, limit, existingUsers, hasSearched, items])
useEffect(() => {
if (!open) {
setQuery('')
setTypeFilter('all')
setItems([])
setTotal(0)
setError('')
setLoading(false)
setHasSearched(false)
reqIdRef.current = 0 // reset guard
}
}, [open])
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [open])
// Auto-prune current results when parent existingUsers changes (keeps modal open)
useEffect(() => {
if (!open || items.length === 0) return
const existingIds = new Set(existingUsers.map(u => String(u.id)))
const cleaned = items.filter(i => !existingIds.has(String(i.userId)))
if (cleaned.length !== items.length) {
console.info('[SearchModal] Pruned results after parent update', { before: items.length, after: cleaned.length })
setItems(cleaned)
}
}, [existingUsers, items, open])
// Track a revision to force remount of parent dropdown when existingUsers changes
const [parentsRevision, setParentsRevision] = useState(0) // NEW
// Compute children counts per parent (uses parentUserId on existingUsers)
const parentUsage = useMemo(() => {
const map = new Map<number, number>()
existingUsers.forEach(u => {
if (u.parentUserId != null) {
map.set(u.parentUserId, (map.get(u.parentUserId) || 0) + 1)
}
})
return map
}, [existingUsers])
const potentialParents = useMemo(() => {
// All users up to depth 5 can be parents (capacity 5)
return existingUsers
.filter(u => u.level < 6)
.sort((a, b) => a.level - b.level || a.id - b.id)
}, [existingUsers])
const selectedParent = useMemo(() => {
if (!parentId) return null
return existingUsers.find(u => u.id === parentId) || null
}, [parentId, existingUsers])
// NEW: when existingUsers changes, refresh dropdown and clear invalid/now-full parent selection
useEffect(() => {
setParentsRevision(r => r + 1)
if (!selectedParent) return
const used = parentUsage.get(selectedParent.id) || 0
const isRoot = (selectedParent.level ?? 0) === 0
const isFull = !isRoot && used >= 5
const stillExists = !!existingUsers.find(u => u.id === selectedParent.id)
if (!stillExists || isFull) {
setParentId(undefined)
}
}, [existingUsers, parentUsage, selectedParent])
const remainingLevels = useMemo(() => {
if (!selectedParent) return null
if (!policyMaxDepth || policyMaxDepth <= 0) return Infinity
return Math.max(0, policyMaxDepth - Number(selectedParent.level ?? 0))
}, [selectedParent, policyMaxDepth])
const addDisabledReason = useMemo(() => {
if (!selectedParent) return ''
if (!policyMaxDepth || policyMaxDepth <= 0) return ''
if (Number(selectedParent.level ?? 0) >= policyMaxDepth) {
return `Parent at max depth (${policyMaxDepth}).`
}
return ''
}, [selectedParent, policyMaxDepth])
// Helper: is root selected
const isRootSelected = useMemo(() => {
if (!selectedParent) return false
return (selectedParent.level ?? 0) === 0
}, [selectedParent])
const closeWithAnimation = useCallback(() => {
// guard: if already closing, ignore
if (closing) return
setClosing(true)
// allow CSS transitions to play
setTimeout(() => {
setClosing(false)
onClose()
}, 200) // keep brief for responsiveness
}, [closing, onClose])
useEffect(() => {
// reset closing flag when reopened
if (open) setClosing(false)
}, [open])
const handleAdd = async () => {
if (!selected) return
setAddError('')
setAddSuccess('')
setAdding(true)
try {
const data = await addUserToMatrix({
childUserId: selected.userId,
parentUserId: advanced ? parentId : undefined,
forceParentFallback: forceFallback,
rootUserId,
matrixId,
topNodeEmail
})
console.info('[SearchModal] addUserToMatrix success', data)
setAddSuccess(`Added at position ${data.position} under parent ${data.parentUserId}`)
onAdd({ id: selected.userId, name: selected.name, email: selected.email, type: selected.userType })
// NEW: animated close instead of abrupt onClose
closeWithAnimation()
return
// setSelected(null)
// setParentId(undefined)
// Soft refresh: keep list visible; doSearch won't clear items now
// setTimeout(() => { void doSearch() }, 200)
} catch (e: any) {
console.error('[SearchModal] addUserToMatrix error', e)
setAddError(e?.message || 'Add failed')
} finally {
setAdding(false)
}
}
if (!open) return null
const modal = (
<div className="fixed inset-0 z-[10000]">
{/* Backdrop: animate opacity */}
<div
className={`absolute inset-0 backdrop-blur-sm transition-opacity duration-200 ${closing ? 'opacity-0' : 'opacity-100'} bg-black/60`}
onClick={closeWithAnimation} // CHANGED: use animated close
/>
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
<div
className={`w-full max-w-full sm:max-w-xl md:max-w-3xl lg:max-w-4xl rounded-2xl overflow-hidden bg-[#0F1F3A] shadow-2xl ring-1 ring-black/40 flex flex-col
transition-all duration-200 ${closing ? 'opacity-0 scale-95 translate-y-1' : 'opacity-100 scale-100 translate-y-0'}`}
style={{ maxHeight: '90vh' }}
>
{/* Header */}
<div className="relative px-6 py-5 border-b border-blue-900/40 bg-gradient-to-r from-[#142b52] via-[#13365f] to-[#154270]">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">
Add users to {matrixName}
</h3>
<button
onClick={closeWithAnimation} // CHANGED: animated close
className="p-1.5 rounded-md text-blue-200 transition
hover:bg-white/15 hover:text-white
focus:outline-none focus:ring-2 focus:ring-white/60 focus:ring-offset-2 focus:ring-offset-[#13365f]
active:scale-95"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<p className="mt-1 text-xs text-blue-200">
Search by name or email. Minimum 3 characters. Existing matrix members are hidden.
</p>
</div>
{/* Form */}
<form
ref={formRef}
onSubmit={e => {
e.preventDefault()
void doSearch()
}}
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-blue-900/40 bg-[#112645]"
>
{/* Query */}
<div className="md:col-span-2">
<div className="relative">
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search name or email…"
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 placeholder-blue-300 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
/>
</div>
</div>
{/* Type */}
<div>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value as any)}
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
>
<option value="all">All Types</option>
<option value="personal">Personal</option>
<option value="company">Company</option>
</select>
</div>
{/* Buttons */}
<div className="flex gap-2">
<button
type="submit"
disabled={loading || query.trim().length < 3}
className="flex-1 rounded-md bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
>
{loading ? 'Searching…' : 'Search'}
</button>
<button
type="button"
onClick={() => { setQuery(''); setItems([]); setTotal(0); setError(''); setHasSearched(false); }}
className="rounded-md border border-blue-800 bg-[#173456] px-3 py-2 text-sm text-blue-100 hover:bg-blue-800/40 transition"
>
Clear
</button>
</div>
{/* Total */}
<div className="text-sm text-blue-200 self-center">
Total: <span className="font-semibold text-white">{total}</span>
</div>
</form>
{/* Results + selection area (scrollable) */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Results section */}
<div className="relative">
{error && (
<div className="text-sm text-red-400 mb-4">{error}</div>
)}
{!error && query.trim().length < 3 && (
<div className="py-12 text-sm text-blue-300 text-center">
Enter at least 3 characters and click Search.
</div>
)}
{!error && query.trim().length >= 3 && !hasSearched && !loading && (
<div className="py-12 text-sm text-blue-300 text-center">
Ready to search. Click the Search button to fetch candidates.
</div>
)}
{/* Skeleton only for first-time load (when no items yet) */}
{!error && query.trim().length >= 3 && loading && items.length === 0 && (
<ul className="space-y-0 divide-y divide-blue-900/40 border border-blue-900/40 rounded-md bg-[#132c4e]">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i} className="animate-pulse px-4 py-3">
<div className="h-3.5 w-36 bg-blue-800/40 rounded" />
<div className="mt-2 h-3 w-56 bg-blue-800/30 rounded" />
</li>
))}
</ul>
)}
{!error && hasSearched && !loading && query.trim().length >= 3 && items.length === 0 && (
<div className="py-12 text-sm text-blue-300 text-center">
No users match your filters.
</div>
)}
{!error && hasSearched && items.length > 0 && (
<ul className="divide-y divide-blue-900/40 border border-blue-900/40 rounded-lg bg-[#132c4e]">
{items.map(u => (
<li
key={u.userId}
className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-blue-800/40 transition"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
{u.userType === 'company'
? <BuildingOffice2Icon className="h-4 w-4 text-indigo-400" />
: <UserIcon className="h-4 w-4 text-blue-300" />}
<span className="text-sm font-medium text-blue-100 truncate max-w-[160px]">{u.name}</span>
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
u.userType === 'company'
? 'bg-indigo-700/40 text-indigo-200'
: 'bg-blue-700/40 text-blue-200'
}`}>
{u.userType === 'company' ? 'Company' : 'Personal'}
</span>
</div>
<div className="mt-0.5 text-[11px] text-blue-300 break-all">{u.email}</div>
</div>
<button
onClick={() => {
console.log('[SearchModal] Select candidate', { id: u.userId })
setSelected(u)
setAddError('')
setAddSuccess('')
}}
className="shrink-0 inline-flex items-center rounded-md bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
>
{selected?.userId === u.userId ? 'Selected' : 'Select'}
</button>
</li>
))}
</ul>
)}
{/* Soft-loading overlay over existing list to avoid jumpiness */}
{loading && items.length > 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[#0F1F3A]/30">
<span className="h-5 w-5 rounded-full border-2 border-blue-400 border-b-transparent animate-spin" />
</div>
)}
</div>
{/* Selected candidate details (conditional) */}
{selected && (
<div className="border border-blue-900/40 rounded-lg p-4 bg-[#132c4e] space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm text-blue-100 font-medium">
Candidate: {selected.name} <span className="text-blue-300">({selected.email})</span>
</div>
<button
onClick={() => { setSelected(null); setParentId(undefined); }}
className="text-xs text-blue-300 hover:text-white transition"
>
Clear selection
</button>
</div>
<label className="flex items-center gap-2 text-xs text-blue-200">
<input
type="checkbox"
checked={advanced}
onChange={e => setAdvanced(e.target.checked)}
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
/>
Advanced: choose parent manually
</label>
{advanced && (
<div className="space-y-2">
<select
key={parentsRevision}
value={parentId ?? ''}
onChange={e => setParentId(e.target.value ? Number(e.target.value) : undefined)}
className="w-full rounded-md bg-[#173456] border border-blue-800 text-xs text-blue-100 px-2 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
title={addDisabledReason || undefined}
>
<option value="">(Auto referral / root)</option>
{potentialParents.map(p => {
const used = parentUsage.get(p.id) || 0
const isRoot = (p.level ?? 0) === 0
const full = (!isRoot && used >= 5) || (!!policyMaxDepth && policyMaxDepth > 0 && p.level >= policyMaxDepth) // CHANGED
const rem = !policyMaxDepth || policyMaxDepth <= 0 ? '∞' : Math.max(0, policyMaxDepth - p.level)
return (
<option key={p.id} value={p.id} disabled={full} title={full ? 'Parent full or at policy depth' : undefined}>
{p.name} L{p.level} Slots {isRoot ? `${used} (root ∞)` : `${used}/5`} Rem levels: {rem}
</option>
)
})}
</select>
{/* CHANGED: clarify root unlimited and rogue behavior */}
<p className="text-[11px] text-blue-300">
{isRootSelected
? 'Root has unlimited capacity; placing under root does not mark the user as rogue.'
: (!policyMaxDepth || policyMaxDepth <= 0)
? 'Unlimited policy: no remaining-level cap for subtree.'
: `Remaining levels under chosen parent = Max(${policyMaxDepth}) - parent level.`}
</p>
</div>
)}
<label className="flex items-center gap-2 text-xs text-blue-200">
<input
type="checkbox"
checked={forceFallback}
onChange={e => setForceFallback(e.target.checked)}
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
/>
Fallback to root if referral parent not in matrix
</label>
<p className="text-[11px] text-blue-300">
If the referrer is outside the matrix, the user is placed under root; enabling fallback may mark the user as rogue.
</p>
{addError && <div className="text-xs text-red-400">{addError}</div>}
{addSuccess && <div className="text-xs text-green-400">{addSuccess}</div>}
<div className="flex justify-end">
<button
onClick={handleAdd}
disabled={adding || (!!addDisabledReason && !!advanced)} // NEW
title={addDisabledReason || undefined} // NEW
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 text-xs font-medium shadow-sm transition"
>
{adding ? 'Adding…' : 'Add to Matrix'}
</button>
</div>
</div>
)}
</div>
{/* Footer (hidden when a candidate is selected) */}
{!selected && (
<div className="px-6 py-3 border-t border-blue-900/40 flex items-center justify-end bg-[#112645]">
<button
onClick={closeWithAnimation} // CHANGED: animated close
className="text-sm rounded-md px-4 py-2 font-medium
bg-white/10 text-blue-200 backdrop-blur
hover:bg-indigo-500/20 hover:text-white hover:shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-300 focus:ring-offset-2 focus:ring-offset-[#112645]
active:scale-95 transition"
>
Done
</button>
</div>
)}
</div>
</div>
</div>
)
return createPortal(modal, document.body)
}

View File

@ -0,0 +1,101 @@
import { authFetch } from '../../../../utils/authFetch'
export type AddUserToMatrixParams = {
childUserId: number
parentUserId?: number
forceParentFallback?: boolean
rootUserId?: number
matrixId?: string | number
topNodeEmail?: string
}
export type AddUserToMatrixResponse = {
success: boolean
data?: {
rootUserId: number
parentUserId: number
childUserId: number
position: number
remainingFreeSlots: number
usersPreview?: Array<{
userId: number
level?: number
name?: string
email?: string
userType?: string
parentUserId?: number | null
}>
}
message?: string
}
export async function addUserToMatrix(params: AddUserToMatrixParams) {
const {
childUserId,
parentUserId,
forceParentFallback = false,
rootUserId,
matrixId,
topNodeEmail
} = params
if (!childUserId) throw new Error('childUserId required')
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
if (!base) console.warn('[addUserToMatrix] NEXT_PUBLIC_API_BASE_URL missing')
// Choose exactly one identifier
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0
const hasMatrix = !!matrixId
const hasEmail = !!topNodeEmail
if (!hasRoot && !hasMatrix && !hasEmail) {
throw new Error('One of rootUserId, matrixId or topNodeEmail is required')
}
const body: any = {
childUserId,
forceParentFallback: !!forceParentFallback
}
if (parentUserId) body.parentUserId = parentUserId
if (hasRoot) body.rootUserId = rootUserId
else if (hasMatrix) body.matrixId = matrixId
else body.topNodeEmail = topNodeEmail
const url = `${base}/api/admin/matrix/add-user`
console.info('[addUserToMatrix] POST', { url, body })
const res = await authFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
credentials: 'include',
body: JSON.stringify(body)
})
const ct = res.headers.get('content-type') || ''
const raw = await res.text()
let json: AddUserToMatrixResponse | null = null
try {
json = ct.includes('application/json') ? JSON.parse(raw) : null
} catch {
json = null
}
console.debug('[addUserToMatrix] Response', {
status: res.status,
ok: res.ok,
hasJson: !!json,
bodyPreview: raw.slice(0, 300)
})
if (!res.ok) {
const msg = json?.message || `Request failed: ${res.status}`
throw new Error(msg)
}
if (!json?.success) {
throw new Error(json?.message || 'Backend returned non-success')
}
return json.data!
}

View File

@ -0,0 +1,211 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { authFetch } from '../../../../utils/authFetch'
export type UserType = 'personal' | 'company'
export type MatrixUser = {
id: number
name: string
email: string
type: UserType
level: number
parentUserId?: number | null // NEW
position?: number | null // NEW
}
type ApiUser = {
userId: number
level?: number | null
depth?: number | null
name?: string | null
displayName?: string | null
email: string
userType: string
role: string
createdAt: string
parentUserId: number | null
position: number | null
}
type ApiResponse = {
success: boolean
data?: {
rootUserId: number
maxDepth: number
limit: number
offset: number
includeRoot: boolean
users: ApiUser[]
}
error?: any
}
export type UseMatrixUsersParams = {
depth?: number
limit?: number
offset?: number
includeRoot?: boolean
matrixId?: string | number
topNodeEmail?: string
}
export function useMatrixUsers(
rootUserId: number | undefined,
params: UseMatrixUsersParams = {}
) {
const { depth = 5, limit = 100, offset = 0, includeRoot = true, matrixId, topNodeEmail } = params
const [users, setUsers] = useState<MatrixUser[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<unknown>(null)
const [tick, setTick] = useState(0)
const abortRef = useRef<AbortController | null>(null)
const [serverMaxDepth, setServerMaxDepth] = useState<number | null>(null) // NEW
// Include new identifiers in diagnostics
const builtParams = useMemo(() => {
const p = { rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot }
console.debug('[useMatrixUsers] Params built', p)
return p
}, [rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot])
const refetch = useCallback(() => {
console.info('[useMatrixUsers] refetch() called')
setTick(t => t + 1)
}, [])
useEffect(() => {
console.info('[useMatrixUsers] Hook mounted')
return () => {
console.info('[useMatrixUsers] Hook unmounted, aborting any inflight request')
abortRef.current?.abort()
}
}, [])
useEffect(() => {
// Require at least one acceptable identifier
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0
const hasMatrix = !!matrixId
const hasEmail = !!topNodeEmail
if (!hasRoot && !hasMatrix && !hasEmail) {
console.error('[useMatrixUsers] Missing identifier. Provide one of: rootUserId, matrixId, topNodeEmail.', { rootUserId, matrixId, topNodeEmail })
setUsers([])
setError(new Error('One of rootUserId, matrixId or topNodeEmail is required'))
setLoading(false)
return
}
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
if (!base) {
console.warn('[useMatrixUsers] NEXT_PUBLIC_API_BASE_URL is not set. Falling back to same-origin (may fail in dev).')
}
// Choose exactly ONE identifier to avoid backend confusion
const qs = new URLSearchParams()
let chosenKey: 'rootUserId' | 'matrixId' | 'topNodeEmail'
let chosenValue: string | number
if (hasRoot) {
qs.set('rootUserId', String(rootUserId))
chosenKey = 'rootUserId'
chosenValue = rootUserId!
} else if (hasMatrix) {
qs.set('matrixId', String(matrixId))
chosenKey = 'matrixId'
chosenValue = matrixId as any
} else {
qs.set('topNodeEmail', String(topNodeEmail))
chosenKey = 'topNodeEmail'
chosenValue = topNodeEmail as any
}
qs.set('depth', String(depth))
qs.set('limit', String(limit))
qs.set('offset', String(offset))
qs.set('includeRoot', String(includeRoot))
const url = `${base}/api/admin/matrix/users?${qs.toString()}`
console.info('[useMatrixUsers] Fetch start (via authFetch)', {
url,
method: 'GET',
identifiers: { rootUserId, matrixId, topNodeEmail, chosen: { key: chosenKey, value: chosenValue } },
params: { depth, limit, offset, includeRoot }
})
console.log('[useMatrixUsers] REQUEST GET', url)
const t0 = performance.now()
setLoading(true)
setError(null)
authFetch(url, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
signal: controller.signal as any
})
.then(async r => {
const t1 = performance.now()
const ct = r.headers.get('content-type') || ''
console.debug('[useMatrixUsers] Response received', { status: r.status, durationMs: Math.round(t1 - t0), contentType: ct })
if (!r.ok || !ct.includes('application/json')) {
const text = await r.text().catch(() => '')
console.error('[useMatrixUsers] Non-OK or non-JSON response', { status: r.status, bodyPreview: text.slice(0, 500) })
throw new Error(`Request failed: ${r.status}`)
}
const json: ApiResponse = await r.json()
if (!json?.success || !json?.data) {
console.warn('[useMatrixUsers] Non-success response', json)
throw new Error('Backend returned non-success for /admin/matrix/users')
}
const { data } = json
console.debug('[useMatrixUsers] Meta', {
rootUserId: data.rootUserId,
maxDepth: data.maxDepth,
limit: data.limit,
offset: data.offset,
includeRoot: data.includeRoot,
usersCount: data.users?.length ?? 0
})
setServerMaxDepth(typeof data.maxDepth === 'number' ? data.maxDepth : null) // NEW
const mapped: MatrixUser[] = (data.users || []).map(u => {
const rawLevel = (typeof u.level === 'number' ? u.level : (typeof u.depth === 'number' ? u.depth : undefined))
const level = (typeof rawLevel === 'number' && rawLevel >= 0) ? rawLevel : 0
if (rawLevel === undefined || rawLevel === null) {
console.warn('[useMatrixUsers] Coerced missing level/depth to 0', { userId: u.userId })
}
const name = (u.name?.trim()) || (u.displayName?.trim()) || u.email
return {
id: u.userId,
name,
email: u.email,
type: u.userType === 'company' ? 'company' : 'personal',
level,
parentUserId: u.parentUserId ?? null,
position: u.position ?? null // NEW
}
})
mapped.sort((a, b) => a.level - b.level || a.id - b.id)
setUsers(mapped)
console.info('[useMatrixUsers] Users mapped', { count: mapped.length, sample: mapped.slice(0, 3) })
})
.catch(err => {
console.error('[useMatrixUsers] Fetch error', err)
setError(err)
setUsers([])
})
.finally(() => {
setLoading(false)
})
return () => controller.abort()
}, [rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot, tick])
const meta = { rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot, serverMaxDepth } // NEW
return { users, loading, error, meta, refetch, serverMaxDepth } // NEW
}

View File

@ -0,0 +1,224 @@
import { authFetch } from '../../../../utils/authFetch';
export type CandidateItem = {
userId: number;
email: string;
userType: 'personal' | 'company';
name: string;
};
export type UserCandidatesResponse = {
success: boolean;
data?: {
q: string | null;
type: 'all' | 'personal' | 'company';
rootUserId: number | null;
limit: number;
offset: number;
total: number;
items: CandidateItem[];
};
message?: string;
};
export type UserCandidatesData = {
q: string | null;
type: 'all' | 'personal' | 'company';
rootUserId: number | null;
limit: number;
offset: number;
total: number;
items: CandidateItem[];
_debug?: {
endpoint: string;
query: Record<string, string>;
combo: string;
};
};
export type GetUserCandidatesParams = {
q: string;
type?: 'all' | 'personal' | 'company';
rootUserId?: number;
matrixId?: string | number;
topNodeEmail?: string;
limit?: number;
offset?: number;
};
export async function getUserCandidates(params: GetUserCandidatesParams): Promise<UserCandidatesData> {
const {
q,
type = 'all',
rootUserId,
matrixId,
topNodeEmail,
limit = 20,
offset = 0
} = params;
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
if (!base) {
console.warn('[getUserCandidates] NEXT_PUBLIC_API_BASE_URL not set. Falling back to same-origin.');
}
const qTrimmed = q.trim();
console.info('[getUserCandidates] Building candidate request', {
base,
q: qTrimmed,
typeSent: type !== 'all' ? type : undefined,
identifiers: { rootUserId, matrixId, topNodeEmail },
pagination: { limit, offset }
});
// Build identifier combinations: all -> root-only -> matrix-only -> email-only
const combos: Array<{ label: string; apply: (qs: URLSearchParams) => void }> = [];
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0;
const hasMatrix = !!matrixId;
const hasEmail = !!topNodeEmail;
if (hasRoot || hasMatrix || hasEmail) {
combos.push({
label: 'all-identifiers',
apply: (qs) => {
if (hasRoot) qs.set('rootUserId', String(rootUserId));
if (hasMatrix) qs.set('matrixId', String(matrixId));
if (hasEmail) qs.set('topNodeEmail', String(topNodeEmail));
}
});
}
if (hasRoot) combos.push({ label: 'root-only', apply: (qs) => { qs.set('rootUserId', String(rootUserId)); } });
if (hasMatrix) combos.push({ label: 'matrix-only', apply: (qs) => { qs.set('matrixId', String(matrixId)); } });
if (hasEmail) combos.push({ label: 'email-only', apply: (qs) => { qs.set('topNodeEmail', String(topNodeEmail)); } });
if (combos.length === 0) combos.push({ label: 'no-identifiers', apply: () => {} });
const endpointVariants = [
(qs: string) => `${base}/api/admin/matrix/users/candidates?${qs}`,
(qs: string) => `${base}/api/admin/matrix/user-candidates?${qs}`
];
console.debug('[getUserCandidates] Endpoint variants', endpointVariants.map(f => f('...')));
let lastError: any = null;
let lastZeroData: UserCandidatesData | null = null;
// Try each identifier combo against both endpoint variants
for (const combo of combos) {
const qs = new URLSearchParams();
qs.set('q', qTrimmed);
qs.set('limit', String(limit));
qs.set('offset', String(offset));
if (type !== 'all') qs.set('type', type);
combo.apply(qs);
const qsObj = Object.fromEntries(qs.entries());
console.debug('[getUserCandidates] Final query params', { combo: combo.label, qs: qsObj });
for (let i = 0; i < endpointVariants.length; i++) {
const url = endpointVariants[i](qs.toString());
const fetchOpts = { method: 'GET', headers: { Accept: 'application/json' } as const };
console.info('[getUserCandidates] REQUEST GET', {
url,
attempt: i + 1,
combo: combo.label,
identifiers: { rootUserId, matrixId, topNodeEmail },
params: { q: qTrimmed, type: type !== 'all' ? type : undefined, limit, offset },
fetchOpts
});
const t0 = performance.now();
const res = await authFetch(url, fetchOpts);
const t1 = performance.now();
const ct = res.headers.get('content-type') || '';
console.debug('[getUserCandidates] Response meta', {
status: res.status,
ok: res.ok,
durationMs: Math.round(t1 - t0),
contentType: ct
});
// Preview raw body (first 300 chars)
let rawPreview = '';
try {
rawPreview = await res.clone().text();
} catch {}
if (rawPreview) {
console.trace('[getUserCandidates] Raw body preview (trimmed)', rawPreview.slice(0, 300));
}
if (res.status === 404 && i < endpointVariants.length - 1) {
try {
const preview = ct.includes('application/json') ? await res.json() : await res.text();
console.warn('[getUserCandidates] 404 on endpoint variant, trying fallback', {
tried: url,
combo: combo.label,
preview: typeof preview === 'string' ? preview.slice(0, 200) : preview
});
} catch {}
continue;
}
if (!ct.includes('application/json')) {
const text = await res.text().catch(() => '');
console.error('[getUserCandidates] Non-JSON response', { status: res.status, bodyPreview: text.slice(0, 500) });
lastError = new Error(`Request failed: ${res.status}`);
break;
}
const json: UserCandidatesResponse = await res.json().catch(() => ({ success: false, message: 'Invalid JSON' } as any));
console.debug('[getUserCandidates] Parsed JSON', {
success: json?.success,
message: json?.message,
dataMeta: json?.data && {
q: json.data.q,
type: json.data.type,
total: json.data.total,
itemsCount: json.data.items?.length
}
});
if (!res.ok || !json?.success) {
console.error('[getUserCandidates] Backend reported failure', {
status: res.status,
successFlag: json?.success,
message: json?.message
});
lastError = new Error(json?.message || `Request failed: ${res.status}`);
break;
}
const dataWithDebug: UserCandidatesData = {
...json.data!,
_debug: { endpoint: url, query: qsObj, combo: combo.label }
};
if ((dataWithDebug.total || 0) > 0) {
console.info('[getUserCandidates] Success (non-empty)', {
total: dataWithDebug.total,
itemsCount: dataWithDebug.items.length,
combo: combo.label,
endpoint: url
});
return dataWithDebug;
}
// Keep last zero result but continue trying other combos/endpoints
lastZeroData = dataWithDebug;
console.info('[getUserCandidates] Success (empty)', { combo: combo.label, endpoint: url });
}
if (lastError) break; // stop on hard error
}
if (lastError) {
console.error('[getUserCandidates] Exhausted endpoint variants with error', { lastError: lastError?.message });
throw lastError;
}
// Return the last empty response (with _debug info) if everything was empty
if (lastZeroData) {
console.warn('[getUserCandidates] All combos returned empty results', { lastCombo: lastZeroData._debug });
return lastZeroData;
}
throw new Error('Request failed');
}

View File

@ -0,0 +1,560 @@
'use client'
import React, { useEffect, useMemo, useState, Suspense } from 'react' // CHANGED: add Suspense
import { useSearchParams, useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout'
import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline'
import { useMatrixUsers, MatrixUser } from './hooks/getStats'
import useAuthStore from '../../../store/authStore'
import { getMatrixStats } from '../hooks/getMatrixStats'
import SearchModal from './components/searchModal'
const DEFAULT_FETCH_DEPTH = 50 // provisional large depth to approximate unlimited
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
function MatrixDetailPageInner() {
const sp = useSearchParams()
const router = useRouter()
const matrixId = sp.get('id') || 'm-1'
const matrixName = sp.get('name') || 'Unnamed Matrix'
const topNodeEmail = sp.get('top') || 'top@example.com'
const rootUserIdParam = sp.get('rootUserId')
const rootUserId = rootUserIdParam ? Number(rootUserIdParam) : undefined
console.info('[MatrixDetailPage] Params', { matrixId, matrixName, topNodeEmail, rootUserId })
// Resolve rootUserId when missing by looking it up via stats
const accessToken = useAuthStore(s => s.accessToken)
const [resolvedRootUserId, setResolvedRootUserId] = useState<number | undefined>(rootUserId)
// NEW: track policy (DB) max depth (null => unlimited)
const [policyMaxDepth, setPolicyMaxDepth] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
async function resolveRoot() {
if (rootUserId && rootUserId > 0) {
console.info('[MatrixDetailPage] Using rootUserId from URL', { rootUserId })
setResolvedRootUserId(rootUserId)
return
}
if (!accessToken) {
console.warn('[MatrixDetailPage] No accessToken; cannot resolve rootUserId from stats')
setResolvedRootUserId(undefined)
return
}
if (!matrixId) {
console.warn('[MatrixDetailPage] No matrixId; cannot resolve rootUserId from stats')
setResolvedRootUserId(undefined)
return
}
console.info('[MatrixDetailPage] Resolving rootUserId via stats for matrixId', { matrixId })
const res = await getMatrixStats({ token: accessToken, baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL })
console.debug('[MatrixDetailPage] getMatrixStats result', res)
if (!res.ok) {
console.error('[MatrixDetailPage] getMatrixStats failed', { status: res.status, message: res.message })
setResolvedRootUserId(undefined)
return
}
const body = res.body || {}
const matrices = (body?.data?.matrices ?? body?.matrices ?? []) as any[]
console.debug('[MatrixDetailPage] Stats matrices overview', {
count: matrices.length,
ids: matrices.map((m: any) => m?.id ?? m?.matrixId),
matrixIds: matrices.map((m: any) => m?.matrixId ?? m?.id),
rootUserIds: matrices.map((m: any) => m?.rootUserId ?? m?.root_user_id),
emails: matrices.map((m: any) => m?.topNodeEmail ?? m?.email)
})
const found = matrices.find((m: any) =>
String(m?.id) === String(matrixId) || String(m?.matrixId) === String(matrixId)
)
// NEW: extract policy maxDepth (may be null)
const pmRaw = found?.maxDepth ?? found?.max_depth ?? null
if (!cancelled) {
setPolicyMaxDepth(pmRaw == null ? null : Number(pmRaw))
}
const ru = Number(found?.rootUserId ?? found?.root_user_id)
if (ru > 0 && !cancelled) {
console.info('[MatrixDetailPage] Resolved rootUserId from stats', { matrixId, rootUserId: ru })
setResolvedRootUserId(ru)
} else {
console.warn('[MatrixDetailPage] Could not resolve rootUserId from stats', { matrixId, found })
setResolvedRootUserId(undefined)
}
}
resolveRoot()
return () => { cancelled = true }
}, [matrixId, rootUserId, accessToken])
// Backend users (changed depth from 5 to DEFAULT_FETCH_DEPTH)
const { users: fetchedUsers, loading: usersLoading, error: usersError, meta, refetch, serverMaxDepth } = useMatrixUsers(resolvedRootUserId, {
depth: DEFAULT_FETCH_DEPTH,
includeRoot: true,
limit: 2000,
offset: 0,
matrixId,
topNodeEmail
})
// Prepare for backend fetches
const [users, setUsers] = useState<MatrixUser[]>([])
useEffect(() => {
console.info('[MatrixDetailPage] useMatrixUsers state', {
loading: usersLoading,
error: !!usersError,
fetchedCount: fetchedUsers.length,
meta
})
setUsers(fetchedUsers)
}, [fetchedUsers, usersLoading, usersError, meta])
// Modal state
const [open, setOpen] = useState(false)
// ADD: global search state (was removed)
const [globalSearch, setGlobalSearch] = useState('')
// Refresh overlay state
const [refreshing, setRefreshing] = useState(false)
// Collapsed state for each level
const [collapsedLevels, setCollapsedLevels] = useState<{ [level: number]: boolean }>({
0: true, 1: true, 2: true, 3: true, 4: true, 5: true
})
// Per-level search
const [levelSearch, setLevelSearch] = useState<{ [level: number]: string }>({
0: '', 1: '', 2: '', 3: '', 4: '', 5: ''
})
// Counts per level and next available level logic
const byLevel = useMemo(() => {
const map = new Map<number, MatrixUser[]>()
users.forEach(u => {
if (!u.name) {
console.warn('[MatrixDetailPage] User missing name, fallback email used', { id: u.id, email: u.email })
}
const lvl = (typeof u.level === 'number' && u.level >= 0) ? u.level : 0
const arr = map.get(lvl) || []
arr.push({ ...u, level: lvl })
map.set(lvl, arr)
})
console.debug('[MatrixDetailPage] byLevel computed', { levels: Array.from(map.keys()), total: users.length })
return map
}, [users])
const nextAvailableLevel = () => {
let lvl = 1
while (true) {
const current = byLevel.get(lvl)?.length || 0
if (current < LEVEL_CAP(lvl)) return lvl
lvl += 1
if (lvl > 8) return lvl // safety ceiling in demo
}
}
const addToMatrix = (u: Omit<MatrixUser, 'level'>) => {
const level = nextAvailableLevel()
console.info('[MatrixDetailPage] addToMatrix', { userId: u.id, nextLevel: level })
setUsers(prev => [...prev, { ...u, level }])
}
// Simple chip for user
const UserChip = ({ u }: { u: MatrixUser }) => (
<div className="inline-flex items-center gap-2 rounded-full bg-gray-50 border border-gray-200 px-3 py-1 text-xs text-gray-800">
{u.type === 'company' ? <BuildingOffice2Icon className="h-4 w-4 text-indigo-600" /> : <UserIcon className="h-4 w-4 text-blue-600" />}
<span className="font-medium truncate max-w-[140px]">{u.name}</span>
</div>
)
// Global search (already present) + node collapse state
const [collapsedNodes, setCollapsedNodes] = useState<Record<number, boolean>>({})
const toggleNode = (id: number) => setCollapsedNodes(p => ({ ...p, [id]: !p[id] }))
// Build children adjacency map
const childrenMap = useMemo(() => {
const m = new Map<number, MatrixUser[]>()
users.forEach(u => {
if (u.parentUserId != null) {
const arr = m.get(u.parentUserId) || []
arr.push(u)
m.set(u.parentUserId, arr)
}
})
// sort children by optional position then id
m.forEach(arr => arr.sort((a,b) => {
const pa = (a as any).position ?? 0
const pb = (b as any).position ?? 0
return pa - pb || a.id - b.id
}))
return m
}, [users])
// Root node
const rootNode = useMemo(
() => users.find(u => u.level === 0 || u.id === resolvedRootUserId),
[users, resolvedRootUserId]
)
const rootChildren = useMemo(() => rootNode ? (childrenMap.get(rootNode.id) || []) : [], [rootNode, childrenMap])
const rootChildrenCount = useMemo(() => rootChildren.length, [rootChildren])
const displayedRootSlots = useMemo(() =>
rootChildren.filter(c => {
const pos = Number((c as any).position ?? -1)
return pos >= 1 && pos <= 5
}).length
, [rootChildren])
// Rogue count if flags exist
const rogueCount = useMemo(
() => users.filter(u => (u as any).rogueUser || (u as any).rogue_user || (u as any).rogue).length,
[users]
)
// Filter match helper
const searchLower = globalSearch.trim().toLowerCase()
const matchesSearch = (u: MatrixUser) =>
!searchLower ||
u.name.toLowerCase().includes(searchLower) ||
u.email.toLowerCase().includes(searchLower)
// Determine which nodes should be visible when searching:
// Show all ancestors of matching nodes so the path is visible.
const visibleIds = useMemo(() => {
if (!searchLower) return new Set(users.map(u => u.id))
const matchIds = new Set<number>()
const parentMap = new Map<number, number | undefined>()
users.forEach(u => parentMap.set(u.id, u.parentUserId == null ? undefined : u.parentUserId))
users.forEach(u => {
if (matchesSearch(u)) {
let cur: number | undefined = u.id
while (cur != null) {
if (!matchIds.has(cur)) matchIds.add(cur)
cur = parentMap.get(cur)
}
}
})
return matchIds
}, [users, searchLower])
// Auto-expand ancestors for search results
useEffect(() => {
if (!searchLower) return
// Expand all visible nodes containing matches
setCollapsedNodes(prev => {
const next = { ...prev }
visibleIds.forEach(id => { next[id] = false })
return next
})
}, [searchLower, visibleIds])
// Tree renderer (inside component to access scope)
const renderNode = (node: MatrixUser, depth: number) => {
const children = childrenMap.get(node.id) || []
const hasChildren = children.length > 0
const collapsed = collapsedNodes[node.id]
const highlight = matchesSearch(node) && searchLower.length > 0
if (!visibleIds.has(node.id)) return null
const isRoot = node.level === 0
const pos = node.position ?? null
return (
<li key={node.id} className="relative">
<div
className={`flex items-center gap-2 rounded-md border px-2 py-1 text-xs ${
highlight ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}`}
>
{hasChildren && (
<button
onClick={() => toggleNode(node.id)}
className="h-4 w-4 rounded border border-gray-300 text-[10px] flex items-center justify-center bg-gray-50 hover:bg-gray-100"
aria-label={collapsed ? 'Expand' : 'Collapse'}
>
{collapsed ? '+' : ''}
</button>
)}
{!hasChildren && <span className="h-4 w-4" />}
{node.type === 'company'
? <BuildingOffice2Icon className="h-4 w-4 text-indigo-600" />
: <UserIcon className="h-4 w-4 text-blue-600" />}
<span className="font-medium truncate max-w-[160px]">{node.name}</span>
<span className="text-[10px] px-1 rounded bg-gray-100 text-gray-600">L{node.level}</span>
{(node as any).rogueUser || (node as any).rogue_user || (node as any).rogue ? (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800">Rogue</span>
) : null}
{pos != null && (
<span className="text-[10px] px-1 rounded bg-gray-100 text-gray-600">
pos {pos}
</span>
)}
{isRoot && (
<span className="ml-auto text-[10px] text-gray-500">
Unlimited; positions numbered sequentially
</span>
)}
{!isRoot && hasChildren && (
<span className="ml-auto text-[10px] text-gray-500">
{children.length}/5 (slots 15)
</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 15)
</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 15): {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">5ary Tree</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Policy Max Depth</div>
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Fill %</div>
<div className="text-xl font-semibold text-blue-900">{fillMetrics.label}</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Highest full level</div>
<div className="text-xl font-semibold text-blue-900">{fillMetrics.highestFull}</div>
</div>
</div>
{/* Unlimited hierarchical tree (replaces dynamic levels + grouped level list) */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
<div className="px-8 py-6 border-b border-gray-100">
<h2 className="text-xl font-semibold text-blue-900">Matrix Tree (Unlimited Depth)</h2>
<p className="text-xs text-blue-700">Each node can hold up to 5 direct children. Depth unbounded.</p>
</div>
<div className="px-8 py-6">
{!rootNode && (
<div className="text-xs text-gray-500 italic">Root not yet loaded.</div>
)}
{rootNode && (
<ul className="flex flex-col gap-1">
{renderNode(rootNode, 0)}
</ul>
)}
</div>
</div>
{/* Vacancies placeholder */}
<div className="rounded-2xl bg-white border border-dashed border-blue-200 shadow-sm p-6 mb-8">
<h3 className="text-lg font-semibold text-blue-900 mb-2">Vacancies</h3>
<p className="text-sm text-blue-700">
Pending backend wiring to MatrixController.listVacancies. This section will surface empty slots and allow reassignment.
</p>
</div>
{/* Add Users Modal */}
<SearchModal
open={open}
onClose={handleModalClose}
matrixName={matrixName}
rootUserId={resolvedRootUserId}
matrixId={matrixId}
topNodeEmail={topNodeEmail}
existingUsers={users}
policyMaxDepth={policyMaxDepth}
onAdd={(u) => { addToMatrix(u) }}
/>
</div>
</div>
</PageLayout>
)
}
// CHANGED: default export wraps inner component in Suspense
export default function MatrixDetailPage() {
return (
<Suspense
fallback={
<PageLayout>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#8D6B1D] mx-auto mb-3" />
<p className="text-[#4A4A4A]">Loading...</p>
</div>
</div>
</PageLayout>
}
>
<MatrixDetailPageInner />
</Suspense>
)
}

View File

@ -0,0 +1,45 @@
import { authFetch } from '../../../utils/authFetch'
export type MatrixStateData = {
matrixInstanceId: string | number
wasActive: boolean
isActive: boolean
status: 'deactivated' | 'already_inactive' | 'activated' | 'already_active'
}
type MatrixStateResponse = {
success: boolean
data?: MatrixStateData
message?: string
}
const baseUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
async function patch(endpoint: string) {
const url = `${baseUrl}${endpoint}`
const res = await authFetch(url, {
method: 'PATCH',
headers: { Accept: 'application/json' },
credentials: 'include'
})
const ct = res.headers.get('content-type') || ''
const raw = await res.text()
const json: MatrixStateResponse | null = ct.includes('application/json') ? JSON.parse(raw) : null
if (!res.ok || !json?.success) {
const msg = json?.message || `Request failed: ${res.status}`
throw new Error(msg)
}
return json.data!
}
export async function deactivateMatrix(id: string | number) {
if (!id && id !== 0) throw new Error('matrix id required')
return patch(`/api/admin/matrix/${id}/deactivate`)
}
export async function activateMatrix(id: string | number) {
if (!id && id !== 0) throw new Error('matrix id required')
// Assuming symmetrical endpoint; backend may expose this path.
return patch(`/api/admin/matrix/${id}/activate`)
}

View File

@ -0,0 +1,45 @@
export type CreateMatrixResult = {
ok: boolean
status: number
body?: any
message?: string
}
export async function createMatrix(params: {
token: string
name: string
email: string
force?: boolean
baseUrl?: string
}): Promise<CreateMatrixResult> {
const { token, name, email, force = false, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params
if (!token) return { ok: false, status: 401, message: 'Missing token' }
const url = new URL(`${baseUrl}/api/matrix/create`)
url.searchParams.set('name', name)
url.searchParams.set('email', email)
if (force) url.searchParams.set('force', 'true')
try {
const res = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
credentials: 'include',
})
let body: any = null
try { body = await res.json() } catch {}
if (!res.ok) {
return {
ok: false,
status: res.status,
body,
message: body?.message || `Create matrix failed (${res.status})`
}
}
return { ok: true, status: res.status, body }
} catch (err) {
return { ok: false, status: 0, message: 'Network error' }
}
}

View File

@ -0,0 +1,35 @@
export type GetMatrixStatsResult = {
ok: boolean
status: number
body?: any
message?: string
}
export async function getMatrixStats(params: {
token: string
baseUrl?: string
}): Promise<GetMatrixStatsResult> {
const { token, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params
if (!token) return { ok: false, status: 401, message: 'Missing token' }
const base = (baseUrl || '').replace(/\/+$/, '')
const url = `${base}/api/matrix/stats`
console.info('[getMatrixStats] REQUEST GET', url)
try {
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
let body: any = null
try { body = await res.json() } catch {}
console.debug('[getMatrixStats] Response', { status: res.status, hasBody: !!body, keys: body ? Object.keys(body) : [] })
if (!res.ok) {
return { ok: false, status: res.status, body, message: body?.message || `Fetch stats failed (${res.status})` }
}
return { ok: true, status: res.status, body }
} catch (e) {
console.error('[getMatrixStats] Network error', e)
return { ok: false, status: 0, message: 'Network error' }
}
}

View File

@ -0,0 +1,531 @@
'use client'
import React, { useMemo, useState, useEffect } from 'react'
import {
ChartBarIcon,
CheckCircleIcon,
UsersIcon,
PlusIcon,
EnvelopeIcon,
CalendarDaysIcon,
} from '@heroicons/react/24/outline'
import PageLayout from '../../components/PageLayout'
import { useRouter } from 'next/navigation'
import useAuthStore from '../../store/authStore'
import { createMatrix } from './hooks/createMatrix'
import { getMatrixStats } from './hooks/getMatrixStats'
import { deactivateMatrix, activateMatrix } from './hooks/changeMatrixState' // NEW
type Matrix = {
id: string
name: string
status: 'active' | 'inactive'
usersCount: number
createdAt: string
topNodeEmail: string
rootUserId: number // added
policyMaxDepth?: number | null // NEW
}
export default function MatrixManagementPage() {
const router = useRouter()
const user = useAuthStore(s => s.user)
const token = useAuthStore(s => s.accessToken)
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin'))
)
useEffect(() => {
if (user === null) {
router.push('/login')
} else if (user && !isAdmin) {
router.push('/')
}
}, [user, isAdmin, router])
const [matrices, setMatrices] = useState<Matrix[]>([])
const [stats, setStats] = useState({ total: 0, active: 0, totalUsers: 0 })
const [statsLoading, setStatsLoading] = useState(false)
const [statsError, setStatsError] = useState<string>('')
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('')
const [createEmail, setCreateEmail] = useState('')
const [formError, setFormError] = useState<string>('')
const [createLoading, setCreateLoading] = useState(false)
const [forcePrompt, setForcePrompt] = useState<{ name: string; email: string } | null>(null)
const [createSuccess, setCreateSuccess] = useState<{ name: string; email: string } | null>(null)
const [policyFilter, setPolicyFilter] = useState<'all'|'unlimited'|'five'>('all') // CHANGED
const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc')
const [sortByPolicy, setSortByPolicy] = useState<'none'|'asc'|'desc'>('none') // NEW
const [mutatingId, setMutatingId] = useState<string | null>(null) // NEW
const loadStats = async () => {
if (!token) return
setStatsLoading(true)
setStatsError('')
try {
const res = await getMatrixStats({ token })
console.log('📊 MatrixManagement: GET /matrix/stats ->', res.status, res.body)
if (res.ok) {
const payload = res.body?.data || res.body || {}
const apiMatrices: any[] = Array.isArray(payload.matrices) ? payload.matrices : []
const mapped: Matrix[] = apiMatrices.map((m: any, idx: number) => {
const isActive = !!m?.isActive
const createdAt = m?.createdAt || m?.ego_activated_at || m?.activatedAt || new Date().toISOString()
const topNodeEmail = m?.topNodeEmail || m?.masterTopUserEmail || m?.email || ''
const rootUserId = Number(m?.rootUserId ?? m?.root_user_id ?? 0)
const matrixId = m?.matrixId ?? m?.id // prefer matrixId for routing
const maxDepth = (m?.maxDepth ?? m?.policyMaxDepth) // backend optional
return {
id: String(matrixId ?? `m-${idx}`),
name: String(m?.name ?? 'Unnamed Matrix'),
status: isActive ? 'active' : 'inactive',
usersCount: Number(m?.usersCount ?? 0),
createdAt: String(createdAt),
topNodeEmail: String(topNodeEmail),
rootUserId,
policyMaxDepth: typeof maxDepth === 'number' ? maxDepth : null // NEW
}
})
setMatrices(mapped)
const activeMatrices = Number(payload.activeMatrices ?? mapped.filter(m => m.status === 'active').length)
const totalMatrices = Number(payload.totalMatrices ?? mapped.length)
const totalUsersSubscribed = Number(payload.totalUsersSubscribed ?? 0)
setStats({ total: totalMatrices, active: activeMatrices, totalUsers: totalUsersSubscribed })
console.log('✅ MatrixManagement: mapped stats:', { total: totalMatrices, active: activeMatrices, totalUsers: totalUsersSubscribed })
console.log('✅ MatrixManagement: mapped matrices sample:', mapped.slice(0, 3))
} else {
setStatsError(res.message || 'Failed to load matrix stats.')
}
} catch (e) {
console.error('❌ MatrixManagement: stats load error', e)
setStatsError('Network error while loading matrix stats.')
} finally {
setStatsLoading(false)
}
}
useEffect(() => {
loadStats()
}, [token])
const resetForm = () => {
setCreateName('')
setCreateEmail('')
setFormError('')
setForcePrompt(null)
setCreateSuccess(null)
}
const validateEmail = (email: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
const name = createName.trim()
const email = createEmail.trim()
setFormError('')
setCreateSuccess(null)
setForcePrompt(null)
if (!name) {
setFormError('Please provide a matrix name.')
return
}
if (!email || !validateEmail(email)) {
setFormError('Please provide a valid top-node email.')
return
}
if (!token) {
setFormError('Not authenticated. Please log in again.')
return
}
setCreateLoading(true)
try {
const res = await createMatrix({ token, name, email })
console.log('🧱 MatrixManagement: create result ->', res.status, res.body)
if (res.ok && res.body?.success) {
const createdName = res.body?.data?.name || name
const createdEmail = res.body?.data?.masterTopUserEmail || email
setCreateSuccess({ name: createdName, email: createdEmail })
await loadStats()
setCreateName('')
setCreateEmail('')
} else if (res.status === 409) {
setForcePrompt({ name, email })
} else {
setFormError(res.message || 'Failed to create matrix.')
}
} catch (err) {
setFormError('Network error while creating the matrix.')
} finally {
setCreateLoading(false)
}
}
const confirmForce = async () => {
if (!forcePrompt || !token) return
setFormError('')
setCreateLoading(true)
try {
const res = await createMatrix({ token, name: forcePrompt.name, email: forcePrompt.email, force: true })
console.log('🧱 MatrixManagement: force-create result ->', res.status, res.body)
if (res.ok && res.body?.success) {
const createdName = res.body?.data?.name || forcePrompt.name
const createdEmail = res.body?.data?.masterTopUserEmail || forcePrompt.email
setCreateSuccess({ name: createdName, email: createdEmail })
setForcePrompt(null)
setCreateName('')
setCreateEmail('')
await loadStats()
} else {
setFormError(res.message || 'Failed to create matrix (force).')
}
} catch {
setFormError('Network error while forcing the matrix creation.')
} finally {
setCreateLoading(false)
}
}
const toggleStatus = async (id: string) => {
try {
const target = matrices.find(m => m.id === id)
if (!target) return
setStatsError('')
setMutatingId(id)
if (target.status === 'active') {
await deactivateMatrix(id)
} else {
await activateMatrix(id)
}
await loadStats()
} catch (e: any) {
setStatsError(e?.message || 'Failed to change matrix state.')
} finally {
setMutatingId(null)
}
}
// derived list with filter/sort (always apply selected filter)
const matricesView = useMemo(() => {
let list = [...matrices]
list = list.filter(m => {
const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0
if (policyFilter === 'all') return true
return policyFilter === 'unlimited' ? unlimited : (!unlimited && m.policyMaxDepth === 5)
})
list.sort((a,b) => {
if (sortByPolicy !== 'none') {
const pa = (!a.policyMaxDepth || a.policyMaxDepth <= 0) ? Infinity : a.policyMaxDepth
const pb = (!b.policyMaxDepth || b.policyMaxDepth <= 0) ? Infinity : b.policyMaxDepth
const diff = sortByPolicy === 'asc' ? pa - pb : pb - pa
if (diff !== 0) return diff
}
return sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount)
})
return list
}, [matrices, policyFilter, sortByUsers, sortByPolicy])
const StatCard = ({
icon: Icon,
label,
value,
color,
}: {
icon: any
label: string
value: number
color: string
}) => (
<div className="relative overflow-hidden rounded-lg bg-white px-4 pb-6 pt-5 shadow-sm border border-gray-200 sm:px-6 sm:pt-6">
<dt>
<div className={`absolute rounded-md ${color} p-3`}>
<Icon className="h-6 w-6 text-white" aria-hidden="true" />
</div>
<p className="ml-16 truncate text-sm font-medium text-gray-500">{label}</p>
</dt>
<dd className="ml-16 mt-2 text-2xl font-semibold text-gray-900">{value}</dd>
</div>
)
const StatusBadge = ({ status }: { status: Matrix['status'] }) => (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700'
}`}
>
<span
className={`mr-1.5 h-1.5 w-1.5 rounded-full ${
status === 'active' ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
)
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen w-full">
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
{/* Header + Create */}
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Matrix Management</h1>
<p className="text-lg text-blue-700 mt-2">Manage matrices, see stats, and create new ones.</p>
</div>
<button
onClick={() => setCreateOpen(true)}
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
>
<PlusIcon className="h-5 w-5" />
Create Matrix
</button>
</div>
</header>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-6">
<div className="flex items-center gap-2 text-xs text-blue-900">
<span className="font-semibold">Policy filter:</span>
<button onClick={() => setPolicyFilter('all')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='all'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>All</button>
<button onClick={() => setPolicyFilter('unlimited')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='unlimited'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Unlimited</button>
<button onClick={() => setPolicyFilter('five')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='five'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Depth 5</button>
</div>
<div className="flex items-center gap-2 text-xs text-blue-900">
<span className="font-semibold">Sort:</span>
<select value={sortByPolicy} onChange={e => setSortByPolicy(e.target.value as any)} className="border rounded px-2 py-1">
<option value="none">None</option>
<option value="asc">Policy </option>
<option value="desc">Policy </option>
</select>
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="border rounded px-2 py-1">
<option value="desc">Users </option>
<option value="asc">Users </option>
</select>
</div>
</div>
{/* Error banner for stats */}
{statsError && (
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{statsError}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-8">
<StatCard icon={CheckCircleIcon} label="Active Matrices" value={stats.active} color="bg-green-500" />
<StatCard icon={ChartBarIcon} label="Total Matrices" value={stats.total} color="bg-blue-900" />
<StatCard icon={UsersIcon} label="Total Users Subscribed" value={stats.totalUsers} color="bg-amber-600" />
</div>
{/* Matrix cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
{statsLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden">
<div className="p-6 animate-pulse space-y-4">
<div className="h-5 w-1/2 bg-gray-200 rounded" />
<div className="h-4 w-1/3 bg-gray-200 rounded" />
<div className="h-4 w-2/3 bg-gray-200 rounded" />
<div className="h-9 w-full bg-gray-100 rounded" />
</div>
</div>
))
) : matricesView.length === 0 ? (
<div className="text-sm text-gray-600">No matrices found.</div>
) : (
matricesView.map(m => (
<article key={m.id} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden flex flex-col">
<div className="p-6 flex-1 flex flex-col">
<div className="flex items-start justify-between gap-3">
<h3 className="text-xl font-semibold text-blue-900">{m.name}</h3>
<StatusBadge status={m.status} />
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 border ${m.status==='inactive'?'border-gray-200 bg-gray-50 text-gray-500':'border-blue-200 bg-blue-50 text-blue-900'}`}>
Policy: {(!m.policyMaxDepth || m.policyMaxDepth <= 0) ? 'Unlimited' : m.policyMaxDepth}
</span>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 border ${m.status==='inactive'?'border-gray-200 bg-gray-50 text-gray-500':'border-gray-200 bg-gray-100 text-gray-800'}`}>
Root: unlimited immediate children (sequential), non-root: 5 children (positions 15)
</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 matrixs 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>
)
}

View File

@ -0,0 +1,27 @@
import React from 'react'
import { authFetch } from '../../../utils/authFetch'
export async function addNews(payload: { title: string; summary?: string; content?: string; slug: string; category?: string; isActive: boolean; publishedAt?: string | null; imageFile?: File }) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
const form = new FormData()
form.append('title', payload.title)
if (payload.summary) form.append('summary', payload.summary)
if (payload.content) form.append('content', payload.content)
form.append('slug', payload.slug)
if (payload.category) form.append('category', payload.category)
form.append('isActive', String(payload.isActive))
if (payload.publishedAt) form.append('publishedAt', payload.publishedAt)
if (payload.imageFile) form.append('image', payload.imageFile)
const url = `${BASE_URL}/api/admin/news`
const res = await authFetch(url, { method: 'POST', body: form, headers: { Accept: 'application/json' } })
let body: any = null
try { body = await res.clone().json() } catch {}
if (process.env.NODE_ENV === 'development') {
console.debug('[addNews] status:', res.status)
console.debug('[addNews] body preview:', body ? JSON.stringify(body).slice(0,500) : '<no body>')
}
if (res.status === 401) throw new Error('Unauthorized. Please log in.')
if (res.status === 403) throw new Error('Forbidden. Admin access required.')
if (!res.ok) throw new Error(body?.error || body?.message || `Failed to create news (${res.status})`)
return body || res.json()
}

View File

@ -0,0 +1,9 @@
import React from 'react'
import { authFetch } from '../../../utils/authFetch'
export async function deleteNews(id: number) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
const url = `${BASE_URL}/api/admin/news/${id}`
const res = await authFetch(url, { method: 'DELETE', headers: { Accept: 'application/json' } })
if (!res.ok) throw new Error('Failed to delete news')
return res.json()
}

View File

@ -0,0 +1,58 @@
import React from 'react'
import { authFetch } from '../../../utils/authFetch'
export type AdminNewsItem = {
id: number
title: string
summary?: string
content?: string
slug: string
category?: string
imageUrl?: string
isActive: boolean
publishedAt?: string | null
createdAt?: string
updatedAt?: string
}
export function useAdminNews() {
const [items, setItems] = React.useState<AdminNewsItem[]>([])
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
const refresh = React.useCallback(async () => {
setLoading(true)
setError(null)
try {
const url = `${BASE_URL}/api/admin/news`
const res = await authFetch(url, { headers: { Accept: 'application/json' } })
let json: any = null
try { json = await res.clone().json() } catch {}
if (res.status === 401) throw new Error('Unauthorized. Please log in.')
if (res.status === 403) throw new Error('Forbidden. Admin access required.')
if (!res.ok) throw new Error(json?.error || json?.message || 'Failed to fetch admin news')
const data = (json.data || []).map((r: any) => ({
id: r.id,
title: r.title,
summary: r.summary,
content: r.content,
slug: r.slug,
category: r.category,
imageUrl: r.imageUrl,
isActive: !!r.is_active,
publishedAt: r.published_at || null,
createdAt: r.created_at,
updatedAt: r.updated_at,
}))
setItems(data)
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}, [])
React.useEffect(() => { refresh() }, [refresh])
return { items, loading, error, refresh }
}

View File

@ -0,0 +1,24 @@
import React from 'react'
import { authFetch } from '../../../utils/authFetch'
export async function updateNews(id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
const form = new FormData()
if (payload.title) form.append('title', payload.title)
if (payload.summary) form.append('summary', payload.summary)
if (payload.content) form.append('content', payload.content)
if (payload.slug) form.append('slug', payload.slug)
if (payload.category) form.append('category', payload.category)
if (payload.isActive !== undefined) form.append('isActive', String(payload.isActive))
if (payload.publishedAt !== undefined && payload.publishedAt !== null) form.append('publishedAt', payload.publishedAt)
if (payload.removeImage) form.append('removeImage', 'true')
if (payload.imageFile) form.append('image', payload.imageFile)
const url = `${BASE_URL}/api/admin/news/${id}`
const res = await authFetch(url, { method: 'PATCH', body: form, headers: { Accept: 'application/json' } })
let body: any = null
try { body = await res.clone().json() } catch {}
if (res.status === 401) throw new Error('Unauthorized. Please log in.')
if (res.status === 403) throw new Error('Forbidden. Admin access required.')
if (!res.ok) throw new Error(body?.error || body?.message || `Failed to update news (${res.status})`)
return body || res.json()
}

View File

@ -0,0 +1,293 @@
'use client'
import React from 'react'
import Header from '../../components/nav/Header'
import Footer from '../../components/Footer'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import { PlusIcon, PencilIcon, TrashIcon, PhotoIcon, XMarkIcon } from '@heroicons/react/24/outline'
import AffiliateCropModal from '../affiliate-management/components/AffiliateCropModal'
import { useAdminNews } from './hooks/getNews'
import { addNews } from './hooks/addNews'
import { updateNews } from './hooks/updateNews'
import { deleteNews } from './hooks/deleteNews'
export default function NewsManagementPage() {
const { items, loading, error, refresh } = useAdminNews()
const [showCreate, setShowCreate] = React.useState(false)
const [selected, setSelected] = React.useState<any | null>(null)
const [deleteTarget, setDeleteTarget] = React.useState<any | null>(null)
return (
<PageTransitionEffect>
<Header />
<main className="bg-white min-h-screen pb-20">
<div className="mx-auto max-w-7xl px-6 pt-8 pb-12">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-blue-900">News Manager</h1>
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-900 text-white rounded-lg hover:bg-blue-800">
<PlusIcon className="h-5 w-5" /> Add News
</button>
</div>
{error && <div className="mt-4 text-red-600">{error}</div>}
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-8">
{items.map(item => (
<div key={item.id} className="rounded-2xl border border-gray-200 overflow-hidden bg-white shadow-sm">
<div className="aspect-[3/2] bg-gray-100">
{item.imageUrl ? (
<img src={item.imageUrl} alt={item.title} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full flex items-center justify-center">
<PhotoIcon className="h-12 w-12 text-gray-400" />
</div>
)}
</div>
<div className="p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-blue-900 truncate">{item.title}</h3>
<span className={`text-xs px-2 py-1 rounded ${item.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>{item.isActive ? 'Active' : 'Inactive'}</span>
</div>
{item.summary && <p className="mt-2 text-sm text-gray-700 line-clamp-2">{item.summary}</p>}
<div className="mt-3 space-y-1 text-xs text-gray-500">
{item.publishedAt && <div>Published: {new Date(item.publishedAt).toLocaleDateString('de-DE')}</div>}
{item.createdAt && <div>Created: {new Date(item.createdAt).toLocaleDateString('de-DE')}</div>}
{item.updatedAt && <div>Updated: {new Date(item.updatedAt).toLocaleDateString('de-DE')}</div>}
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button onClick={() => setSelected(item)} className="px-3 py-1.5 text-sm bg-blue-50 text-blue-900 rounded hover:bg-blue-100">
<PencilIcon className="h-4 w-4" />
</button>
<button onClick={() => setDeleteTarget(item)} className="px-3 py-1.5 text-sm bg-red-50 text-red-700 rounded hover:bg-red-100">
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
</main>
<Footer />
{showCreate && (
<CreateNewsModal onClose={() => setShowCreate(false)} onCreate={async (payload) => { await addNews(payload); setShowCreate(false); await refresh() }} />
)}
{selected && (
<EditNewsModal item={selected} onClose={() => setSelected(null)} onUpdate={async (id, payload) => { await updateNews(id, payload); setSelected(null); await refresh() }} />
)}
{deleteTarget && (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/30" onClick={() => setDeleteTarget(null)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
<div className="px-6 pt-6">
<h3 className="text-lg font-semibold text-blue-900">Delete news?</h3>
<p className="mt-2 text-sm text-gray-700">You are about to delete "{deleteTarget.title}". This action cannot be undone.</p>
</div>
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
<button
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
onClick={() => setDeleteTarget(null)}
>
Cancel
</button>
<button
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow"
onClick={async () => { await deleteNews(deleteTarget.id); setDeleteTarget(null); await refresh(); }}
>
Delete
</button>
</div>
</div>
</div>
</div>
)}
</PageTransitionEffect>
)
}
function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate: (payload: { title: string; summary?: string; content?: string; slug: string; category?: string; isActive: boolean; publishedAt?: string | null; imageFile?: File }) => void }) {
const [title, setTitle] = React.useState('')
const [summary, setSummary] = React.useState('')
const [content, setContent] = React.useState('')
const [slug, setSlug] = React.useState('')
const [category, setCategory] = React.useState('')
const [isActive, setIsActive] = React.useState(true)
const [publishedAt, setPublishedAt] = React.useState<string>('')
const [imageFile, setImageFile] = React.useState<File | undefined>(undefined)
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null)
const [showCrop, setShowCrop] = React.useState(false)
const [rawUrl, setRawUrl] = React.useState<string | null>(null)
React.useEffect(() => () => { if (previewUrl) URL.revokeObjectURL(previewUrl); if (rawUrl) URL.revokeObjectURL(rawUrl) }, [previewUrl, rawUrl])
const openFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]
if (!f) return
const allowed = ['image/jpeg','image/png','image/webp']
if (!allowed.includes(f.type)) { alert('Invalid image type'); e.target.value=''; return }
if (f.size > 5*1024*1024) { alert('Max 5MB'); e.target.value=''; return }
const url = URL.createObjectURL(f)
setRawUrl(url)
setShowCrop(true)
e.target.value=''
}
const onCropComplete = (blob: Blob) => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
if (rawUrl) URL.revokeObjectURL(rawUrl)
const file = new File([blob], 'news-image.jpg', { type: 'image/jpeg' })
setImageFile(file)
setPreviewUrl(URL.createObjectURL(blob))
setRawUrl(null)
}
const submit = (e: React.FormEvent) => {
e.preventDefault()
onCreate({ title, summary: summary || undefined, content: content || undefined, slug, category: category || undefined, isActive, publishedAt: publishedAt || undefined, imageFile })
}
return (
<>
<AffiliateCropModal isOpen={showCrop} imageSrc={rawUrl || ''} onClose={() => { setShowCrop(false); if (rawUrl) URL.revokeObjectURL(rawUrl); setRawUrl(null) }} onCropComplete={onCropComplete} />
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-blue-900">Add News</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
</div>
<form onSubmit={submit} className="p-6 space-y-4">
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('news-image-upload') as HTMLInputElement)?.click()}>
{!previewUrl ? (
<div className="text-center w-full px-6 py-10">
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4 text-sm font-medium text-gray-700">Click to upload</div>
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP up to 5MB</p>
</div>
) : (
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
<img src={previewUrl} alt="Preview" className="max-h-[180px] max-w-full object-contain" />
<button type="button" onClick={() => { if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(null); setImageFile(undefined) }} className="absolute top-2 right-2 bg-red-50 hover:bg-red-100 text-red-700 px-3 py-1.5 rounded-lg text-sm">Remove</button>
</div>
)}
</div>
<input id="news-image-upload" type="file" accept="image/*" className="hidden" onChange={openFile} />
</div>
<div className="flex items-center gap-2">
<input id="isActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">Active</label>
<input type="datetime-local" value={publishedAt} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm text-gray-900" />
</div>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Add News</button>
</div>
</form>
</div>
</div>
</>
)
}
function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () => void; onUpdate: (id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) => void }) {
const [title, setTitle] = React.useState(item.title)
const [summary, setSummary] = React.useState(item.summary || '')
const [content, setContent] = React.useState(item.content || '')
const [slug, setSlug] = React.useState(item.slug)
const [category, setCategory] = React.useState(item.category || '')
const [isActive, setIsActive] = React.useState(item.isActive)
const [publishedAt, setPublishedAt] = React.useState<string>(item.publishedAt || '')
const [imageFile, setImageFile] = React.useState<File | undefined>(undefined)
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null)
const [currentUrl, setCurrentUrl] = React.useState<string | undefined>(item.imageUrl)
const [removeImage, setRemoveImage] = React.useState(false)
const [showCrop, setShowCrop] = React.useState(false)
const [rawUrl, setRawUrl] = React.useState<string | null>(null)
React.useEffect(() => () => { if (previewUrl) URL.revokeObjectURL(previewUrl); if (rawUrl) URL.revokeObjectURL(rawUrl) }, [previewUrl, rawUrl])
const openFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]
if (!f) return
const allowed = ['image/jpeg','image/png','image/webp']
if (!allowed.includes(f.type)) { alert('Invalid image type'); e.target.value=''; return }
if (f.size > 5*1024*1024) { alert('Max 5MB'); e.target.value=''; return }
const url = URL.createObjectURL(f)
setRawUrl(url)
setShowCrop(true)
e.target.value=''
}
const onCropComplete = (blob: Blob) => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
if (rawUrl) URL.revokeObjectURL(rawUrl)
const file = new File([blob], 'news-image.jpg', { type: 'image/jpeg' })
setImageFile(file)
setRemoveImage(false)
setPreviewUrl(URL.createObjectURL(blob))
setRawUrl(null)
}
const displayUrl = removeImage ? null : (previewUrl || currentUrl)
const submit = (e: React.FormEvent) => {
e.preventDefault()
onUpdate(item.id, { title, summary: summary || undefined, content: content || undefined, slug, category: category || undefined, isActive, publishedAt, imageFile, removeImage: removeImage && !imageFile })
}
return (
<>
<AffiliateCropModal isOpen={showCrop} imageSrc={rawUrl || ''} onClose={() => { setShowCrop(false); if (rawUrl) URL.revokeObjectURL(rawUrl); setRawUrl(null) }} onCropComplete={onCropComplete} />
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-blue-900">Edit News</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
</div>
<form onSubmit={submit} className="p-6 space-y-4">
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('edit-news-image-upload') as HTMLInputElement)?.click()}>
{!displayUrl ? (
<div className="text-center w-full px-6 py-10">
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4 text-sm font-medium text-gray-700">Click to upload</div>
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP up to 5MB</p>
</div>
) : (
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
<img src={displayUrl} alt="Preview" className="max-h-[180px] max-w-full object-contain" />
<button type="button" onClick={() => { if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(null); setImageFile(undefined); setCurrentUrl(undefined); setRemoveImage(true) }} className="absolute top-2 right-2 bg-red-50 hover:bg-red-100 text-red-700 px-3 py-1.5 rounded-lg text-sm">Remove</button>
</div>
)}
</div>
<input id="edit-news-image-upload" type="file" accept="image/*" className="hidden" onChange={openFile} />
</div>
<div className="flex items-center gap-2">
<input id="editIsActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
<label htmlFor="editIsActive" className="text-sm font-medium text-gray-700">Active</label>
<input type="datetime-local" value={publishedAt || ''} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm text-gray-900" />
</div>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Save Changes</button>
</div>
</form>
</div>
</div>
</>
)
}

485
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,485 @@
'use client'
import PageLayout from '../components/PageLayout'
import Waves from '../components/background/waves'
import BlueBlurryBackground from '../components/background/blueblurry' // NEW
import {
UsersIcon,
ExclamationTriangleIcon,
CpuChipIcon,
ServerStackIcon,
ArrowRightIcon,
Squares2X2Icon,
BanknotesIcon,
ClipboardDocumentListIcon,
CommandLineIcon
} from '@heroicons/react/24/outline'
import { useMemo, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAdminUsers } from '../hooks/useAdminUsers'
import useAuthStore from '../store/authStore'
// env-based feature flags
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
const DISPLAY_ABONEMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMMENTS !== 'false'
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
const DISPLAY_DEV_MANAGEMENT = process.env.NEXT_PUBLIC_DISPLAY_DEV_MANAGEMENT !== 'false'
export default function AdminDashboardPage() {
const router = useRouter()
const { userStats, isAdmin } = useAdminUsers()
const user = useAuthStore(s => s.user)
const isAdminOrSuper =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin')) ||
(user as any)?.role === 'super_admin' ||
(user as any)?.userType === 'super_admin' ||
(user as any)?.isSuperAdmin === true ||
((user as any)?.roles?.includes?.('super_admin'))
)
const [isClient, setIsClient] = useState(false)
const [isMobile, setIsMobile] = useState(false)
// Handle client-side mounting
useEffect(() => {
setIsClient(true)
}, [])
useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)')
const apply = () => setIsMobile(mq.matches)
apply()
mq.addEventListener?.('change', apply)
window.addEventListener('resize', apply, { passive: true })
return () => {
mq.removeEventListener?.('change', apply)
window.removeEventListener('resize', apply)
}
}, [])
// Fallback for loading/no data
const displayStats = userStats || {
totalUsers: 0,
adminUsers: 0,
verificationPending: 0,
activeUsers: 0,
personalUsers: 0,
companyUsers: 0
}
const permissionStats = useMemo(() => ({
permissions: 1 // TODO: fetch permission definitions
}), [])
const serverStats = useMemo(() => ({
status: 'Online',
uptime: '4 days, 8 hours',
cpu: '0%',
memory: '0.1 / 7.8',
recentErrors: [] as { id: string; ts: string; msg: string }[]
}), [])
// Show loading during SSR/initial client render
if (!isClient) {
return (
<PageLayout>
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="text-center">
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-blue-900">Loading...</p>
</div>
</div>
</PageLayout>
)
}
// Access check (only after client-side hydration)
if (!isAdmin) {
return (
<PageLayout>
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
<p className="text-gray-600">You need admin privileges to access this page.</p>
</div>
</div>
</div>
</PageLayout>
)
}
const content = (
<div className="relative z-10 min-h-screen flex flex-col">
<main className="flex-1 max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8 w-full">
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
{/* Header */}
<header className="flex flex-col gap-4 mb-8">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Admin Dashboard</h1>
<p className="text-lg text-blue-700 mt-2">
Manage all administrative features, user management, permissions, and global settings.
</p>
</div>
</header>
{/* Warning banner */}
<div className="rounded-2xl border border-red-300 bg-red-50 text-red-700 px-8 py-6 flex gap-3 items-start text-base mb-8 shadow">
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
<div className="leading-relaxed">
<p className="font-semibold mb-0.5">
Warning: Settings and actions below this point can have consequences for the entire system!
</p>
<p className="text-red-600/80 hidden sm:block">
Manage all administrative features, user management, permissions, and global settings.
</p>
</div>
</div>
{/* Stats Card */}
<div className="mb-8 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6">
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Total Users</div>
<div className="text-xl font-semibold text-blue-900">{displayStats.totalUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Admins</div>
<div className="text-xl font-semibold text-indigo-700">{displayStats.adminUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Active</div>
<div className="text-xl font-semibold text-green-700">{displayStats.activeUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Pending Verification</div>
<div className="text-xl font-semibold text-amber-700">{displayStats.verificationPending}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Personal</div>
<div className="text-xl font-semibold text-blue-700">{displayStats.personalUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Company</div>
<div className="text-xl font-semibold text-purple-700">{displayStats.companyUsers}</div>
</div>
</div>
{/* Management Shortcuts Card */}
<div className="mb-8">
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
<div className="flex items-start gap-4 mb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-blue-900">Management Shortcuts</h2>
<p className="text-sm text-blue-700 mt-0.5">
Quick access to common admin modules.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Matrix Management */}
<button
type="button"
disabled={!DISPLAY_MATRIX}
onClick={DISPLAY_MATRIX ? () => router.push('/admin/matrix-management') : undefined}
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
DISPLAY_MATRIX
? 'border border-blue-200 bg-blue-50 hover:bg-blue-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<div className="flex items-center gap-4">
<span
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
DISPLAY_MATRIX
? 'bg-blue-100 border-blue-200 group-hover:animate-pulse'
: 'bg-gray-100 border-gray-300'
}`}
>
<Squares2X2Icon className={`h-6 w-6 ${DISPLAY_MATRIX ? 'text-blue-600' : 'text-gray-400'}`} />
</span>
<div className="text-left">
<div className="text-base font-semibold text-blue-900">Matrix Management</div>
<div className="text-xs text-blue-700">Configure matrices and users</div>
{!DISPLAY_MATRIX && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
</p>
)}
</div>
</div>
<ArrowRightIcon
className={`h-5 w-5 ${
DISPLAY_MATRIX ? 'text-blue-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
}`}
/>
</button>
{/* Coffee Subscription Management */}
<button
type="button"
disabled={!DISPLAY_ABONEMENTS}
onClick={DISPLAY_ABONEMENTS ? () => router.push('/admin/subscriptions') : undefined}
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
DISPLAY_ABONEMENTS
? 'border border-amber-200 bg-amber-50 hover:bg-amber-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<div className="flex items-center gap-4">
<span
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
DISPLAY_ABONEMENTS
? 'bg-amber-100 border-amber-200 group-hover:animate-pulse'
: 'bg-gray-100 border-gray-300'
}`}
>
<BanknotesIcon className={`h-6 w-6 ${DISPLAY_ABONEMENTS ? 'text-amber-600' : 'text-gray-400'}`} />
</span>
<div className="text-left">
<div className="text-base font-semibold text-amber-900">Coffee Subscription Management</div>
<div className="text-xs text-amber-700">Plans, billing and renewals</div>
{!DISPLAY_ABONEMENTS && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
</p>
)}
</div>
</div>
<ArrowRightIcon
className={`h-5 w-5 ${
DISPLAY_ABONEMENTS
? 'text-amber-600 opacity-70 group-hover:opacity-100'
: 'text-gray-400 opacity-60'
}`}
/>
</button>
{/* Contract Management (unchanged) */}
<button
type="button"
onClick={() => router.push('/admin/contract-management')}
className="group w-full flex items-center justify-between rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
>
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-indigo-100 border border-indigo-200 group-hover:animate-pulse">
<ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-indigo-900">Contract Management</div>
<div className="text-xs text-indigo-700">Templates, approvals, status</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
</button>
{/* User Management (unchanged) */}
<button
type="button"
onClick={() => router.push('/admin/user-management')}
className="group w-full flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 hover:bg-blue-50 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
>
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-blue-100 border border-blue-200 group-hover:animate-pulse">
<UsersIcon className="h-6 w-6 text-blue-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-blue-900">User Management</div>
<div className="text-xs text-blue-700">Browse, search, and manage all users</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
</button>
{/* News Management */}
<button
type="button"
disabled={!DISPLAY_NEWS}
onClick={DISPLAY_NEWS ? () => router.push('/admin/news-management') : undefined}
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
DISPLAY_NEWS
? 'border border-green-200 bg-green-50 hover:bg-green-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<div className="flex items-center gap-4">
<span
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
DISPLAY_NEWS
? 'bg-green-100 border-green-200 group-hover:animate-pulse'
: 'bg-gray-100 border-gray-300'
}`}
>
<ClipboardDocumentListIcon className={`h-6 w-6 ${DISPLAY_NEWS ? 'text-green-600' : 'text-gray-400'}`} />
</span>
<div className="text-left">
<div className="text-base font-semibold text-green-900">News Management</div>
<div className="text-xs text-green-700">Create and manage news articles</div>
{!DISPLAY_NEWS && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
</p>
)}
</div>
</div>
<ArrowRightIcon
className={`h-5 w-5 ${
DISPLAY_NEWS ? 'text-green-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
}`}
/>
</button>
{/* Dev Management */}
<button
type="button"
disabled={!DISPLAY_DEV_MANAGEMENT || !isAdminOrSuper}
onClick={DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? () => router.push('/admin/dev-management') : undefined}
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper
? 'border border-slate-200 bg-slate-50 hover:bg-slate-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<div className="flex items-center gap-4">
<span
className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper
? 'bg-slate-100 border-slate-200 group-hover:animate-pulse'
: 'bg-gray-100 border-gray-300'
}`}
>
<CommandLineIcon className={`h-6 w-6 ${DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600' : 'text-gray-400'}`} />
</span>
<div className="text-left">
<div className="text-base font-semibold text-slate-900">Dev Management</div>
<div className="text-xs text-slate-700">Run SQL queries and dev tools</div>
{!DISPLAY_DEV_MANAGEMENT && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
</p>
)}
{DISPLAY_DEV_MANAGEMENT && !isAdminOrSuper && (
<p className="mt-1 text-xs text-gray-500 italic">
Admin access required.
</p>
)}
</div>
</div>
<ArrowRightIcon
className={`h-5 w-5 ${
DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
}`}
/>
</button>
</div>
</div>
</div>
{/* Server Status & Logs */}
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
<div className="flex items-start gap-4 mb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gray-100">
<ServerStackIcon className="h-7 w-7 text-gray-700" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">
Server Status & Logs
</h2>
<p className="text-sm text-gray-500 mt-0.5">
System health, resource usage & recent error insights.
</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* Metrics */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<span className={`h-2.5 w-2.5 rounded-full ${serverStats.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
<p className="text-base">
<span className="font-semibold">Server Status:</span>{' '}
<span className={serverStats.status === 'Online' ? 'text-emerald-600 font-medium' : 'text-red-600 font-medium'}>
{serverStats.status === 'Online' ? 'Server Online' : 'Offline'}
</span>
</p>
</div>
<div className="text-sm space-y-1 text-gray-600">
<p><span className="font-medium text-gray-700">Uptime:</span> {serverStats.uptime}</p>
<p><span className="font-medium text-gray-700">CPU Usage:</span> {serverStats.cpu}</p>
<p><span className="font-medium text-gray-700">Memory Usage:</span> {serverStats.memory} GB</p>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<CpuChipIcon className="h-4 w-4" />
<span>Autoscaled environment (mock)</span>
</div>
</div>
{/* Divider */}
<div className="hidden lg:block border-l border-gray-200" />
{/* Logs */}
<div className="lg:col-span-2">
<h3 className="text-base font-semibold text-gray-800 mb-3">
Recent Error Logs
</h3>
{serverStats.recentErrors.length === 0 && (
<p className="text-sm text-gray-500 italic">
No recent logs.
</p>
)}
{/* Placeholder for future logs list */}
{/* TODO: Replace with mapped log entries */}
<div className="mt-6">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-gray-50 hover:bg-gray-100 text-gray-700 text-sm font-medium px-4 py-3 transition"
// TODO: navigate to logs / monitoring page
onClick={() => {}}
>
View Full Logs
<ArrowRightIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
)
return (
<PageLayout>
{isMobile ? (
<BlueBlurryBackground>{content}</BlueBlurryBackground>
) : (
<div
className="relative w-full flex flex-col min-h-screen overflow-hidden"
style={{ backgroundImage: 'none', background: 'none' }}
>
<Waves
className="pointer-events-none"
lineColor="#0f172a"
backgroundColor="rgba(245, 245, 240, 1)"
waveSpeedX={0.02}
waveSpeedY={0.01}
waveAmpX={40}
waveAmpY={20}
friction={0.9}
tension={0.01}
maxCursorMove={120}
xGap={12}
yGap={36}
/>
{content}
</div>
)}
</PageLayout>
)
}

View File

@ -0,0 +1,158 @@
'use client'
import React from 'react'
interface Props {
isOpen: boolean
onClose: () => void
onCreate: (data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other' }) => void | Promise<void>
creating: boolean
error?: string
success?: string
clearMessages: () => void
}
export default function CreateNewPoolModal({
isOpen,
onClose,
onCreate,
creating,
error,
success,
clearMessages
}: Props) {
const [poolName, setPoolName] = React.useState('')
const [description, setDescription] = React.useState('')
const [price, setPrice] = React.useState('0.00')
const [poolType, setPoolType] = React.useState<'coffee' | 'other'>('other')
const isDisabled = creating || !!success
React.useEffect(() => {
if (!isOpen) {
setPoolName('')
setDescription('')
setPrice('0.00')
setPoolType('other')
}
}, [isOpen])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-lg mx-4 rounded-2xl bg-white shadow-xl border border-blue-100 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-blue-900">Create New Pool</h2>
<button
onClick={() => { clearMessages(); onClose(); }}
className="text-gray-500 hover:text-gray-700 transition text-sm"
aria-label="Close"
>
</button>
</div>
{success && (
<div className="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
{success}
</div>
)}
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<form
onSubmit={e => {
e.preventDefault()
clearMessages()
onCreate({ pool_name: poolName, description, price: parseFloat(price) || 0, pool_type: poolType })
}}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Name</label>
<input
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
placeholder="e.g., VIP Members"
value={poolName}
onChange={e => setPoolName(e.target.value)}
disabled={isDisabled}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Description</label>
<textarea
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
rows={3}
placeholder="Short description of the pool"
value={description}
onChange={e => setDescription(e.target.value)}
disabled={isDisabled}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Price (per capsule)</label>
<input
type="number"
step="0.01"
min="0"
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
placeholder="0.00"
value={price}
onChange={e => setPrice(e.target.value)}
disabled={isDisabled}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Type</label>
<select
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
value={poolType}
onChange={e => setPoolType(e.target.value as 'coffee' | 'other')}
disabled={isDisabled}
>
<option value="other">Other</option>
<option value="coffee">Coffee</option>
</select>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={isDisabled}
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
>
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
{creating ? 'Creating...' : 'Create Pool'}
</button>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition"
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); clearMessages(); }}
disabled={isDisabled}
>
Reset
</button>
<button
type="button"
className="ml-auto px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800 transition"
onClick={() => { clearMessages(); onClose(); }}
disabled={isDisabled}
>
Close
</button>
</div>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,53 @@
import { authFetch } from '../../../utils/authFetch';
export type AddPoolPayload = {
pool_name: string;
description?: string;
price: number;
pool_type: 'coffee' | 'other';
is_active?: boolean;
};
export async function addPool(payload: AddPoolPayload) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/pools`;
const res = await authFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
let body: any = null;
try {
body = await res.json();
} catch {
body = null;
}
const ok = res.status === 201 || res.ok;
const message =
body?.message ||
(res.status === 409
? 'Pool name already exists.'
: res.status === 400
? 'Invalid request. Check pool data.'
: res.status === 401
? 'Unauthorized.'
: res.status === 403
? 'Forbidden.'
: res.status === 500
? 'Internal server error.'
: !ok
? `Request failed (${res.status}).`
: '');
return {
ok,
status: res.status,
body,
message,
};
}

View File

@ -0,0 +1,49 @@
import { authFetch } from '../../../utils/authFetch';
async function setPoolActiveStatus(
id: string | number,
is_active: boolean
) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/pools/${id}/active`;
const res = await authFetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ is_active }),
});
let body: any = null;
try {
body = await res.json();
} catch {
body = null;
}
const ok = res.ok;
const message =
body?.message ||
(res.status === 404
? 'Pool not found.'
: res.status === 400
? 'Invalid request.'
: res.status === 403
? 'Forbidden.'
: res.status === 500
? 'Server error.'
: !ok
? `Request failed (${res.status}).`
: '');
return { ok, status: res.status, body, message };
}
export async function setPoolInactive(id: string | number) {
return setPoolActiveStatus(id, false);
}
export async function setPoolActive(id: string | number) {
return setPoolActiveStatus(id, true);
}

View File

@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { authFetch } from '../../../utils/authFetch';
import { log } from '../../../utils/logger';
export type AdminPool = {
id: string;
pool_name: string;
description?: string;
price?: number;
pool_type?: 'coffee' | 'other';
is_active?: boolean;
membersCount: number;
createdAt: string;
};
export function useAdminPools() {
const [pools, setPools] = useState<AdminPool[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError('');
const url = `${BASE_URL}/api/admin/pools`; // reverted to /api/admin/pools
log("🌐 Pools: GET", url);
try {
const headers = { Accept: 'application/json' };
log("📤 Pools: Request headers:", headers);
const res = await authFetch(url, { headers });
log("📡 Pools: Response status:", res.status);
let body: any = null;
try {
body = await res.clone().json();
const preview = JSON.stringify(body).slice(0, 600);
log("📦 Pools: Response body preview:", preview);
} catch {
log("📦 Pools: Response body is not JSON or failed to parse");
}
if (res.status === 401) {
if (!cancelled) setError('Unauthorized. Please log in.');
return;
}
if (res.status === 403) {
if (!cancelled) setError('Forbidden. Admin access required.');
return;
}
if (!res.ok) {
if (!cancelled) setError('Failed to load pools.');
return;
}
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
log("🔧 Pools: Mapping items count:", apiItems.length);
const mapped: AdminPool[] = apiItems.map(item => ({
id: String(item.id),
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
description: String(item.description ?? ''),
price: Number(item.price ?? 0),
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
is_active: Boolean(item.is_active),
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
createdAt: String(item.created_at ?? new Date().toISOString()),
}));
log("✅ Pools: Mapped sample:", mapped.slice(0, 3));
if (!cancelled) setPools(mapped);
} catch (e: any) {
log("❌ Pools: Network or parsing error:", e?.message || e);
if (!cancelled) setError('Network error while loading pools.');
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [BASE_URL]);
return {
pools,
loading,
error,
refresh: async () => {
const url = `${BASE_URL}/api/admin/pools`; // reverted to /api/admin/pools
log("🔁 Pools: Refresh GET", url);
const res = await authFetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) {
log("❌ Pools: Refresh failed status:", res.status);
return false;
}
const body = await res.json();
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
setPools(apiItems.map(item => ({
id: String(item.id),
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
description: String(item.description ?? ''),
price: Number(item.price ?? 0),
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
is_active: Boolean(item.is_active),
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
createdAt: String(item.created_at ?? new Date().toISOString()),
})));
log("✅ Pools: Refresh succeeded, items:", apiItems.length);
return true;
}
};
}

View File

@ -0,0 +1,49 @@
import { authFetch } from '../../../utils/authFetch';
async function setPoolActiveStatus(
id: string | number,
is_active: boolean
) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/pools/${id}/active`;
const res = await authFetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ is_active }),
});
let body: any = null;
try {
body = await res.json();
} catch {
body = null;
}
const ok = res.ok;
const message =
body?.message ||
(res.status === 404
? 'Pool not found.'
: res.status === 400
? 'Invalid request.'
: res.status === 403
? 'Forbidden.'
: res.status === 500
? 'Server error.'
: !ok
? `Request failed (${res.status}).`
: '');
return { ok, status: res.status, body, message };
}
export async function setPoolInactive(id: string | number) {
return setPoolActiveStatus(id, false);
}
export async function setPoolActive(id: string | number) {
return setPoolActiveStatus(id, true);
}

View File

@ -0,0 +1,513 @@
'use client'
import React, { Suspense } from 'react' // CHANGED: add Suspense
import Header from '../../../components/nav/Header'
import Footer from '../../../components/Footer'
import { UsersIcon, PlusIcon, BanknotesIcon, CalendarDaysIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { useRouter, useSearchParams } from 'next/navigation'
import useAuthStore from '../../../store/authStore'
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
import { AdminAPI } from '../../../utils/api'
type PoolUser = {
id: string
name: string
email: string
contributed: number
joinedAt: string // NEW: member since
}
function PoolManagePageInner() {
const router = useRouter()
const searchParams = useSearchParams()
const user = useAuthStore(s => s.user)
const token = useAuthStore(s => s.accessToken)
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin'))
)
// Auth gate
const [authChecked, setAuthChecked] = React.useState(false)
React.useEffect(() => {
if (user === null) {
router.replace('/login')
return
}
if (user && !isAdmin) {
router.replace('/')
return
}
setAuthChecked(true)
}, [user, isAdmin, router])
// Read pool data from query params with fallbacks (hooks must be before any return)
const poolId = searchParams.get('id') ?? 'pool-unknown'
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
const poolDescription = searchParams.get('description') ?? ''
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
const poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
const poolIsActive = searchParams.get('is_active') === 'true'
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
// Members (no dummy data)
const [users, setUsers] = React.useState<PoolUser[]>([])
const [membersLoading, setMembersLoading] = React.useState(false)
const [membersError, setMembersError] = React.useState<string>('')
// Stats (no dummy data)
const [totalAmount, setTotalAmount] = React.useState<number>(0)
const [amountThisYear, setAmountThisYear] = React.useState<number>(0)
const [amountThisMonth, setAmountThisMonth] = React.useState<number>(0)
// Search modal state
const [searchOpen, setSearchOpen] = React.useState(false)
const [query, setQuery] = React.useState('')
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState<string>('')
const [candidates, setCandidates] = React.useState<Array<{ id: string; name: string; email: string }>>([])
const [hasSearched, setHasSearched] = React.useState(false)
const [selectedCandidates, setSelectedCandidates] = React.useState<Set<string>>(new Set())
const [savingMembers, setSavingMembers] = React.useState(false)
async function fetchMembers() {
if (!token || !poolId || poolId === 'pool-unknown') return
setMembersError('')
setMembersLoading(true)
try {
const resp = await AdminAPI.getPoolMembers(token, poolId)
const rows = Array.isArray(resp?.members) ? resp.members : []
const mapped: PoolUser[] = rows.map((row: any) => {
const name = row.company_name
? String(row.company_name)
: [row.first_name, row.last_name].filter(Boolean).join(' ').trim()
return {
id: String(row.id),
name: name || String(row.email || '').trim() || 'Unnamed user',
email: String(row.email || '').trim(),
contributed: 0,
joinedAt: row.joined_at || new Date().toISOString()
}
})
setUsers(mapped)
} catch (e: any) {
setMembersError(e?.message || 'Failed to load pool members.')
} finally {
setMembersLoading(false)
}
}
React.useEffect(() => {
void fetchMembers()
}, [token, poolId])
// Early return AFTER all hooks are declared to keep consistent order
if (!authChecked) return null
async function doSearch() {
setError('')
const q = query.trim().toLowerCase()
if (q.length < 3) {
setHasSearched(false)
setCandidates([])
return
}
if (!token) {
setError('Authentication required.')
setHasSearched(true)
setCandidates([])
return
}
setHasSearched(true)
setLoading(true)
try {
const resp = await AdminAPI.getUserList(token)
const list = Array.isArray(resp?.users) ? resp.users : []
const existingIds = new Set(users.map(u => String(u.id)))
const mapped: Array<{ id: string; name: string; email: string }> = list
.filter((u: any) => u && u.role !== 'admin' && u.role !== 'super_admin')
.map((u: any) => {
const name = u.company_name
? String(u.company_name)
: [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
return {
id: String(u.id),
name: name || String(u.email || '').trim() || 'Unnamed user',
email: String(u.email || '').trim()
}
})
.filter((u: { id: string; name: string; email: string }) => !existingIds.has(u.id))
.filter((u: { id: string; name: string; email: string }) => {
const hay = `${u.name} ${u.email}`.toLowerCase()
return hay.includes(q)
})
setCandidates(mapped)
} catch (e: any) {
setError(e?.message || 'Failed to search users.')
setCandidates([])
} finally {
setLoading(false)
}
}
async function addUserFromModal(u: { id: string; name: string; email: string }) {
if (!token || !poolId || poolId === 'pool-unknown') return
setSavingMembers(true)
setError('')
try {
await AdminAPI.addPoolMembers(token, poolId, [u.id])
await fetchMembers()
setSearchOpen(false)
setQuery('')
setCandidates([])
setHasSearched(false)
setSelectedCandidates(new Set())
} catch (e: any) {
setError(e?.message || 'Failed to add user.')
} finally {
setLoading(false)
setSavingMembers(false)
}
}
function toggleCandidate(id: string) {
setSelectedCandidates(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function addSelectedUsers() {
if (selectedCandidates.size === 0) return
const selectedList = candidates.filter(c => selectedCandidates.has(c.id))
if (selectedList.length === 0) return
if (!token || !poolId || poolId === 'pool-unknown') return
setSavingMembers(true)
setError('')
try {
const userIds = selectedList.map(u => u.id)
await AdminAPI.addPoolMembers(token, poolId, userIds)
await fetchMembers()
setSearchOpen(false)
setQuery('')
setCandidates([])
setHasSearched(false)
setSelectedCandidates(new Set())
} catch (e: any) {
setError(e?.message || 'Failed to add users.')
} finally {
setLoading(false)
setSavingMembers(false)
}
}
return (
<PageTransitionEffect>
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
<Header />
{/* main wrapper: avoid high z-index stacking */}
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
<div className="max-w-7xl mx-auto relative z-0">
{/* Header (remove sticky/z-10) */}
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
<UsersIcon className="h-5 w-5 text-blue-900" />
</div>
<div>
<h1 className="text-3xl font-extrabold text-blue-900 tracking-tight">{poolName}</h1>
<p className="text-sm text-blue-700">
{poolDescription ? poolDescription : 'Manage users and track pool funds'}
</p>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${!poolIsActive ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!poolIsActive ? 'bg-gray-400' : 'bg-green-500'}`} />
{!poolIsActive ? 'Inactive' : 'Active'}
</span>
<span></span>
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
<span></span>
<span className="text-gray-500">ID: {poolId}</span>
</div>
</div>
</div>
{/* Back to Pool Management */}
<button
onClick={() => router.push('/admin/pool-management')}
className="inline-flex items-center gap-2 rounded-lg bg-white text-blue-900 border border-blue-200 px-4 py-2 text-sm font-medium hover:bg-blue-50 transition"
title="Back to Pool Management"
>
Back
</button>
</div>
</header>
{/* Stats (now zero until backend wired) */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-900 p-2">
<BanknotesIcon className="h-5 w-5 text-white" />
</div>
<div>
<p className="text-sm text-gray-600">Total in Pool</p>
<p className="text-2xl font-semibold text-gray-900"> {totalAmount.toLocaleString()}</p>
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
<div className="flex items-center gap-3">
<div className="rounded-md bg-amber-600 p-2">
<CalendarDaysIcon className="h-5 w-5 text-white" />
</div>
<div>
<p className="text-sm text-gray-600">This Year</p>
<p className="text-2xl font-semibold text-gray-900"> {amountThisYear.toLocaleString()}</p>
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
<div className="flex items-center gap-3">
<div className="rounded-md bg-green-600 p-2">
<CalendarDaysIcon className="h-5 w-5 text-white" />
</div>
<div>
<p className="text-sm text-gray-600">Current Month</p>
<p className="text-2xl font-semibold text-gray-900"> {amountThisMonth.toLocaleString()}</p>
</div>
</div>
</div>
</div>
{/* Unified Members card: add button + list */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
<button
onClick={() => { setSearchOpen(true); setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
>
<PlusIcon className="h-5 w-5" />
Add User
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{users.map(u => (
<article key={u.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
<UsersIcon className="h-5 w-5 text-blue-900" />
</div>
<div>
<h3 className="text-sm font-semibold text-blue-900">{u.name}</h3>
<p className="text-xs text-gray-600">{u.email}</p>
</div>
</div>
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2 py-0.5 text-xs text-blue-900">
{u.contributed.toLocaleString()}
</span>
</div>
<div className="mt-3 text-xs text-gray-600">
Member since:{' '}
<span className="font-medium text-gray-900">
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
</span>
</div>
</article>
))}
{membersLoading && (
<div className="col-span-full text-center text-gray-500 italic py-6">
Loading members...
</div>
)}
{membersError && !membersLoading && (
<div className="col-span-full text-center text-red-600 py-6">
{membersError}
</div>
)}
{users.length === 0 && !membersLoading && !membersError && (
<div className="col-span-full text-center text-gray-500 italic py-6">
No users in this pool yet.
</div>
)}
</div>
</div>
</div>
</main>
<Footer />
{/* Search Modal (keep above with high z) */}
{searchOpen && (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
<div className="w-full max-w-2xl rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
{/* Header */}
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
<h4 className="text-lg font-semibold text-blue-900">Add user to pool</h4>
<button
onClick={() => setSearchOpen(false)}
className="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
{/* Form */}
<form
onSubmit={e => { e.preventDefault(); void doSearch(); }}
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-gray-100"
>
<div className="md:col-span-3">
<div className="relative">
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search name or email…"
className="w-full rounded-md bg-gray-50 border border-gray-300 text-sm text-gray-900 placeholder-gray-400 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent transition"
/>
</div>
</div>
<div className="flex gap-2 md:col-span-2">
<button
type="submit"
disabled={loading || query.trim().length < 3}
className="flex-1 rounded-md bg-blue-900 hover:bg-blue-800 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
>
{loading ? 'Searching…' : 'Search'}
</button>
<button
type="button"
onClick={() => { setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
>
Clear
</button>
</div>
</form>
<div className="px-6 pt-1 pb-3 text-right text-xs text-gray-500">
Min. 3 characters
</div>
{/* Results */}
<div className="px-6 py-4">
{error && <div className="text-sm text-red-600 mb-3">{error}</div>}
{!error && query.trim().length < 3 && (
<div className="py-8 text-sm text-gray-500 text-center">
Enter at least 3 characters and click Search.
</div>
)}
{!error && hasSearched && loading && candidates.length === 0 && (
<ul className="space-y-0 divide-y divide-gray-200 border border-gray-200 rounded-md bg-gray-50">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i} className="animate-pulse px-4 py-3">
<div className="h-3.5 w-36 bg-gray-200 rounded" />
<div className="mt-2 h-3 w-56 bg-gray-100 rounded" />
</li>
))}
</ul>
)}
{!error && hasSearched && !loading && candidates.length === 0 && (
<div className="py-8 text-sm text-gray-500 text-center">
No users match your search.
</div>
)}
{!error && candidates.length > 0 && (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
{candidates.map(u => (
<li key={u.id} className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-gray-50 transition">
<label className="min-w-0 flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900"
checked={selectedCandidates.has(u.id)}
onChange={() => toggleCandidate(u.id)}
/>
<div className="min-w-0">
<div className="flex items-center gap-2">
<UsersIcon className="h-4 w-4 text-blue-900" />
<span className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{u.name}</span>
</div>
<div className="mt-0.5 text-[11px] text-gray-600 break-all">{u.email}</div>
</div>
</label>
<button
onClick={() => addUserFromModal(u)}
className="shrink-0 inline-flex items-center rounded-md bg-blue-900 hover:bg-blue-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
>
Add
</button>
</li>
))}
</ul>
)}
{loading && candidates.length > 0 && (
<div className="pointer-events-none relative">
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-gray-100 flex items-center justify-between bg-gray-50">
<div className="text-xs text-gray-600">
{selectedCandidates.size > 0 ? `${selectedCandidates.size} selected` : 'No users selected'}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSearchOpen(false)}
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 transition"
>
Done
</button>
<button
onClick={addSelectedUsers}
disabled={selectedCandidates.size === 0 || savingMembers}
className="text-sm rounded-md px-4 py-2 font-medium bg-blue-900 text-white hover:bg-blue-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{savingMembers ? 'Adding…' : 'Add Selected'}
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</PageTransitionEffect>
)
}
// CHANGED: Suspense wrapper required for useSearchParams() during prerender
export default function PoolManagePage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#0F172A] mx-auto mb-3" />
<p className="text-[#4A4A4A]">Loading...</p>
</div>
</div>
}
>
<PoolManagePageInner />
</Suspense>
)
}

View File

@ -0,0 +1,294 @@
'use client'
import React from 'react'
import Header from '../../components/nav/Header'
import Footer from '../../components/Footer'
import { UsersIcon } from '@heroicons/react/24/outline'
import { useAdminPools } from './hooks/getlist'
import useAuthStore from '../../store/authStore'
import { addPool } from './hooks/addPool'
import { useRouter } from 'next/navigation'
import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import CreateNewPoolModal from './components/createNewPoolModal'
type Pool = {
id: string
pool_name: string
description?: string
price?: number
pool_type?: 'coffee' | 'other'
is_active?: boolean
membersCount: number
createdAt: string
}
export default function PoolManagementPage() {
const router = useRouter()
// Modal state
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState<string>('')
const [createSuccess, setCreateSuccess] = React.useState<string>('')
const [createModalOpen, setCreateModalOpen] = React.useState(false)
const [archiveError, setArchiveError] = React.useState<string>('')
// Token and API URL
const token = useAuthStore.getState().accessToken
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
// Replace local fetch with hook
const { pools: initialPools, loading, error, refresh } = useAdminPools()
const [pools, setPools] = React.useState<Pool[]>([])
const [showInactive, setShowInactive] = React.useState(false)
React.useEffect(() => {
if (!loading && !error) {
setPools(initialPools)
}
}, [initialPools, loading, error])
const filteredPools = pools.filter(p => showInactive ? !p.is_active : p.is_active)
// REPLACED: handleCreatePool to accept data from modal with new schema fields
async function handleCreatePool(data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other' }) {
setCreateError('')
setCreateSuccess('')
const pool_name = data.pool_name.trim()
const description = data.description.trim()
if (!pool_name) {
setCreateError('Please provide a pool name.')
return
}
setCreating(true)
try {
const res = await addPool({ pool_name, description: description || undefined, price: data.price, pool_type: data.pool_type, is_active: true })
if (res.ok && res.body?.data) {
setCreateSuccess('Pool created successfully.')
await refresh?.()
setTimeout(() => {
setCreateModalOpen(false)
setCreateSuccess('')
}, 1500)
} else {
setCreateError(res.message || 'Failed to create pool.')
}
} catch {
setCreateError('Network error while creating pool.')
} finally {
setCreating(false)
}
}
async function handleArchive(poolId: string) {
setArchiveError('')
const res = await setPoolInactive(poolId)
if (res.ok) {
await refresh?.()
} else {
setArchiveError(res.message || 'Failed to deactivate pool.')
}
}
async function handleSetActive(poolId: string) {
setArchiveError('')
const res = await setPoolActive(poolId)
if (res.ok) {
await refresh?.()
} else {
setArchiveError(res.message || 'Failed to activate pool.')
}
}
const user = useAuthStore(s => s.user)
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin'))
)
// NEW: block rendering until we decide access
const [authChecked, setAuthChecked] = React.useState(false)
React.useEffect(() => {
// When user is null -> unauthenticated; undefined means not loaded yet (store default may be null in this app).
if (user === null) {
router.replace('/login')
return
}
if (user && !isAdmin) {
router.replace('/')
return
}
// user exists and is admin
setAuthChecked(true)
}, [user, isAdmin, router])
// Early return: render nothing until authorized, prevents any flash
if (!authChecked) return null
// Remove Access Denied overlay; render normal content
return (
<PageTransitionEffect>
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
<Header />
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
<div className="max-w-7xl mx-auto relative z-0">
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8 relative z-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Pool Management</h1>
<p className="text-lg text-blue-700 mt-2">Create and manage user pools.</p>
</div>
<button
onClick={() => { setCreateModalOpen(true); createError && setCreateError(''); }}
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
>
Create New Pool
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Show:</span>
<button
onClick={() => setShowInactive(false)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition ${!showInactive ? 'bg-blue-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
>
Active Pools
</button>
<button
onClick={() => setShowInactive(true)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition ${showInactive ? 'bg-blue-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
>
Inactive Pools
</button>
</div>
</header>
{/* Pools List card */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-blue-900">Existing Pools</h2>
<span className="text-sm text-gray-600">{pools.length} total</span>
</div>
{/* Show archive errors */}
{archiveError && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{archiveError}
</div>
)}
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-white border border-gray-100 shadow p-5">
<div className="animate-pulse space-y-3">
<div className="h-5 w-1/2 bg-gray-200 rounded" />
<div className="h-4 w-3/4 bg-gray-200 rounded" />
<div className="h-4 w-2/3 bg-gray-100 rounded" />
<div className="h-8 w-full bg-gray-100 rounded" />
</div>
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPools.map(pool => (
<article key={pool.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col relative z-0">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
<UsersIcon className="h-5 w-5 text-blue-900" />
</div>
<h3 className="text-lg font-semibold text-blue-900">{pool.pool_name}</h3>
</div>
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${!pool.is_active ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!pool.is_active ? 'bg-gray-400' : 'bg-green-500'}`} />
{!pool.is_active ? 'Inactive' : 'Active'}
</span>
</div>
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
<div>
<span className="text-gray-500">Members</span>
<div className="font-medium text-gray-900">{pool.membersCount}</div>
</div>
<div>
<span className="text-gray-500">Created</span>
<div className="font-medium text-gray-900">
{new Date(pool.createdAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
</div>
</div>
</div>
<div className="mt-5 flex items-center justify-between">
<button
className="px-4 py-2 text-xs font-medium rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
onClick={() => {
const params = new URLSearchParams({
id: String(pool.id),
pool_name: pool.pool_name ?? '',
description: pool.description ?? '',
price: String(pool.price ?? 0),
pool_type: pool.pool_type ?? 'other',
is_active: pool.is_active ? 'true' : 'false',
createdAt: pool.createdAt ?? '',
})
router.push(`/admin/pool-management/manage?${params.toString()}`)
}}
>
Manage
</button>
{!pool.is_active ? (
<button
className="px-4 py-2 text-xs font-medium rounded-lg bg-green-100 text-green-800 hover:bg-green-200 transition"
onClick={() => handleSetActive(pool.id)}
title="Activate this pool"
>
Set Active
</button>
) : (
<button
className="px-4 py-2 text-xs font-medium rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition"
onClick={() => handleArchive(pool.id)}
title="Archive this pool"
>
Archive
</button>
)}
</div>
</article>
))}
{filteredPools.length === 0 && !loading && !error && (
<div className="col-span-full text-center text-gray-500 italic py-6">
{showInactive ? 'No inactive pools found.' : 'No active pools found.'}
</div>
)}
</div>
)}
</div>
</div>
</main>
{/* Modal for creating a new pool */}
<CreateNewPoolModal
isOpen={createModalOpen}
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
onCreate={handleCreatePool}
creating={creating}
error={createError}
success={createSuccess}
clearMessages={() => { setCreateError(''); setCreateSuccess(''); }}
/>
<Footer />
</div>
</PageTransitionEffect>
)
}

View File

@ -0,0 +1,132 @@
'use client'
import React, { useState, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import { Point, Area } from 'react-easy-crop'
interface ImageCropModalProps {
isOpen: boolean
imageSrc: string
onClose: () => void
onCropComplete: (croppedImageBlob: Blob) => void
}
export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComplete }: ImageCropModalProps) {
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const onCropAreaComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels)
}, [])
const createCroppedImage = async () => {
if (!croppedAreaPixels) return
const image = new Image()
image.src = imageSrc
await new Promise((resolve) => {
image.onload = resolve
})
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas size to cropped area
canvas.width = croppedAreaPixels.width
canvas.height = croppedAreaPixels.height
ctx.drawImage(
image,
croppedAreaPixels.x,
croppedAreaPixels.y,
croppedAreaPixels.width,
croppedAreaPixels.height,
0,
0,
croppedAreaPixels.width,
croppedAreaPixels.height
)
return new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob)
}, 'image/jpeg', 0.95)
})
}
const handleSave = async () => {
const croppedBlob = await createCroppedImage()
if (croppedBlob) {
onCropComplete(croppedBlob)
onClose()
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
<h2 className="text-xl font-semibold text-blue-900">Crop & Adjust Image</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 transition"
aria-label="Close"
>
</button>
</div>
{/* Crop Area */}
<div className="relative bg-gray-900" style={{ height: '500px' }}>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={16 / 9}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropAreaComplete}
/>
</div>
{/* Controls */}
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">
Zoom: {zoom.toFixed(1)}x
</label>
<input
type="range"
min={1}
max={3}
step={0.1}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="w-full h-2 bg-blue-200 rounded-lg appearance-none cursor-pointer accent-blue-900"
/>
</div>
<div className="flex items-center justify-end gap-3">
<button
onClick={onClose}
className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
>
Apply Crop
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,289 @@
"use client";
import React, { useEffect, useMemo, useState } from 'react';
import PageLayout from '../../../components/PageLayout';
import useCoffeeManagement from '../hooks/useCoffeeManagement';
import { PhotoIcon } from '@heroicons/react/24/solid';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import ImageCropModal from '../components/ImageCropModal';
export default function CreateSubscriptionPage() {
const { createProduct } = useCoffeeManagement();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
// form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('0.00');
const [state, setState] = useState<'available'|'unavailable'>('available');
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [showCropModal, setShowCropModal] = useState(false);
const [currency, setCurrency] = useState('EUR');
const [isFeatured, setIsFeatured] = useState(false);
// Fixed billing defaults (locked: month / 1)
const billingInterval: 'month' = 'month';
const intervalCount: number = 1;
const onCreate = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await createProduct({
title,
description,
price: parseFloat(price),
currency,
is_featured: isFeatured,
state: state === 'available',
pictureFile
});
router.push('/admin/subscriptions');
} catch (e: any) {
setError(e.message || 'Failed to create');
}
};
// Cleanup object URLs
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
};
}, []);
function handleSelectFile(file?: File) {
if (!file) return;
const allowed = ['image/jpeg','image/png','image/webp'];
if (!allowed.includes(file.type)) {
setError('Invalid image type. Allowed: JPG, PNG, WebP');
return;
}
if (file.size > 10 * 1024 * 1024) { // 10MB
setError('Image exceeds 10MB limit');
return;
}
setError(null);
// Create object URL for cropping
const url = URL.createObjectURL(file);
setOriginalImageSrc(url);
setShowCropModal(true);
}
function handleCropComplete(croppedBlob: Blob) {
// Convert blob to file
const croppedFile = new File([croppedBlob], 'cropped-image.jpg', { type: 'image/jpeg' });
setPictureFile(croppedFile);
// Create preview URL
const url = URL.createObjectURL(croppedBlob);
setPreviewUrl(url);
}
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Create Coffee</h1>
<p className="text-lg text-blue-700 mt-2">Add a new coffee.</p>
</div>
<Link href="/admin/subscriptions"
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>
Back to list
</Link>
</div>
</header>
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg">
<form onSubmit={onCreate} className="space-y-8">
{/* Picture Upload moved to top */}
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Picture</label>
<p className="text-xs text-gray-600 mb-3">Upload an image and crop it to fit the coffee thumbnail (16:9 aspect ratio, 144px height)</p>
<div
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
style={{ minHeight: '400px' }}
onClick={() => document.getElementById('file-upload')?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => {
e.preventDefault();
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
}}
>
{!previewUrl && (
<div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" />
<div className="mt-4 text-base font-medium text-blue-700">
<span>Click or drag and drop an image here</span>
</div>
<p className="text-sm text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
<p className="text-xs text-gray-500 mt-2">You'll be able to crop and adjust the image after uploading</p>
</div>
)}
{previewUrl && (
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6">
<img
src={previewUrl}
alt="Preview"
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg"
/>
<div className="absolute top-4 right-4 flex gap-2">
<button
type="button"
onClick={e => {
e.stopPropagation();
setShowCropModal(true);
}}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-blue-900 shadow hover:bg-white transition"
>
Edit Crop
</button>
<button
type="button"
onClick={e => {
e.stopPropagation();
setPictureFile(undefined);
setPreviewUrl(null);
}}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
>
Remove
</button>
</div>
</div>
)}
<input
id="file-upload"
name="file-upload"
type="file"
accept="image/*"
className="hidden"
onChange={e => handleSelectFile(e.target.files?.[0])}
/>
</div>
</div>
{/* Title moved above description */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-blue-900">Title</label>
<input
id="title"
name="title"
required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
placeholder="Title"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
{/* Description now after title */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-blue-900">Description</label>
<textarea
id="description"
name="description"
required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
rows={3}
placeholder="Describe the product"
value={description}
onChange={e => setDescription(e.target.value)}
/>
<p className="mt-1 text-xs text-gray-600">Shown to users in the shop and checkout.</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Price */}
<div>
<label htmlFor="price" className="block text-sm font-medium text-blue-900">Price</label>
<input
id="price"
name="price"
required
min={0.01}
step={0.01}
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
placeholder="0.00"
type="number"
value={price}
onChange={e => {
const val = e.target.value;
setPrice(val);
}}
onBlur={e => {
const num = parseFloat(e.target.value);
if (!isNaN(num)) {
setPrice(num.toFixed(2));
}
}}
/>
</div>
{/* Currency */}
<div>
<label htmlFor="currency" className="block text-sm font-medium text-blue-900">Currency (e.g., EUR)</label>
<input id="currency" name="currency" required maxLength={3} pattern="[A-Za-z]{3}" className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400" placeholder="EUR" value={currency} onChange={e => setCurrency(e.target.value.toUpperCase().slice(0,3))} />
</div>
{/* Featured */}
<div className="flex items-center gap-2 mt-6">
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
<label htmlFor="featured" className="text-sm font-medium text-blue-900">Featured</label>
</div>
{/* Subscription Billing (Locked) + Availability */}
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-blue-900">Subscription Billing</label>
<p className="mt-1 text-xs text-gray-600">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p>
<div className="mt-2 flex gap-4">
<input disabled value={billingInterval} className="w-40 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
<input disabled value={intervalCount} className="w-24 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
</div>
</div>
<div>
<label htmlFor="availability" className="block text-sm font-medium text-blue-900">Availability</label>
<select id="availability" name="availability" required className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black" value={state} onChange={e => setState(e.target.value as any)}>
<option value="available">Available</option>
<option value="unavailable">Unavailable</option>
</select>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-x-4">
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
Cancel
</Link>
<button type="submit" className="inline-flex justify-center rounded-lg bg-blue-900 px-5 py-3 text-sm font-semibold text-blue-50 shadow hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900 transition">
Create Coffee
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</form>
</div>
</main>
</div>
{/* Image Crop Modal */}
{originalImageSrc && (
<ImageCropModal
isOpen={showCropModal}
imageSrc={originalImageSrc}
onClose={() => setShowCropModal(false)}
onCropComplete={handleCropComplete}
/>
)}
</PageLayout>
);
}

View File

@ -0,0 +1,289 @@
"use client";
import React, { useEffect, useState, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import PageLayout from '../../../../components/PageLayout';
import useCoffeeManagement, { CoffeeItem } from '../../hooks/useCoffeeManagement';
import { PhotoIcon } from '@heroicons/react/24/solid';
export default function EditSubscriptionPage() {
const router = useRouter();
// next/navigation app router dynamic param
const params = useParams();
const idParam = params?.id;
const id = typeof idParam === 'string' ? parseInt(idParam, 10) : Array.isArray(idParam) ? parseInt(idParam[0], 10) : NaN;
const { listProducts, updateProduct } = useCoffeeManagement();
const [item, setItem] = useState<CoffeeItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState<string>('');
const [currency, setCurrency] = useState('EUR');
const [isFeatured, setIsFeatured] = useState(false);
const [state, setState] = useState(true);
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [removeExistingPicture, setRemoveExistingPicture] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
let active = true;
async function load() {
if (!id || Number.isNaN(id)) {
setError('Invalid subscription id');
setLoading(false);
return;
}
try {
const all = await listProducts();
const found = all.find((p: CoffeeItem) => p.id === id) || null;
if (!active) return;
if (!found) {
setError('Subscription not found');
} else {
setItem(found);
setTitle(found.title || '');
setDescription(found.description || '');
setPrice(found.price != null ? String(found.price) : '');
setCurrency(found.currency || 'EUR');
setIsFeatured(!!found.is_featured);
setState(!!found.state);
setRemoveExistingPicture(false);
}
} catch (e: any) {
if (active) setError(e?.message ?? 'Failed to load subscription');
} finally {
if (active) setLoading(false);
}
}
load();
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!item) return;
setError(null);
try {
const numericPrice = Number(price);
if (!Number.isFinite(numericPrice) || numericPrice < 0) {
setError('Price must be a valid non-negative number');
return;
}
await updateProduct(item.id, {
title: title.trim(),
description: description.trim(),
price: numericPrice,
currency: currency.trim(),
is_featured: isFeatured,
state,
pictureFile,
removePicture: removeExistingPicture && !pictureFile ? true : false,
});
router.push('/admin/subscriptions');
} catch (e: any) {
setError(e?.message ?? 'Update failed');
}
}
useEffect(() => {
if (pictureFile) {
const url = URL.createObjectURL(pictureFile);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
} else {
setPreviewUrl(null);
}
}, [pictureFile]);
function handleSelectFile(file?: File) {
if (!file) return;
const allowed = ['image/jpeg','image/png','image/webp'];
if (!allowed.includes(file.type)) {
setError('Invalid image type. Allowed: JPG, PNG, WebP');
return;
}
if (file.size > 10 * 1024 * 1024) {
setError('Image exceeds 10MB limit');
return;
}
setError(null);
setPictureFile(file);
setRemoveExistingPicture(false); // selecting new overrides removal flag
}
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Edit Coffee</h1>
<p className="text-lg text-blue-700 mt-2">Update details of the coffee.</p>
</div>
<Link href="/admin/subscriptions"
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>
Back to list
</Link>
</div>
</header>
{loading && (
<div className="rounded-md bg-blue-50 p-4 text-blue-700 text-sm mb-6">Loading subscription</div>
)}
{error && !loading && (
<div className="rounded-md bg-red-50 p-4 text-red-700 text-sm mb-6">{error}</div>
)}
{!loading && item && (
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg">
<form onSubmit={handleSubmit} className="space-y-8">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-blue-900">Title</label>
<input
required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900">Price</label>
<input
type="number"
min={0}
step={0.01}
required
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
value={price}
onChange={e => setPrice(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900">Currency</label>
<input
required
maxLength={3}
pattern="[A-Za-z]{3}"
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
value={currency}
onChange={e => setCurrency(e.target.value.toUpperCase().slice(0,3))}
/>
</div>
<div className="flex items-center gap-4 mt-6">
<div className="flex items-center gap-2">
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
<label htmlFor="featured" className="text-sm font-medium text-blue-900">Featured</label>
</div>
<div className="flex items-center gap-2">
<input id="enabled" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={state} onChange={e => setState(e.target.checked)} />
<label htmlFor="enabled" className="text-sm font-medium text-blue-900">Enabled</label>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-blue-900">Description</label>
<textarea
required
rows={4}
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">Picture (optional)</label>
<p className="text-xs text-gray-600 mb-3">Upload an image to replace the current picture (16:9 aspect ratio recommended)</p>
<div
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
style={{ minHeight: '400px' }}
onClick={() => fileInputRef.current?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => {
e.preventDefault();
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
}}
>
{!previewUrl && !item.pictureUrl && (
<div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" />
<div className="mt-4 text-base font-medium text-blue-700">
<span>Click or drag and drop a new image here</span>
</div>
<p className="text-sm text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
</div>
)}
{(previewUrl || (!removeExistingPicture && item.pictureUrl)) && (
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6">
<img
src={previewUrl || item.pictureUrl || ''}
alt={previewUrl ? "Preview" : item.title}
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg"
/>
<div className="absolute top-4 right-4">
<button
type="button"
onClick={e => {
e.stopPropagation();
if (previewUrl) {
setPictureFile(undefined);
setPreviewUrl(null);
} else if (item.pictureUrl) {
setRemoveExistingPicture(true);
}
}}
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
>
Remove
</button>
</div>
</div>
)}
{removeExistingPicture && !previewUrl && (
<div className="text-center w-full px-6 py-10">
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-gray-400" />
<div className="mt-4 text-base font-medium text-gray-600">
<span>Image removed - Click to upload a new one</span>
</div>
<p className="text-sm text-gray-500 mt-2">PNG, JPG, WebP up to 10MB</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => handleSelectFile(e.target.files?.[0])}
/>
</div>
</div>
<div className="flex items-center justify-end gap-x-4">
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
Cancel
</Link>
<button type="submit" className="inline-flex justify-center rounded-lg bg-blue-900 px-5 py-3 text-sm font-semibold text-blue-50 shadow hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900 transition">
Save Changes
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</form>
</div>
)}
</main>
</div>
</PageLayout>
);
}

View File

@ -0,0 +1,146 @@
import { useCallback } from 'react';
import useAuthStore from '../../../store/authStore';
export type CoffeeItem = {
id: number;
title: string;
description: string;
price: number;
currency?: string;
is_featured?: boolean;
billing_interval?: 'day'|'week'|'month'|'year'|null;
interval_count?: number|null;
object_storage_id?: string|null;
original_filename?: string|null;
state: boolean;
pictureUrl?: string | null;
created_at?: string;
updated_at?: string;
};
function isFormData(body: any): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData;
}
export default function useCoffeeManagement() {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
const getState = useAuthStore.getState;
const authorizedFetch = useCallback(
async <T = any>(
path: string,
init: RequestInit = {},
responseType: 'json' | 'text' | 'blob' = 'json'
): Promise<T> => {
let token = getState().accessToken;
if (!token) {
const ok = await getState().refreshAuthToken();
if (ok) token = getState().accessToken;
}
const headers: Record<string, string> = {
...(init.headers as Record<string, string> || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
if (!isFormData(init.body) && init.method && init.method !== 'GET') {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
const res = await fetch(`${base}${path}`, {
credentials: 'include',
...init,
headers,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
if (responseType === 'blob') return (await res.blob()) as unknown as T;
if (responseType === 'text') return (await res.text()) as unknown as T;
const text = await res.text();
try { return JSON.parse(text) as T; } catch { return {} as T; }
},
[base]
);
const listProducts = useCallback(async (): Promise<CoffeeItem[]> => {
const data = await authorizedFetch<any[]>('/api/admin/coffee', { method: 'GET' });
if (!Array.isArray(data)) return [];
return data.map((r: any) => ({
...r,
id: Number(r.id),
price: r.price != null && r.price !== '' ? Number(r.price) : 0,
interval_count: r.interval_count != null && r.interval_count !== '' ? Number(r.interval_count) : null,
state: !!r.state,
})) as CoffeeItem[];
}, [authorizedFetch]);
const createProduct = useCallback(async (payload: {
title: string;
description: string;
price: number;
currency?: string;
is_featured?: boolean;
state?: boolean;
pictureFile?: File;
}): Promise<CoffeeItem> => {
const fd = new FormData();
fd.append('title', payload.title);
fd.append('description', payload.description);
fd.append('price', String(payload.price));
if (payload.currency) fd.append('currency', payload.currency);
if (typeof payload.is_featured === 'boolean') fd.append('is_featured', String(payload.is_featured));
if (typeof payload.state === 'boolean') fd.append('state', String(payload.state));
// Fixed billing defaults
fd.append('billing_interval', 'month');
fd.append('interval_count', '1');
if (payload.pictureFile) fd.append('picture', payload.pictureFile);
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: fd });
}, [authorizedFetch]);
const updateProduct = useCallback(async (id: number, payload: Partial<{
title: string;
description: string;
price: number;
currency: string;
is_featured: boolean;
state: boolean;
pictureFile: File;
removePicture: boolean;
}>): Promise<CoffeeItem> => {
const fd = new FormData();
if (payload.title !== undefined) fd.append('title', String(payload.title));
if (payload.description !== undefined) fd.append('description', String(payload.description));
if (payload.price !== undefined) fd.append('price', String(payload.price));
if (payload.currency !== undefined) fd.append('currency', payload.currency);
if (payload.is_featured !== undefined) fd.append('is_featured', String(payload.is_featured));
if (payload.state !== undefined) fd.append('state', String(payload.state));
if (payload.removePicture) fd.append('removePicture', 'true');
// Keep fixed defaults
fd.append('billing_interval', 'month');
fd.append('interval_count', '1');
if (payload.pictureFile) fd.append('picture', payload.pictureFile);
return authorizedFetch<CoffeeItem>(`/api/admin/coffee/${id}`, { method: 'PUT', body: fd });
}, [authorizedFetch]);
const setProductState = useCallback(async (id: number, state: boolean): Promise<CoffeeItem> => {
return authorizedFetch<CoffeeItem>(`/api/admin/coffee/${id}/state`, {
method: 'PATCH',
body: JSON.stringify({ state })
});
}, [authorizedFetch]);
const deleteProduct = useCallback(async (id: number): Promise<{success?: boolean}> => {
return authorizedFetch<{success?: boolean}>(`/api/admin/coffee/${id}`, { method: 'DELETE' });
}, [authorizedFetch]);
return {
listProducts,
createProduct,
updateProduct,
setProductState,
deleteProduct,
};
}

View File

@ -0,0 +1,158 @@
"use client";
import React, { useEffect, useState } from 'react';
import { PhotoIcon } from '@heroicons/react/24/solid';
import Link from 'next/link';
import PageLayout from '../../components/PageLayout';
import useCoffeeManagement, { CoffeeItem } from './hooks/useCoffeeManagement';
export default function AdminSubscriptionsPage() {
const { listProducts, setProductState, deleteProduct } = useCoffeeManagement();
const [items, setItems] = useState<CoffeeItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
setError(null);
try {
const data = await listProducts();
setItems(Array.isArray(data) ? data : []);
} catch (e: any) {
setError(e?.message ?? 'Failed to load products');
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const availabilityBadge = (avail: boolean) => (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${avail ? 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200' : 'bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300'}`}>
{avail ? 'Available' : 'Unavailable'}
</span>
);
const [deleteTarget, setDeleteTarget] = useState<CoffeeItem | null>(null);
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-6 px-6 rounded-2xl shadow-lg mb-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Coffees</h1>
<p className="text-lg text-blue-700 mt-2">Manage all coffees.</p>
</div>
<Link
href="/admin/subscriptions/createSubscription"
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition self-start sm:self-auto"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>
Create Coffee
</Link>
</div>
</header>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{error}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{loading && (
<div className="col-span-full text-sm text-gray-700">Loading</div>
)}
{!loading && items.map(item => (
<div key={item.id} className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 flex flex-col gap-3 hover:shadow-xl transition">
<div className="flex items-start justify-between gap-3">
<h3 className="text-xl font-semibold text-blue-900">{item.title}</h3>
{availabilityBadge(!!item.state)}
</div>
<div className="mt-3 w-full h-40 rounded-xl ring-1 ring-gray-200 overflow-hidden flex items-center justify-center bg-gray-50">
{item.pictureUrl ? (
<img src={item.pictureUrl} alt={item.title} className="w-full h-full object-cover" />
) : (
<PhotoIcon className="w-12 h-12 text-gray-300" />
)}
</div>
<p className="mt-3 text-sm text-gray-800 line-clamp-4">{item.description}</p>
<dl className="mt-4 grid grid-cols-1 gap-y-2 text-sm">
<div>
<dt className="text-gray-500">Price</dt>
<dd className="font-medium text-gray-900">
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
</dd>
</div>
{item.billing_interval && item.interval_count ? (
<div className="text-gray-600">
<span className="text-xs">Subscription billing: {item.billing_interval} (x{item.interval_count})</span>
</div>
) : null}
</dl>
<div className="mt-4 flex gap-2">
<button
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-medium shadow transition
${item.state
? 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200 hover:bg-amber-100'
: 'bg-blue-900 text-blue-50 hover:bg-blue-800'}`}
onClick={async () => { await setProductState(item.id, !item.state); await load(); }}
>
{item.state ? 'Disable' : 'Enable'}
</button>
<Link
href={`/admin/subscriptions/edit/${item.id}`}
className="inline-flex items-center rounded-lg bg-indigo-50 px-4 py-2 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-200 hover:bg-indigo-100 shadow transition"
>
Edit
</Link>
<button
className="inline-flex items-center rounded-lg bg-red-50 px-4 py-2 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-200 hover:bg-red-100 shadow transition"
onClick={() => setDeleteTarget(item)}
>
Delete
</button>
</div>
</div>
))}
{!loading && !items.length && (
<div className="col-span-full py-8 text-center text-sm text-gray-500">No subscriptions found.</div>
)}
</div>
{/* Confirm Delete Modal */}
{deleteTarget && (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/30" onClick={() => setDeleteTarget(null)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
<div className="px-6 pt-6">
<h3 className="text-lg font-semibold text-blue-900">Delete coffee?</h3>
<p className="mt-2 text-sm text-gray-700">You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.</p>
</div>
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
<button
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
onClick={() => setDeleteTarget(null)}
>
Cancel
</button>
<button
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow"
onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }}
>
Delete
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</PageLayout>
);
}

View File

@ -0,0 +1,522 @@
'use client'
import { useMemo, useState, useEffect, useCallback } from 'react'
import PageLayout from '../../components/PageLayout'
import UserDetailModal from '../../components/UserDetailModal'
import {
MagnifyingGlassIcon,
PencilSquareIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'
import { useAdminUsers } from '../../hooks/useAdminUsers'
import { AdminAPI } from '../../utils/api'
import useAuthStore from '../../store/authStore'
type UserType = 'personal' | 'company'
type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived'
type UserRole = 'user' | 'admin'
interface User {
id: number
email: string
user_type: UserType
role: UserRole
created_at: string
last_login_at: string | null
status: string
is_admin_verified: number
first_name?: string
last_name?: string
company_name?: string
}
const STATUSES: UserStatus[] = ['active','pending','disabled','inactive']
const TYPES: UserType[] = ['personal','company']
const ROLES: UserRole[] = ['user','admin']
export default function AdminUserManagementPage() {
const { isAdmin } = useAdminUsers()
const token = useAuthStore(state => state.accessToken)
const [isClient, setIsClient] = useState(false)
// State for all users (not just pending)
const [allUsers, setAllUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Handle client-side mounting
useEffect(() => {
setIsClient(true)
}, [])
// Fetch all users from backend
const fetchAllUsers = useCallback(async () => {
if (!token || !isAdmin) return
setLoading(true)
setError(null)
try {
const response = await AdminAPI.getUserList(token)
if (response.success) {
setAllUsers(response.users || [])
} else {
throw new Error(response.message || 'Failed to fetch users')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch users'
setError(errorMessage)
console.error('AdminUserManagement.fetchAllUsers error:', err)
} finally {
setLoading(false)
}
}, [token, isAdmin])
// Load users on mount
useEffect(() => {
if (isClient && isAdmin && token) {
fetchAllUsers()
}
}, [fetchAllUsers, isClient])
// Filter hooks - must be declared before conditional returns
const [search, setSearch] = useState('')
const [fType, setFType] = useState<'all'|UserType>('all')
const [fStatus, setFStatus] = useState<'all'|UserStatus>('all')
const [fRole, setFRole] = useState<'all'|UserRole>('all')
const [page, setPage] = useState(1)
const PAGE_SIZE = 10
// Modal state
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
const filtered = useMemo(() => {
return allUsers.filter(u => {
const firstName = u.first_name || ''
const lastName = u.last_name || ''
const companyName = u.company_name || ''
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
// Use backend status directly for filtering
const allowedStatuses: UserStatus[] = ['pending','active','suspended','inactive','archived']
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
return (
(fType === 'all' || u.user_type === fType) &&
(fStatus === 'all' || userStatus === fStatus) &&
(fRole === 'all' || u.role === fRole) &&
(
!search.trim() ||
u.email.toLowerCase().includes(search.toLowerCase()) ||
fullName.toLowerCase().includes(search.toLowerCase())
)
)
})
}, [allUsers, search, fType, fStatus, fRole])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const current = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
// Move stats calculation above all conditional returns to avoid hook order errors
const stats = useMemo(() => ({
total: allUsers.length,
admins: allUsers.filter(u => u.role === 'admin').length,
personal: allUsers.filter(u => u.user_type === 'personal').length,
company: allUsers.filter(u => u.user_type === 'company').length,
active: allUsers.filter(u => u.status === 'active').length,
pending: allUsers.filter(u => u.status === 'pending').length,
}), [allUsers])
// Show loading during SSR/initial client render
if (!isClient) {
return (
<PageLayout>
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="text-center">
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-blue-900">Loading...</p>
</div>
</div>
</PageLayout>
)
}
// Access check (only after client-side hydration)
if (!isAdmin) {
return (
<PageLayout>
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
<div className="text-center">
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
<p className="text-gray-600">You need admin privileges to access this page.</p>
</div>
</div>
</div>
</PageLayout>
)
}
const applyFilter = (e: React.FormEvent) => {
e.preventDefault()
setPage(1)
}
// NEW: CSV export utilities (exports all filtered results, not only current page)
const toCsvValue = (v: unknown) => {
if (v === null || v === undefined) return '""'
const s = String(v).replace(/"/g, '""')
return `"${s}"`
}
const exportCsv = () => {
const headers = [
'ID','Email','Type','Role','Status','Admin Verified',
'First Name','Last Name','Company Name','Created At','Last Login At'
]
const rows = filtered.map(u => {
// Use backend status directly
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
return [
u.id,
u.email,
u.user_type,
u.role,
userStatus,
u.is_admin_verified === 1 ? 'yes' : 'no',
u.first_name || '',
u.last_name || '',
u.company_name || '',
new Date(u.created_at).toISOString(),
u.last_login_at ? new Date(u.last_login_at).toISOString() : ''
].map(toCsvValue).join(',')
})
const csv = [headers.join(','), ...rows].join('\r\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `users_${new Date().toISOString().slice(0,10)}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const badge = (text: string, color: 'blue'|'amber'|'green'|'gray'|'rose'|'indigo'|'purple') => {
const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide'
const map: Record<string,string> = {
blue: 'bg-blue-100 text-blue-700',
amber: 'bg-amber-100 text-amber-700',
green: 'bg-green-100 text-green-700',
gray: 'bg-gray-100 text-gray-700',
rose: 'bg-rose-100 text-rose-700',
indigo: 'bg-indigo-100 text-indigo-700',
purple: 'bg-purple-100 text-purple-700'
}
return <span className={`${base} ${map[color]}`}>{text}</span>
}
const statusBadge = (s: UserStatus) =>
s==='active' ? badge('Active','green')
: s==='pending' ? badge('Pending','amber')
: s==='suspended' ? badge('Suspended','rose')
: s==='archived' ? badge('Archived','gray')
: s==='inactive' ? badge('Inactive','gray')
: badge('Unknown','gray')
const typeBadge = (t: UserType) =>
t==='personal' ? badge('Personal','blue') : badge('Company','purple')
const roleBadge = (r: UserRole) =>
r==='admin' ? badge('Admin','indigo') : badge('User','gray')
// Action handler for opening edit modal
const onEdit = (id: string) => {
setSelectedUserId(id)
setIsDetailModalOpen(true)
}
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">User Management</h1>
<p className="text-lg text-blue-700 mt-2">
Manage all users, view statistics, and handle verification.
</p>
</div>
</header>
{/* Statistic Section + Verify Button */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center gap-6">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6 flex-1">
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Total Users</div>
<div className="text-xl font-semibold text-blue-900">{stats.total}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Admins</div>
<div className="text-xl font-semibold text-indigo-700">{stats.admins}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Personal</div>
<div className="text-xl font-semibold text-blue-700">{stats.personal}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Company</div>
<div className="text-xl font-semibold text-purple-700">{stats.company}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Active</div>
<div className="text-xl font-semibold text-green-700">{stats.active}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Pending</div>
<div className="text-xl font-semibold text-amber-700">{stats.pending}</div>
</div>
</div>
<div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-lg bg-amber-100 hover:bg-amber-200 border border-amber-200 text-amber-800 text-base font-semibold px-5 py-3 shadow transition"
onClick={() => window.location.href = '/admin/user-verify'}
>
Go to User Verification
</button>
</div>
</div>
{/* Error Message */}
{error && (
<div className="rounded-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
<div>
<p className="font-semibold">Error loading users</p>
<p className="text-sm text-red-600">{error}</p>
<button
onClick={fetchAllUsers}
className="mt-2 text-sm underline hover:no-underline"
>
Try again
</button>
</div>
</div>
)}
{/* Filter Card */}
<form
onSubmit={applyFilter}
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
>
<h2 className="text-lg font-semibold text-blue-900">
Search & Filter Users
</h2>
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
{/* Search */}
<div className="md:col-span-2">
<label className="sr-only">Search</label>
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Email, name, company..."
className="w-full rounded-lg border border-gray-300 pl-10 pr-3 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
/>
</div>
</div>
{/* Type */}
<div>
<select
value={fType}
onChange={e => setFType(e.target.value as any)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
>
<option value="all">All Types</option>
<option value="personal">Personal</option>
<option value="company">Company</option>
</select>
</div>
{/* Status */}
<div>
<select
value={fStatus}
onChange={e => setFStatus(e.target.value as any)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
>
<option value="all">All Status</option>
{STATUSES.map(s => <option key={s} value={s}>{s[0].toUpperCase()+s.slice(1)}</option>)}
</select>
</div>
{/* Role */}
<div>
<select
value={fRole}
onChange={e => setFRole(e.target.value as any)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
>
<option value="all">All Roles</option>
{ROLES.map(r => <option key={r} value={r}>{r[0].toUpperCase()+r.slice(1)}</option>)}
</select>
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={exportCsv}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-blue-900 text-sm font-semibold px-5 py-3 shadow transition"
title="Export all filtered users to CSV"
>
Export all users as CSV
</button>
<button
type="submit"
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 text-sm font-semibold px-5 py-3 shadow transition"
>
Filter
</button>
</div>
</form>
{/* Users Table */}
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
<div className="text-lg font-semibold text-blue-900">
All Users
</div>
<div className="text-xs text-gray-500">
Showing {current.length} of {filtered.length} users
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-100 text-sm">
<thead className="bg-blue-50 text-blue-900 font-medium">
<tr>
<th className="px-4 py-3 text-left">User</th>
<th className="px-4 py-3 text-left">Type</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left">Created</th>
<th className="px-4 py-3 text-left">Last Login</th>
<th className="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center">
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
<span className="text-sm text-blue-900">Loading users...</span>
</div>
</td>
</tr>
) : current.map(u => {
const displayName = u.user_type === 'company'
? u.company_name || 'Unknown Company'
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
const initials = u.user_type === 'company'
? (u.company_name?.[0] || 'C').toUpperCase()
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
// Use backend status directly for display to avoid desync
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
const createdDate = new Date(u.created_at).toLocaleDateString()
const lastLoginDate = u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'
return (
<tr key={u.id} className="hover:bg-blue-50">
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-900 to-blue-700 text-white text-xs font-semibold shadow">
{initials}
</div>
<div>
<div className="font-medium text-blue-900 leading-tight">
{displayName}
</div>
<div className="text-[11px] text-blue-700">
{u.email}
</div>
</div>
</div>
</td>
<td className="px-4 py-4">{typeBadge(u.user_type)}</td>
<td className="px-4 py-4">{statusBadge(userStatus)}</td>
<td className="px-4 py-4">{roleBadge(u.role)}</td>
<td className="px-4 py-4 text-blue-900">{createdDate}</td>
<td className="px-4 py-4 text-blue-700 italic">
{lastLoginDate}
</td>
<td className="px-4 py-4">
<div className="flex gap-2">
<button
onClick={() => onEdit(u.id.toString())}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-900 px-3 py-2 text-xs font-medium transition"
>
<PencilSquareIcon className="h-4 w-4" /> Edit
</button>
</div>
</td>
</tr>
)
})}
{current.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
No users match current filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-8 py-6 bg-blue-50 border-t border-blue-100">
<div className="text-xs text-blue-700">
Page {page} of {totalPages} ({filtered.length} total users)
</div>
<div className="flex gap-2">
<button
disabled={page===1}
onClick={() => setPage(p => Math.max(1,p-1))}
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Previous
</button>
<button
disabled={page===totalPages}
onClick={() => setPage(p => Math.min(totalPages,p+1))}
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</main>
</div>
{/* User Detail Modal */}
<UserDetailModal
isOpen={isDetailModalOpen}
onClose={() => {
setIsDetailModalOpen(false)
setSelectedUserId(null)
}}
userId={selectedUserId}
onUserUpdated={fetchAllUsers}
/>
</PageLayout>
)
}

View File

@ -0,0 +1,396 @@
'use client'
import { useMemo, useState, useEffect } from 'react'
import PageLayout from '../../components/PageLayout'
import UserDetailModal from '../../components/UserDetailModal'
import {
MagnifyingGlassIcon,
ExclamationTriangleIcon,
EyeIcon
} from '@heroicons/react/24/outline'
import { useAdminUsers } from '../../hooks/useAdminUsers'
import { PendingUser } from '../../utils/api'
type UserType = 'personal' | 'company'
type UserRole = 'user' | 'admin'
type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
type StatusFilter = 'all' | 'pending' | 'verifying' | 'active'
export default function AdminUserVerifyPage() {
const {
pendingUsers,
loading,
error,
isAdmin,
fetchPendingUsers
} = useAdminUsers()
const [isClient, setIsClient] = useState(false)
// Handle client-side mounting
useEffect(() => {
setIsClient(true)
}, [])
const [search, setSearch] = useState('')
const [fType, setFType] = useState<'all' | UserType>('all')
const [fRole, setFRole] = useState<'all' | UserRole>('all')
const [fReady, setFReady] = useState<VerificationReadyFilter>('all')
const [fStatus, setFStatus] = useState<StatusFilter>('all')
const [perPage, setPerPage] = useState(10)
const [page, setPage] = useState(1)
// All computations must be after hooks but before conditional returns
const filtered = useMemo(() => {
return pendingUsers.filter(u => {
const firstName = u.first_name || ''
const lastName = u.last_name || ''
const companyName = u.company_name || ''
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
u.documents_uploaded === 1 && u.contract_signed === 1
return (
(fType === 'all' || u.user_type === fType) &&
(fRole === 'all' || u.role === fRole) &&
(fStatus === 'all' || u.status === fStatus) &&
(
fReady === 'all' ||
(fReady === 'ready' && isReadyToVerify) ||
(fReady === 'not_ready' && !isReadyToVerify)
) &&
(
!search.trim() ||
u.email.toLowerCase().includes(search.toLowerCase()) ||
fullName.toLowerCase().includes(search.toLowerCase())
)
)
})
}, [pendingUsers, search, fType, fRole, fReady, fStatus])
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
const current = filtered.slice((page - 1) * perPage, page * perPage)
// Modal state
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
const applyFilters = (e: React.FormEvent) => {
e.preventDefault()
setPage(1)
}
const badge = (text: string, color: string) =>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${color}`}>
{text}
</span>
const typeBadge = (t: UserType) =>
t === 'personal'
? badge('Personal', 'bg-blue-100 text-blue-700')
: badge('Company', 'bg-purple-100 text-purple-700')
const roleBadge = (r: UserRole) =>
r === 'admin'
? badge('Admin', 'bg-indigo-100 text-indigo-700')
: badge('User', 'bg-gray-100 text-gray-700')
const statusBadge = (s: PendingUser['status']) => {
if (s === 'pending') return badge('Pending', 'bg-amber-100 text-amber-700')
if (s === 'verifying') return badge('Verifying', 'bg-blue-100 text-blue-700')
return badge('Active', 'bg-green-100 text-green-700')
}
const verificationStatusBadge = (user: PendingUser) => {
const steps = [
{ name: 'Email', completed: user.email_verified === 1 },
{ name: 'Profile', completed: user.profile_completed === 1 },
{ name: 'Documents', completed: user.documents_uploaded === 1 },
{ name: 'Contract', completed: user.contract_signed === 1 }
]
const completedSteps = steps.filter(s => s.completed).length
const totalSteps = steps.length
if (completedSteps === totalSteps) {
return badge('Ready to Verify', 'bg-green-100 text-green-700')
} else {
return badge(`${completedSteps}/${totalSteps} Steps`, 'bg-gray-100 text-gray-700')
}
}
// Show loading during SSR/initial client render
if (!isClient) {
return (
<PageLayout>
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="text-center">
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-blue-900">Loading...</p>
</div>
</div>
</PageLayout>
)
}
// Access check (only after client-side hydration)
if (!isAdmin) {
return (
<PageLayout>
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
<div className="text-center">
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
<p className="text-gray-600">You need admin privileges to access this page.</p>
</div>
</div>
</div>
</PageLayout>
)
}
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">User Verification Center</h1>
<p className="text-lg text-blue-700 mt-2">
Review and verify all users who need admin approval. Users must complete all steps before verification.
</p>
</div>
</header>
{/* Error Message */}
{error && (
<div className="rounded-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
<div>
<p className="font-semibold">Error loading data</p>
<p className="text-sm text-red-600">{error}</p>
<button
onClick={fetchPendingUsers}
className="mt-2 text-sm underline hover:no-underline"
>
Try again
</button>
</div>
</div>
)}
{/* Filter Card */}
<form
onSubmit={applyFilters}
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
>
<h2 className="text-lg font-semibold text-blue-900">
Search & Filter Pending Users
</h2>
<div className="grid grid-cols-1 lg:grid-cols-7 gap-4">
<div className="lg:col-span-2">
<label className="block text-xs font-semibold text-blue-900 mb-1">Search</label>
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Email, name, company..."
className="w-full rounded-lg border border-gray-300 pl-10 pr-3 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-blue-900 mb-1">User Type</label>
<select
value={fType}
onChange={e => { setFType(e.target.value as any); setPage(1) }}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
>
<option value="all">All Types</option>
<option value="personal">Personal</option>
<option value="company">Company</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-blue-900 mb-1">Role</label>
<select
value={fRole}
onChange={e => { setFRole(e.target.value as any); setPage(1) }}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
>
<option value="all">All Roles</option>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-blue-900 mb-1">Verification Readiness</label>
<select
value={fReady}
onChange={e => { setFReady(e.target.value as VerificationReadyFilter); setPage(1) }}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
>
<option value="all">All Readiness</option>
<option value="ready">Ready to Verify</option>
<option value="not_ready">Not Ready</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-blue-900 mb-1">Status</label>
<select
value={fStatus}
onChange={e => { setFStatus(e.target.value as StatusFilter); setPage(1) }}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="verifying">Verifying</option>
<option value="active">Active</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-blue-900 mb-1">Rows per page</label>
<select
value={perPage}
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
>
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
</form>
{/* Pending Users Table */}
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
<div className="text-lg font-semibold text-blue-900">
Users Pending Verification
</div>
<div className="text-xs text-gray-500">
Showing {current.length} of {filtered.length} users
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-100 text-sm">
<thead className="bg-blue-50 text-blue-900 font-medium">
<tr>
<th className="px-4 py-3 text-left">User</th>
<th className="px-4 py-3 text-left">Type</th>
<th className="px-4 py-3 text-left">Progress</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left">Created</th>
<th className="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center">
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
<span className="text-sm text-blue-900">Loading users...</span>
</div>
</td>
</tr>
) : current.map(u => {
const displayName = u.user_type === 'company'
? u.company_name || 'Unknown Company'
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
const initials = u.user_type === 'company'
? (u.company_name?.[0] || 'C').toUpperCase()
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
const createdDate = new Date(u.created_at).toLocaleDateString()
return (
<tr key={u.id} className="hover:bg-blue-50">
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-900 to-blue-700 text-white text-xs font-semibold shadow">
{initials}
</div>
<div>
<div className="font-medium text-blue-900 leading-tight">
{displayName}
</div>
<div className="text-[11px] text-blue-700">{u.email}</div>
</div>
</div>
</td>
<td className="px-4 py-4">{typeBadge(u.user_type)}</td>
<td className="px-4 py-4">{verificationStatusBadge(u)}</td>
<td className="px-4 py-4">{statusBadge(u.status)}</td>
<td className="px-4 py-4">{roleBadge(u.role)}</td>
<td className="px-4 py-4 text-blue-900">{createdDate}</td>
<td className="px-4 py-4">
<div className="flex gap-2">
<button
onClick={() => {
setSelectedUserId(u.id.toString())
setIsDetailModalOpen(true)
}}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-900 px-3 py-2 text-xs font-medium transition"
>
<EyeIcon className="h-4 w-4" /> View
</button>
</div>
</td>
</tr>
)
})}
{current.length === 0 && !loading && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
No unverified users match current filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-8 py-6 bg-blue-50 border-t border-blue-100">
<div className="text-xs text-blue-700">
Page {page} of {totalPages} ({filtered.length} pending users)
</div>
<div className="flex gap-2">
<button
disabled={page === 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Previous
</button>
<button
disabled={page === totalPages}
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</main>
</div>
{/* User Detail Modal */}
<UserDetailModal
isOpen={isDetailModalOpen}
onClose={() => {
setIsDetailModalOpen(false)
setSelectedUserId(null)
}}
userId={selectedUserId}
onUserUpdated={() => {
fetchPendingUsers()
}}
/>
</PageLayout>
)
}

View File

@ -0,0 +1,217 @@
'use client'
import { useEffect, useState, useMemo } from 'react'
import PageLayout from '../components/PageLayout'
import Waves from '../components/background/waves'
type Affiliate = {
id: string
name: string
description: string
url: string
logoUrl?: string
category: string
commissionRate?: string
}
// Fallback placeholder image
const PLACEHOLDER_IMAGE = 'https://images.unsplash.com/photo-1557804506-669a67965ba0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80'
export default function AffiliateLinksPage() {
const [affiliates, setAffiliates] = useState<Affiliate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// NEW: selected category
const [selectedCategory, setSelectedCategory] = useState<string>('all')
useEffect(() => {
async function fetchAffiliates() {
try {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
const res = await fetch(`${BASE_URL}/api/affiliates/active`)
if (!res.ok) {
throw new Error('Failed to fetch affiliates')
}
const data = await res.json()
const activeAffiliates = data.data || []
setAffiliates(activeAffiliates.map((item: any) => ({
id: String(item.id),
name: String(item.name || 'Partner'),
description: String(item.description || ''),
url: String(item.url || '#'),
logoUrl: item.logoUrl || PLACEHOLDER_IMAGE,
category: String(item.category || 'Other'),
commissionRate: item.commission_rate ? String(item.commission_rate) : undefined
})))
} catch (err) {
console.error('Error loading affiliates:', err)
setError('Failed to load affiliate partners')
} finally {
setLoading(false)
}
}
fetchAffiliates()
}, [])
const posts = affiliates.map(affiliate => ({
id: affiliate.id,
title: affiliate.name,
href: affiliate.url,
description: affiliate.description,
imageUrl: affiliate.logoUrl || PLACEHOLDER_IMAGE,
category: { title: affiliate.category, href: '#' },
commissionRate: affiliate.commissionRate
}))
// NEW: fixed categories from the provided image, merged with backend ones
const categories = useMemo(() => {
const fromImage = [
'Technology',
'Energy',
'Finance',
'Healthcare',
'Education',
'Travel',
'Retail',
'Construction',
'Food',
'Automotive',
'Fashion',
'Pets',
]
const set = new Set<string>(fromImage)
affiliates.forEach(a => { if (a.category) set.add(a.category) })
return ['all', ...Array.from(set)]
}, [affiliates])
return (
<PageLayout>
<div
className="relative w-full flex flex-col min-h-screen overflow-hidden"
style={{ backgroundImage: 'none', background: 'none' }}
>
<Waves
className="pointer-events-none"
lineColor="#0f172a"
backgroundColor="rgba(245, 245, 240, 1)"
waveSpeedX={0.02}
waveSpeedY={0.01}
waveAmpX={40}
waveAmpY={20}
friction={0.9}
tension={0.01}
maxCursorMove={120}
xGap={12}
yGap={36}
/>
<div className="relative z-10 min-h-screen flex flex-col">
<main className="flex-1 max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8 w-full">
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
{/* Header (aligned with management pages) */}
<header className="flex flex-col gap-4 mb-8">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Affiliate Partners</h1>
<p className="text-lg text-blue-700 mt-2">
Discover our trusted partners and earn commissions through affiliate links.
</p>
</div>
{/* NEW: Category filter */}
<div className="flex items-center gap-2">
<label className="text-sm text-blue-900 font-medium">Filter by category:</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="rounded-md border border-blue-200 bg-white px-3 py-1.5 text-sm text-blue-900 shadow-sm"
>
{categories.map(c => (
<option key={c} value={c}>{c === 'all' ? 'All' : c}</option>
))}
</select>
</div>
</header>
{/* States */}
{loading && (
<div className="mx-auto max-w-2xl text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-b-transparent" />
<p className="mt-4 text-sm text-gray-600">Loading affiliate partners...</p>
</div>
)}
{error && !loading && (
<div className="mx-auto max-w-2xl rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 text-center">
{error}
</div>
)}
{!loading && !error && posts.length === 0 && (
<div className="mx-auto max-w-2xl text-center text-sm text-gray-600">
No affiliate partners available at the moment.
</div>
)}
{/* Cards (aligned to white panels, border, shadow) */}
{!loading && !error && posts.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{posts.map((post) => {
// NEW: highlight when matches selected category (keep all visible)
const isHighlighted = selectedCategory !== 'all' && post.category.title === selectedCategory
return (
<article
key={post.id}
className={`rounded-2xl bg-white border shadow-lg overflow-hidden flex flex-col transition
${isHighlighted ? 'border-2 border-indigo-400 ring-2 ring-indigo-200' : 'border-gray-100'}`}
>
<div className="relative">
<img alt="" src={post.imageUrl} className="aspect-video w-full object-cover" />
</div>
<div className="p-6 flex-1 flex flex-col">
<div className="flex items-start justify-between gap-3">
<h3 className="text-xl font-semibold text-blue-900">{post.title}</h3>
{post.commissionRate && (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border border-indigo-200 bg-indigo-50 text-indigo-700">
{post.commissionRate}
</span>
)}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<a
href={post.category.href}
className={`inline-flex items-center rounded-full px-2 py-0.5 border text-blue-900
${isHighlighted ? 'border-indigo-300 bg-indigo-50' : 'border-blue-200 bg-blue-50'}`}
>
{post.category.title}
</a>
</div>
<p className="mt-3 text-sm text-gray-700 line-clamp-4">{post.description}</p>
<div className="mt-5 flex items-center justify-between">
<a
href={post.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow transition"
>
Visit Affiliate Link
</a>
<span className="text-[11px] text-gray-500">
External partner website.
</span>
</div>
</div>
</article>
)
})}
</div>
)}
</div>
</main>
</div>
</div>
</PageLayout>
)
}

102
src/app/api/login/route.ts Normal file
View File

@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
export const runtime = 'nodejs'
function getSharedCookieDomain(req: NextRequest): string | undefined {
const host = req.headers.get('host') ?? ''
return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined
}
function splitSetCookieHeader(header: string): string[] {
const parts: string[] = []
let start = 0
let inExpires = false
for (let i = 0; i < header.length; i++) {
const lower = header.slice(i, i + 8).toLowerCase()
if (lower === 'expires=') inExpires = true
if (inExpires && header[i] === ';') inExpires = false
if (!inExpires && header[i] === ',') {
parts.push(header.slice(start, i).trim())
start = i + 1
}
}
const last = header.slice(start).trim()
if (last) parts.push(last)
return parts
}
function readSetCookies(res: Response): string[] {
const anyHeaders = res.headers as any
if (typeof anyHeaders.getSetCookie === 'function') return anyHeaders.getSetCookie()
const single = res.headers.get('set-cookie')
return single ? splitSetCookieHeader(single) : []
}
function parseRefreshCookie(setCookie: string) {
const m = setCookie.match(/^refreshToken=([^;]*)/i)
if (!m) return null
const value = m[1] ?? ''
const maxAgeMatch = setCookie.match(/;\s*max-age=(\d+)/i)
const expiresMatch = setCookie.match(/;\s*expires=([^;]+)/i)
const sameSiteMatch = setCookie.match(/;\s*samesite=(lax|strict|none)/i)
return {
value,
maxAge: maxAgeMatch ? Number(maxAgeMatch[1]) : undefined,
expires: expiresMatch ? new Date(expiresMatch[1]) : undefined,
sameSite: (sameSiteMatch?.[1]?.toLowerCase() as 'lax' | 'strict' | 'none' | undefined) ?? undefined,
}
}
export async function POST(req: NextRequest) {
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL
if (!apiBase) return NextResponse.json({ message: 'Missing NEXT_PUBLIC_API_BASE_URL' }, { status: 500 })
const body = await req.json().catch(() => null)
const apiRes = await fetch(`${apiBase}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
cache: 'no-store',
})
const data = await apiRes.json().catch(() => null)
const out = NextResponse.json(data, { status: apiRes.status })
const setCookies = readSetCookies(apiRes)
const refreshSetCookie = setCookies.find((c) => /^refreshToken=/i.test(c))
if (refreshSetCookie) {
const parsed = parseRefreshCookie(refreshSetCookie)
if (parsed?.value) {
// Clear host-only duplicates first.
out.cookies.set('refreshToken', '', {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 0,
})
out.cookies.set('__Secure-refreshToken', '', {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 0,
})
out.cookies.set('refreshToken', parsed.value, {
domain: getSharedCookieDomain(req),
path: '/',
httpOnly: true,
secure: true,
sameSite: parsed.sameSite ?? 'lax',
...(parsed.maxAge !== undefined ? { maxAge: parsed.maxAge } : {}),
...(parsed.maxAge === undefined && parsed.expires ? { expires: parsed.expires } : {}),
})
}
}
return out
}

View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
export const runtime = 'nodejs'
function getSharedCookieDomain(req: NextRequest): string | undefined {
const host = req.headers.get('host') ?? ''
return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined
}
export async function POST(req: NextRequest) {
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL
const cookie = req.headers.get('cookie') ?? ''
// Best-effort: tell backend to revoke refresh token (if endpoint exists)
let data: any = { success: true }
let status = 200
if (apiBase) {
const apiRes = await fetch(`${apiBase}/api/logout`, {
method: 'POST',
headers: { cookie, 'Content-Type': 'application/json' },
cache: 'no-store',
}).catch(() => null)
if (apiRes) {
status = apiRes.status
data = await apiRes.json().catch(() => data)
}
}
const out = NextResponse.json(data, { status })
// Clear host-only variants
out.cookies.set('refreshToken', '', { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 0 })
out.cookies.set('__Secure-refreshToken', '', { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 0 })
// Clear shared-domain variant
out.cookies.set('refreshToken', '', {
domain: getSharedCookieDomain(req),
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 0,
})
return out
}

View File

@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from 'next/server'
export const runtime = 'nodejs'
function getSharedCookieDomain(req: NextRequest): string | undefined {
const host = req.headers.get('host') ?? ''
return host.endsWith('profit-planet.partners') ? '.profit-planet.partners' : undefined
}
function splitSetCookieHeader(header: string): string[] {
// Handles "Expires=Wed, 21 Oct ..." commas.
const parts: string[] = []
let start = 0
let inExpires = false
for (let i = 0; i < header.length; i++) {
const lower = header.slice(i, i + 8).toLowerCase()
if (lower === 'expires=') inExpires = true
if (inExpires && header[i] === ';') inExpires = false
if (!inExpires && header[i] === ',') {
parts.push(header.slice(start, i).trim())
start = i + 1
}
}
const last = header.slice(start).trim()
if (last) parts.push(last)
return parts
}
function readSetCookies(res: Response): string[] {
const anyHeaders = res.headers as any
if (typeof anyHeaders.getSetCookie === 'function') return anyHeaders.getSetCookie()
const single = res.headers.get('set-cookie')
return single ? splitSetCookieHeader(single) : []
}
function parseRefreshCookie(setCookie: string) {
const m = setCookie.match(/^refreshToken=([^;]*)/i)
if (!m) return null
const value = m[1] ?? ''
const maxAgeMatch = setCookie.match(/;\s*max-age=(\d+)/i)
const expiresMatch = setCookie.match(/;\s*expires=([^;]+)/i)
const sameSiteMatch = setCookie.match(/;\s*samesite=(lax|strict|none)/i)
return {
value,
maxAge: maxAgeMatch ? Number(maxAgeMatch[1]) : undefined,
expires: expiresMatch ? new Date(expiresMatch[1]) : undefined,
sameSite: (sameSiteMatch?.[1]?.toLowerCase() as 'lax' | 'strict' | 'none' | undefined) ?? undefined,
}
}
export async function POST(req: NextRequest) {
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL
if (!apiBase) return NextResponse.json({ message: 'Missing NEXT_PUBLIC_API_BASE_URL' }, { status: 500 })
// Prefer a single parsed cookie value to avoid "refreshToken=old; refreshToken=new" ambiguity.
const rt =
req.cookies.get('refreshToken')?.value ??
req.cookies.get('__Secure-refreshToken')?.value ??
''
const headers: Record<string, string> = {}
if (rt) {
headers.cookie = `refreshToken=${rt}`
} else {
// fallback (best-effort)
const cookie = req.headers.get('cookie') ?? ''
if (cookie) headers.cookie = cookie
}
const apiRes = await fetch(`${apiBase}/api/refresh`, {
method: 'POST',
headers,
cache: 'no-store',
})
const data = await apiRes.json().catch(() => null)
const out = NextResponse.json(data, { status: apiRes.status })
const setCookies = readSetCookies(apiRes)
const refreshSetCookie = setCookies.find((c) => /^refreshToken=/i.test(c))
if (refreshSetCookie) {
const parsed = parseRefreshCookie(refreshSetCookie)
if (parsed?.value) {
// Clear any host-only variants on the frontend host to prevent duplicates.
out.cookies.set('refreshToken', '', {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 0,
})
out.cookies.set('__Secure-refreshToken', '', {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 0,
})
// Set the shared-domain cookie used across subdomains.
out.cookies.set('refreshToken', parsed.value, {
domain: getSharedCookieDomain(req),
path: '/',
httpOnly: true,
secure: true,
sameSite: parsed.sameSite ?? 'lax',
...(parsed.maxAge !== undefined ? { maxAge: parsed.maxAge } : {}),
...(parsed.maxAge === undefined && parsed.expires ? { expires: parsed.expires } : {}),
})
}
}
return out
}

View File

@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { authFetch } from '../../utils/authFetch';
export type ActiveCoffee = {
id: string | number;
title: string;
description: string;
price: string | number; // price can be a string or number
pictureUrl?: string;
state: number; // 1 for active, 0 for inactive
};
export type CoffeeItem = {
id: string;
name: string;
description: string;
pricePer10: number; // price for 10 pieces
image: string;
};
export function useActiveCoffees() {
const [coffees, setCoffees] = useState<CoffeeItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
const url = `${base}/api/admin/coffee/active`;
console.log('[useActiveCoffees] Fetching active coffees from:', url);
setLoading(true);
setError(null);
authFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
})
.then(async (response) => {
const contentType = response.headers.get('content-type') || '';
console.log('[useActiveCoffees] Response status:', response.status);
console.log('[useActiveCoffees] Response content-type:', contentType);
if (!response.ok || !contentType.includes('application/json')) {
const text = await response.text().catch(() => '');
console.warn('[useActiveCoffees] Non-JSON response or error body:', text.slice(0, 200));
throw new Error(`Request failed: ${response.status} ${text.slice(0, 160)}`);
}
const json = await response.json();
console.log('[useActiveCoffees] Raw JSON response:', json);
const data: ActiveCoffee[] =
Array.isArray(json?.data) ? json.data :
Array.isArray(json) ? json :
[]
console.log('[useActiveCoffees] Parsed coffee data:', data);
const mapped: CoffeeItem[] = data
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
.map((coffee) => {
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price
return {
id: String(coffee.id),
name: coffee.title || `Coffee ${coffee.id}`,
description: coffee.description || '',
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
image: coffee.pictureUrl || '',
}
})
console.log('[useActiveCoffees] Mapped coffee items:', mapped)
setCoffees(mapped)
})
.catch((error: any) => {
console.error('[useActiveCoffees] Error fetching coffees:', error);
setError(error?.message || 'Failed to load active coffees');
setCoffees([]);
})
.finally(() => {
setLoading(false);
console.log('[useActiveCoffees] Fetch complete. Loading state:', false);
});
}, []);
return { coffees, loading, error };
}

View File

@ -0,0 +1,307 @@
'use client';
import React, { useState, useMemo } from 'react';
import PageLayout from '../components/PageLayout';
import { useRouter } from 'next/navigation';
import { useActiveCoffees } from './hooks/getActiveCoffees';
export default function CoffeeAbonnementPage() {
const [selections, setSelections] = useState<Record<string, number>>({});
const [bump, setBump] = useState<Record<string, boolean>>({});
const router = useRouter();
// Fetch active coffees from the backend
const { coffees, loading, error } = useActiveCoffees();
const selectedEntries = useMemo(
() =>
Object.entries(selections).map(([id, qty]) => {
const coffee = coffees.find((c) => c.id === id);
if (!coffee) return null;
return { coffee, quantity: qty };
}).filter(Boolean) as { coffee: ReturnType<typeof useActiveCoffees>['coffees'][number]; quantity: number }[],
[selections, coffees]
);
const totalPrice = useMemo(
() =>
selectedEntries.reduce(
(sum, entry) => sum + (entry.quantity / 10) * entry.coffee.pricePer10,
0
),
[selectedEntries]
);
// NEW: enforce exactly 120 capsules (12 packs)
const totalCapsules = useMemo(
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
[selectedEntries]
);
const packsSelected = totalCapsules / 10;
const canProceed = packsSelected === 12; // CHANGED: require exactly 12 packs
const TAX_RATE = 0.07;
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
const proceedToSummary = () => {
if (!canProceed) return;
try {
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
} catch {}
router.push('/coffee-abonnements/summary');
};
const toggleCoffee = (id: string) => {
setSelections((prev) => {
const copy = { ...prev };
if (id in copy) {
delete copy[id];
} else {
copy[id] = 10;
}
return copy;
});
};
const changeQuantity = (id: string, delta: number) => {
setSelections((prev) => {
if (!(id in prev)) return prev;
const next = prev[id] + delta;
if (next < 10 || next > 120) return prev;
const updated = { ...prev, [id]: next };
setBump((b) => ({ ...b, [id]: true }));
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
return updated;
});
};
return (
<PageLayout>
<div className="mx-auto max-w-7xl px-4 py-10 space-y-10 bg-gradient-to-b from-white to-[#1C2B4A0D]">
<h1 className="text-3xl font-bold tracking-tight">
<span className="text-[#1C2B4A]">Configure Coffee Subscription</span>
</h1>
{/* Stepper */}
<div className="flex items-center gap-3 text-sm text-gray-600">
<div className="flex items-center">
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">1</span>
<span className="ml-2 font-medium">Selection</span>
</div>
<div className="h-px flex-1 bg-gray-200" />
<div className="flex items-center opacity-60">
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">2</span>
<span className="ml-2 font-medium">Summary</span>
</div>
</div>
{/* Section 1: Multi coffee selection + per-coffee quantity */}
<section>
<h2 className="text-xl font-semibold mb-4">1. Choose coffees & quantities</h2>
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{loading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{coffees.map((coffee) => {
const active = coffee.id in selections;
const qty = selections[coffee.id] || 0;
return (
<div
key={coffee.id}
className={`group rounded-xl border p-4 shadow-sm transition ${
active ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-md' : 'border-gray-200 bg-white'
}`}
>
<div className="relative overflow-hidden rounded-md mb-3">
{coffee.image ? (
<img
src={coffee.image}
alt={coffee.name}
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="h-36 w-full bg-gray-100 rounded-md" />
)}
{/* price badge (per 10) */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
<span
aria-label={`Price €${coffee.pricePer10} per 10 capsules`}
className={`relative inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm transition-transform group-hover:scale-105 ${
active ? 'bg-[#1C2B4A]' : 'bg-[#1C2B4A]/80'
}`}
>
{coffee.pricePer10}
</span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[#1C2B4A]/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">
per 10 pcs
</span>
</div>
</div>
<div className="flex items-start justify-between">
<h3 className="font-semibold text-sm">{coffee.name}</h3>
</div>
<p className="mt-2 text-xs text-gray-600 leading-relaxed">
{coffee.description}
</p>
<button
type="button"
onClick={() => toggleCoffee(coffee.id)}
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
active
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
: 'border-gray-300 hover:bg-gray-100'
}`}
>
{active ? 'Remove' : 'Add'}
</button>
{active && (
<div className="mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-gray-500">Quantity (10120)</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>
);
}

View File

@ -0,0 +1,92 @@
import { authFetch } from "../../../utils/authFetch";
interface VatRate {
countryCode: string;
standardRate: number;
}
const normalizeVatRate = (rate: number | null | undefined): number | null => {
if (rate == null || Number.isNaN(rate)) return null;
return rate > 1 ? rate / 100 : rate;
};
const toNumber = (v: any): number | null => {
if (v == null) return null;
const n = typeof v === 'string' ? Number(v) : (typeof v === 'number' ? v : Number(v));
return Number.isFinite(n) ? n : null;
};
const getCode = (row: any): string => {
const raw = row?.countryCode ?? row?.code ?? row?.country ?? row?.country_code;
return typeof raw === 'string' ? raw.toUpperCase() : '';
};
const getRateRaw = (row: any): number | null => {
// support multiple field names and string numbers
const raw = row?.standardRate ?? row?.rate ?? row?.ratePercent ?? row?.standard_rate;
const num = toNumber(raw);
return num;
};
const getRateNormalized = (row: any): number | null => {
const num = getRateRaw(row);
return normalizeVatRate(num);
};
/**
* Fetches the standard VAT rate for a given ISO country code.
* Returns null if not found or on any error.
*/
export async function getStandardVatRate(countryCode: string): Promise<number | null> {
try {
const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/tax/vat-rates`;
console.info('[VAT] getStandardVatRate -> GET', url, { countryCode });
const res = await authFetch(url, { method: "GET" });
console.info('[VAT] getStandardVatRate status:', res.status);
if (!res.ok) return null;
const raw = await res.json().catch(() => null);
const arr = Array.isArray(raw?.data) ? raw.data : (Array.isArray(raw) ? raw : []);
console.info('[VAT] getStandardVatRate parsed length:', Array.isArray(arr) ? arr.length : 0);
if (!Array.isArray(arr) || arr.length === 0) return null;
const upper = countryCode.toUpperCase();
const match = arr.find((r: any) => getCode(r) === upper);
const normalized = match ? getRateNormalized(match) : null;
console.info('[VAT] getStandardVatRate match:', {
upper,
resolvedCode: match ? getCode(match) : null,
rawRate: match ? getRateRaw(match) : null,
normalized
});
return normalized;
} catch (e) {
console.error('[VAT] getStandardVatRate error:', e);
return null;
}
}
export type VatRateEntry = { code: string; rate: number | null }
export async function getVatRates(): Promise<VatRateEntry[]> {
try {
const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/tax/vat-rates`;
console.info('[VAT] getVatRates -> GET', url);
const res = await authFetch(url, { method: 'GET' });
console.info('[VAT] getVatRates status:', res.status);
if (!res.ok) return [];
const raw = await res.json().catch(() => null);
const arr = Array.isArray(raw?.data) ? raw.data : (Array.isArray(raw) ? raw : []);
console.info('[VAT] getVatRates parsed length:', Array.isArray(arr) ? arr.length : 0);
if (!Array.isArray(arr) || arr.length === 0) return [];
const mapped = arr.map((r: any) => ({
code: getCode(r),
rate: getRateNormalized(r)
})).filter((r: VatRateEntry) => !!r.code);
console.info('[VAT] getVatRates mapped sample:', mapped.slice(0, 5));
return mapped;
} catch (e) {
console.error('[VAT] getVatRates error:', e);
return [];
}
}

View File

@ -0,0 +1,138 @@
import { authFetch } from '../../../utils/authFetch'
export type SubscribeAboItem = { coffeeId: string | number; quantity?: number }
export type SubscribeAboInput = {
coffeeId?: string | number // optional when items provided
items?: SubscribeAboItem[] // NEW: whole order in one call
billing_interval?: string
interval_count?: number
is_auto_renew?: boolean
target_user_id?: number
recipient_name?: string
recipient_email?: string
recipient_notes?: string
// NEW: customer fields
firstName?: string
lastName?: string
email?: string
street?: string
postalCode?: string
city?: string
country?: string
frequency?: string
startDate?: string
// NEW: logged-in user id
referred_by?: number
}
type Abonement = any
type HistoryEvent = any
const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const parseJson = async (res: Response) => {
const ct = res.headers.get('content-type') || ''
const isJson = ct.includes('application/json')
const json = isJson ? await res.json().catch(() => ({})) : null
return { json, isJson }
}
export async function subscribeAbo(input: SubscribeAboInput) {
const hasItems = Array.isArray(input.items) && input.items.length > 0
if (!hasItems && !input.coffeeId) throw new Error('coffeeId is required')
const hasRecipientFields = !!(input.recipient_name || input.recipient_email || input.recipient_notes)
if (hasRecipientFields && !input.recipient_name) {
throw new Error('recipient_name is required when gifting to a non-account recipient.')
}
// NEW: validate customer fields (required in UI)
const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency','startDate'] as const
const missing = requiredFields.filter(k => {
const v = (input as any)[k]
return typeof v !== 'string' || v.trim() === ''
})
if (missing.length) {
throw new Error(`Missing required fields: ${missing.join(', ')}`)
}
const body: any = {
billing_interval: input.billing_interval ?? 'month',
interval_count: input.interval_count ?? 1,
is_auto_renew: input.is_auto_renew ?? true,
// NEW: include customer fields
firstName: input.firstName,
lastName: input.lastName,
email: input.email,
street: input.street,
postalCode: input.postalCode,
city: input.city,
country: input.country?.toUpperCase?.() ?? input.country,
frequency: input.frequency,
startDate: input.startDate,
}
if (hasItems) {
body.items = input.items!.map(i => ({
coffeeId: i.coffeeId,
quantity: i.quantity != null ? i.quantity : 1,
}))
// NEW: enforce exactly 12 packs
const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0)
if (sumPacks !== 12) {
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 12')
throw new Error('Order must contain exactly 12 packs (120 capsules).')
}
} else {
body.coffeeId = input.coffeeId
// single-item legacy path — backend expects bundle, prefer items usage
}
// NEW: always include available recipient fields and target_user_id when provided
if (input.target_user_id != null) body.target_user_id = input.target_user_id
if (input.recipient_name) body.recipient_name = input.recipient_name
if (input.recipient_email) body.recipient_email = input.recipient_email
if (input.recipient_notes) body.recipient_notes = input.recipient_notes
// NEW: always include referred_by if provided
if (input.referred_by != null) body.referred_by = input.referred_by
const url = `${apiBase}/api/abonements/subscribe`
console.info('[subscribeAbo] POST', url, { body })
// NEW: explicit JSON preview that matches the actual request body
console.info('[subscribeAbo] Body JSON:', JSON.stringify(body))
const res = await authFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
credentials: 'include',
})
const { json } = await parseJson(res)
console.info('[subscribeAbo] Response', res.status, json)
if (!res.ok || !json?.success) throw new Error(json?.message || `Subscribe failed: ${res.status}`)
return json.data as Abonement
}
async function postAction(url: string) {
const res = await authFetch(url, { method: 'POST', headers: { Accept: 'application/json' }, credentials: 'include' })
const { json } = await parseJson(res)
if (!res.ok || !json?.success) throw new Error(json?.message || `Request failed: ${res.status}`)
return json.data as Abonement
}
export const pauseAbo = (id: string | number) => postAction(`${apiBase}/abonements/${id}/pause`)
export const resumeAbo = (id: string | number) => postAction(`${apiBase}/abonements/${id}/resume`)
export const cancelAbo = (id: string | number) => postAction(`${apiBase}/abonements/${id}/cancel`)
export const renewAbo = (id: string | number) => postAction(`${apiBase}/admin/abonements/${id}/renew`)
export async function getMyAbonements(status?: string) {
const qs = status ? `?status=${encodeURIComponent(status)}` : ''
const res = await authFetch(`${apiBase}/abonements/mine${qs}`, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include' })
const { json } = await parseJson(res)
if (!res.ok || !json?.success) throw new Error(json?.message || `Fetch failed: ${res.status}`)
return (json.data || []) as Abonement[]
}
export async function getAboHistory(id: string | number) {
const res = await authFetch(`${apiBase}/abonements/${id}/history`, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include' })
const { json } = await parseJson(res)
if (!res.ok || !json?.success) throw new Error(json?.message || `History failed: ${res.status}`)
return (json.data || []) as HistoryEvent[]
}

View File

@ -0,0 +1,413 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import PageLayout from '../../components/PageLayout';
import { useRouter } from 'next/navigation';
import { useActiveCoffees } from '../hooks/getActiveCoffees';
import { getStandardVatRate, getVatRates } from './hooks/getTaxRate';
import { subscribeAbo } from './hooks/subscribeAbo';
import useAuthStore from '../../store/authStore'
export default function SummaryPage() {
const router = useRouter();
const { coffees, loading, error } = useActiveCoffees();
const [selections, setSelections] = useState<Record<string, number>>({});
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
street: '',
postalCode: '',
city: '',
country: 'DE',
frequency: 'monatlich',
startDate: ''
});
const [showThanks, setShowThanks] = useState(false);
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
const [taxRate, setTaxRate] = useState(0.07); // minimal fallback only
const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette
useEffect(() => {
try {
const raw = sessionStorage.getItem('coffeeSelections');
if (raw) setSelections(JSON.parse(raw));
} catch {}
}, []);
useEffect(() => {
if (!showThanks) return;
const items = Array.from({ length: 40 }).map(() => ({
left: Math.random() * 100,
delay: Math.random() * 0.6,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
}));
setConfetti(items);
}, [showThanks]);
const selectedEntries = useMemo(
() =>
Object.entries(selections)
.map(([id, qty]) => {
const coffee = coffees.find(c => c.id === id);
return coffee ? { coffee, quantity: qty } : null;
})
.filter(Boolean) as { coffee: ReturnType<typeof useActiveCoffees>['coffees'][number]; quantity: number }[],
[selections, coffees]
);
// NEW: computed packs/capsules for validation
const totalCapsules = useMemo(
() => selectedEntries.reduce((sum, e) => sum + e.quantity, 0),
[selectedEntries]
)
const totalPacks = totalCapsules / 10
const token = useAuthStore.getState().accessToken
console.info('[SummaryPage] token prefix:', token ? `${token.substring(0, 12)}` : null)
// NEW: capture logged-in user id for referral
const currentUserId = useAuthStore.getState().user?.id
console.info('[SummaryPage] currentUserId:', currentUserId)
// Countries list from backend VAT rates (fallback to current country if list empty)
const countryOptions = useMemo(() => {
const opts = vatRates.length > 0 ? vatRates.map(r => r.code) : [(form.country || 'DE').toUpperCase()]
console.info('[SummaryPage] countryOptions:', opts)
return opts
}, [vatRates, form.country]);
// Load VAT rates list from backend and set initial taxRate
useEffect(() => {
let active = true;
(async () => {
console.info('[SummaryPage] Loading vat rates (mount). country:', form.country)
const list = await getVatRates();
if (!active) return;
console.info('[SummaryPage] getVatRates result count:', list.length)
setVatRates(list);
const upper = form.country.toUpperCase();
const match = list.find(r => r.code === upper);
if (match?.rate != null) {
console.info('[SummaryPage] Initial taxRate from list:', match.rate, 'country:', upper)
setTaxRate(match.rate);
} else {
const rate = await getStandardVatRate(form.country);
console.info('[SummaryPage] Fallback taxRate via getStandardVatRate:', rate, 'country:', upper)
setTaxRate(rate ?? 0.07);
}
})();
return () => { active = false; };
}, []); // mount-only
// Update taxRate when country changes (from backend only)
useEffect(() => {
let active = true;
(async () => {
const upper = form.country.toUpperCase();
console.info('[SummaryPage] Country changed:', upper)
const fromList = vatRates.find(r => r.code === upper)?.rate;
if (fromList != null) {
console.info('[SummaryPage] taxRate from existing list:', fromList)
if (active) setTaxRate(fromList);
return;
}
const rate = await getStandardVatRate(form.country);
console.info('[SummaryPage] taxRate via getStandardVatRate:', rate)
if (active) setTaxRate(rate ?? 0.07);
})();
return () => { active = false; };
}, [form.country, vatRates]);
const totalPrice = useMemo(
() => selectedEntries.reduce((sum, e) => sum + (e.quantity / 10) * e.coffee.pricePer10, 0),
[selectedEntries]
);
const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxRate, taxAmount]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
};
const canSubmit =
selectedEntries.length > 0 &&
totalPacks === 12 && // CHANGED: require exactly 12 packs
Object.values(form).every(v => (typeof v === 'string' ? v.trim() !== '' : true));
const backToSelection = () => router.push('/coffee-abonnements');
const submit = async () => {
if (!canSubmit || submitLoading) return
// NEW: guard (defensive) — backend requires exactly 12 packs
if (totalPacks !== 12) {
setSubmitError('Order must contain exactly 12 packs (120 capsules).')
return
}
setSubmitError(null)
setSubmitLoading(true)
try {
const payload = {
items: selectedEntries.map(entry => ({
coffeeId: entry.coffee.id,
quantity: Math.round(entry.quantity / 10), // packs
})),
billing_interval: 'month',
interval_count: 1,
is_auto_renew: true,
// NEW: pass customer fields
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
email: form.email.trim(),
street: form.street.trim(),
postalCode: form.postalCode.trim(),
city: form.city.trim(),
country: form.country.trim(),
frequency: form.frequency.trim(),
startDate: form.startDate.trim(),
// NEW: always include referred_by if available
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
}
console.info('[SummaryPage] subscribeAbo payload:', payload)
// NEW: explicit JSON preview to match request body
console.info('[SummaryPage] subscribeAbo payload JSON:', JSON.stringify(payload))
await subscribeAbo(payload)
setShowThanks(true);
try { sessionStorage.removeItem('coffeeSelections'); } catch {}
} catch (e: any) {
setSubmitError(e?.message || 'Subscription could not be created.');
} finally {
setSubmitLoading(false);
}
};
return (
<PageLayout>
<div className="mx-auto max-w-6xl px-4 py-10 space-y-8 bg-gradient-to-b from-white to-[#1C2B4A0D]">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">
<span className="text-[#1C2B4A]">Summary & Details</span>
</h1>
<button
onClick={backToSelection}
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100"
>
Back to selection
</button>
</div>
{/* Stepper */}
<div className="flex items-center gap-3 text-sm text-gray-600">
<div className="flex items-center opacity-60">
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">1</span>
<span className="ml-2 font-medium">Selection</span>
</div>
<div className="h-px flex-1 bg-gray-200" />
<div className="flex items-center">
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">2</span>
<span className="ml-2 font-medium">Summary</span>
</div>
</div>
{error && (
<div className="rounded-xl border p-6 bg-white shadow-sm">
<p className="text-sm text-red-700 mb-4">{error}</p>
<button
onClick={backToSelection}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
>
Back to selection
</button>
</div>
)}
{/* submit error */}
{submitError && (
<div className="rounded-xl border p-6 bg-white shadow-sm">
<p className="text-sm text-red-700">{submitError}</p>
</div>
)}
{loading ? (
<div className="rounded-xl border p-6 bg-white shadow-sm">
<div className="h-20 rounded-md bg-gray-100 animate-pulse" />
</div>
) : selectedEntries.length === 0 ? (
<div className="rounded-xl border p-6 bg-white shadow-sm">
<p className="text-sm text-gray-600 mb-4">No selection found.</p>
<button
onClick={backToSelection}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
>
Back to selection
</button>
</div>
) : (
<div className="grid gap-8 lg:grid-cols-3">
{/* Left: Customer data */}
<section className="lg:col-span-2">
<h2 className="text-xl font-semibold mb-4">1. Your details</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
<div className="grid gap-4 sm:grid-cols-2">
{/* inputs translated */}
<div>
<label className="block text-sm font-medium mb-1">First name</label>
<input name="firstName" value={form.firstName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Last name</label>
<input name="lastName" value={form.lastName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Email</label>
<input type="email" name="email" value={form.email} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Street & No.</label>
<input name="street" value={form.street} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">ZIP</label>
<input name="postalCode" value={form.postalCode} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">City</label>
<input name="city" value={form.city} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Country</label>
<select name="country" value={form.country} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]">
{countryOptions.map(code => (
<option key={code} value={code}>{code}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Delivery interval</label>
<select name="frequency" value={form.frequency} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]">
<option value="monatlich">Monthly</option>
<option value="zweimonatlich">Every 2 months</option>
<option value="vierteljährlich">Quarterly</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Start date</label>
<input type="date" name="startDate" value={form.startDate} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
</div>
<button
onClick={submit}
disabled={!canSubmit || submitLoading}
className={`group w-full mt-6 rounded-lg px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
canSubmit && !submitLoading ? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg' : 'bg-gray-200 text-gray-600 cursor-not-allowed'
}`}
>
{submitLoading ? 'Creating…' : 'Complete subscription'}
<svg className={`ml-2 h-5 w-5 transition-transform ${canSubmit ? 'group-hover:translate-x-0.5' : ''}`} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
{!canSubmit && <p className="text-xs text-gray-500 mt-2">Please select coffees and fill all fields.</p>}
</div>
</section>
{/* Right: Order summary */}
<section className="lg:col-span-1">
<h2 className="text-xl font-semibold mb-4">2. Your selection</h2>
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg lg:sticky lg:top-6">
{selectedEntries.map(entry => (
<div key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
<div className="flex flex-col">
<span className="font-medium">{entry.coffee.name}</span>
<span className="text-xs text-gray-500">
{entry.quantity} pcs <span className="inline-flex items-center font-semibold text-[#1C2B4A]">{entry.coffee.pricePer10}/10</span>
</span>
</div>
<div className="text-right font-semibold">{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</div>
</div>
))}
<div className="flex justify-between pt-2 border-t">
<span className="text-sm font-semibold">Total (net)</span>
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">{totalPrice.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm">Tax ({(taxRate * 100).toFixed(1)}%)</span>
<span className="text-sm font-medium">{taxAmount.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Total incl. tax</span>
<span className="text-xl font-extrabold text-[#1C2B4A]">{totalWithTax.toFixed(2)}</span>
</div>
{/* Validation summary (refined design) */}
<div className="mt-2 text-xs text-gray-700">
Selected: {totalCapsules} capsules ({totalPacks} packs of 10).
{totalPacks !== 12 && (
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
Exactly 12 packs (120 capsules) are required.
</span>
)}
</div>
</div>
</section>
</div>
)}
</div>
{/* Thank you overlay */}
{showThanks && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="relative mx-4 w-full max-w-md rounded-2xl bg-white p-8 text-center shadow-2xl">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{confetti.map((c, i) => (
<span key={i} className="confetti" style={{ left: `${c.left}%`, animationDelay: `${c.delay}s`, background: c.color }} />
))}
</div>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#1C2B4A]/10 text-[#1C2B4A] pop">
<svg viewBox="0 0 24 24" className="h-9 w-9" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h3 className="text-2xl font-bold">Thanks for your subscription!</h3>
<p className="mt-1 text-sm text-gray-600">We have received your order.</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">
Back to selection
</button>
<button onClick={() => setShowThanks(false)} className="rounded-lg border border-gray-300 px-4 py-2 font-semibold hover:bg-gray-50">
Close
</button>
</div>
<style jsx>{`
.confetti {
position: absolute;
top: -10%;
width: 8px;
height: 12px;
border-radius: 2px;
opacity: 0.9;
animation: fall 1.8s linear forwards;
}
@keyframes fall {
0% { transform: translateY(0) rotate(0deg); }
100% { transform: translateY(110vh) rotate(720deg); }
}
.pop {
animation: pop 450ms ease-out forwards;
}
@keyframes pop {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); }
}
`}</style>
</div>
</div>
)}
</PageLayout>
);
}

284
src/app/community/page.tsx Normal file
View File

@ -0,0 +1,284 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import useAuthStore from '../store/authStore'
import Header from '../components/nav/Header'
import Footer from '../components/Footer'
import {
UsersIcon,
ChatBubbleLeftRightIcon,
HeartIcon,
FireIcon,
TrophyIcon,
UserGroupIcon,
PlusIcon,
ArrowRightIcon
} from '@heroicons/react/24/outline'
export default function CommunityPage() {
const router = useRouter()
const user = useAuthStore(state => state.user)
// Redirect if not logged in
useEffect(() => {
if (!user) {
router.push('/login')
}
}, [user, router])
// Don't render if no user
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
<p className="text-[#4A4A4A]">Loading...</p>
</div>
</div>
)
}
// Mock community data
const communityStats = [
{ label: 'Members', value: '12,487', icon: UsersIcon, color: 'text-blue-600' },
{ label: 'Active Groups', value: '156', icon: UserGroupIcon, color: 'text-green-600' },
{ label: 'Discussions', value: '3,421', icon: ChatBubbleLeftRightIcon, color: 'text-purple-600' },
{ label: 'Daily Active', value: '2,186', icon: FireIcon, color: 'text-orange-600' }
]
const trendingGroups = [
{
name: 'Eco Warriors',
members: '1,284',
category: 'Sustainability',
image: '🌱',
description: 'Join fellow eco-enthusiasts in making the world greener'
},
{
name: 'Zero Waste Living',
members: '892',
category: 'Lifestyle',
image: '♻️',
description: 'Tips and tricks for living a zero-waste lifestyle'
},
{
name: 'Sustainable Fashion',
members: '756',
category: 'Fashion',
image: '👕',
description: 'Ethical fashion choices and sustainable brands'
},
{
name: 'Green Tech',
members: '634',
category: 'Technology',
image: '💚',
description: 'Discuss the latest in green technology and innovation'
}
]
const recentPosts = [
{
user: 'Sarah M.',
group: 'Eco Warriors',
time: '2 hours ago',
content: 'Just discovered a fantastic new way to upcycle old furniture! Has anyone tried painting with eco-friendly paints?',
likes: 23,
comments: 8
},
{
user: 'David K.',
group: 'Zero Waste Living',
time: '4 hours ago',
content: 'Week 3 of my zero-waste challenge! Managed to produce only 1 small jar of waste. Here are my top tips...',
likes: 45,
comments: 12
},
{
user: 'Maria L.',
group: 'Sustainable Fashion',
time: '6 hours ago',
content: 'Found an amazing local brand that makes clothes from recycled ocean plastic. Their quality is incredible!',
likes: 38,
comments: 15
}
]
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Header />
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
{/* Header Section */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Welcome to Profit Planet Community 🌍
</h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Connect with like-minded individuals, share sustainable practices, and make a positive impact together.
</p>
</div>
{/* Community Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-12">
{communityStats.map((stat, index) => (
<div key={index} className="bg-white rounded-lg p-6 shadow-sm border border-gray-200 text-center">
<stat.icon className={`h-8 w-8 ${stat.color} mx-auto mb-3`} />
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
<p className="text-sm text-gray-600">{stat.label}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-8">
{/* Trending Groups */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
<TrophyIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />
Trending Groups
</h2>
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium flex items-center">
View All
<ArrowRightIcon className="h-4 w-4 ml-1" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{trendingGroups.map((group, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer">
<div className="flex items-start space-x-3">
<div className="text-2xl">{group.image}</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{group.name}</h3>
<p className="text-xs text-[#8D6B1D] font-medium mb-1">{group.category}</p>
<p className="text-sm text-gray-600 mb-2">{group.description}</p>
<p className="text-xs text-gray-500">{group.members} members</p>
</div>
</div>
<button className="w-full mt-3 px-3 py-2 bg-[#8D6B1D]/10 text-[#8D6B1D] rounded-lg text-sm font-medium hover:bg-[#8D6B1D]/20 transition-colors">
Join Group
</button>
</div>
))}
</div>
</div>
{/* Recent Discussions */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
<ChatBubbleLeftRightIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />
Recent Discussions
</h2>
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium">
Start Discussion
</button>
</div>
<div className="space-y-6">
{recentPosts.map((post, index) => (
<div key={index} className="border-b border-gray-100 pb-6 last:border-b-0">
<div className="flex items-start space-x-3">
<div className="w-10 h-10 bg-[#8D6B1D]/20 rounded-full flex items-center justify-center">
<span className="text-sm font-semibold text-[#8D6B1D]">
{post.user.charAt(0)}
</span>
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<span className="font-medium text-gray-900">{post.user}</span>
<span className="text-gray-300"></span>
<span className="text-sm text-[#8D6B1D]">{post.group}</span>
<span className="text-gray-300"></span>
<span className="text-sm text-gray-500">{post.time}</span>
</div>
<p className="text-gray-800 mb-3">{post.content}</p>
<div className="flex items-center space-x-4">
<button className="flex items-center space-x-1 text-gray-500 hover:text-red-500 transition-colors">
<HeartIcon className="h-4 w-4" />
<span className="text-sm">{post.likes}</span>
</button>
<button className="flex items-center space-x-1 text-gray-500 hover:text-[#8D6B1D] transition-colors">
<ChatBubbleLeftRightIcon className="h-4 w-4" />
<span className="text-sm">{post.comments}</span>
</button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Quick Actions */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
<div className="space-y-3">
<button className="w-full flex items-center justify-center px-4 py-3 bg-[#8D6B1D] text-white rounded-lg hover:bg-[#7A5E1A] transition-colors">
<PlusIcon className="h-4 w-4 mr-2" />
Create Group
</button>
<button className="w-full flex items-center justify-center px-4 py-3 border border-[#8D6B1D] text-[#8D6B1D] rounded-lg hover:bg-[#8D6B1D]/10 transition-colors">
<ChatBubbleLeftRightIcon className="h-4 w-4 mr-2" />
Start Discussion
</button>
<button
onClick={() => router.push('/dashboard')}
className="w-full flex items-center justify-center px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Go to Dashboard
</button>
</div>
</div>
{/* My Groups */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">My Groups</h3>
<div className="space-y-3">
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
<div className="text-lg">🌱</div>
<div>
<p className="text-sm font-medium text-gray-900">Eco Warriors</p>
<p className="text-xs text-gray-500">1,284 members</p>
</div>
</div>
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
<div className="text-lg"></div>
<div>
<p className="text-sm font-medium text-gray-900">Zero Waste Living</p>
<p className="text-xs text-gray-500">892 members</p>
</div>
</div>
</div>
</div>
{/* Community Guidelines */}
<div className="bg-gradient-to-br from-[#8D6B1D]/10 to-[#C49225]/10 rounded-lg p-6 border border-[#8D6B1D]/20">
<h3 className="font-semibold text-gray-900 mb-2">Community Guidelines</h3>
<ul className="text-sm text-gray-700 space-y-1">
<li> Be respectful and kind</li>
<li> Stay on topic</li>
<li> Share authentic experiences</li>
<li> Help others learn and grow</li>
</ul>
<button className="text-xs text-[#8D6B1D] hover:underline mt-3">
Read full guidelines
</button>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}

View File

@ -0,0 +1,72 @@
'use client'
import { useEffect } from 'react'
import useAuthStore from '../store/authStore'
// Helper to decode JWT and get expiry
function getTokenExpiry(token: string | null): Date | null {
if (!token) return null;
try {
const [, payload] = token.split(".");
const { exp } = JSON.parse(atob(payload));
return exp ? new Date(exp * 1000) : null;
} catch {
return null;
}
}
export default function AuthInitializer({ children }: { children: React.ReactNode }) {
const { refreshAuthToken, setAuthReady, accessToken } = useAuthStore()
useEffect(() => {
const initializeAuth = async () => {
try {
// Try to refresh token from httpOnly cookie
await refreshAuthToken()
} catch (error) {
console.log('No valid refresh token found')
} finally {
// Set auth as ready regardless of success/failure
setAuthReady(true)
}
}
initializeAuth()
}, [refreshAuthToken, setAuthReady])
// Automatic token refresh - check every minute
useEffect(() => {
const interval = setInterval(async () => {
const currentToken = useAuthStore.getState().accessToken;
if (currentToken) {
const expiry = getTokenExpiry(currentToken);
if (expiry) {
const timeUntilExpiry = expiry.getTime() - Date.now();
const threeMinutes = 3 * 60 * 1000; // 3 minutes in milliseconds
// If token expires within 3 minutes, refresh it
if (timeUntilExpiry <= threeMinutes && timeUntilExpiry > 0) {
console.log('🔄 Token expires soon, auto-refreshing...', {
expiresIn: Math.round(timeUntilExpiry / 1000),
expiresAt: expiry.toLocaleTimeString()
});
try {
const success = await refreshAuthToken();
if (success) {
console.log('✅ Token auto-refresh successful');
} else {
console.log('❌ Token auto-refresh failed');
}
} catch (error) {
console.log('❌ Token auto-refresh error:', error);
}
}
}
}
}, 60000); // Check every minute
return () => clearInterval(interval);
}, [refreshAuthToken])
return <>{children}</>
}

View File

@ -0,0 +1,372 @@
import { forwardRef, useImperativeHandle, useEffect, useRef, useMemo, FC, ReactNode } from 'react';
import * as THREE from 'three';
import { Canvas, useFrame } from '@react-three/fiber';
import { PerspectiveCamera } from '@react-three/drei';
import { degToRad } from 'three/src/math/MathUtils.js';
type UniformValue = THREE.IUniform<unknown> | unknown;
interface ExtendMaterialConfig {
header: string;
vertexHeader?: string;
fragmentHeader?: string;
material?: THREE.MeshPhysicalMaterialParameters & { fog?: boolean };
uniforms?: Record<string, UniformValue>;
vertex?: Record<string, string>;
fragment?: Record<string, string>;
}
type ShaderWithDefines = THREE.ShaderLibShader & {
defines?: Record<string, string | number | boolean>;
};
function extendMaterial<T extends THREE.Material = THREE.Material>(
BaseMaterial: new (params?: THREE.MaterialParameters) => T,
cfg: ExtendMaterialConfig
): THREE.ShaderMaterial {
const physical = THREE.ShaderLib.physical as ShaderWithDefines;
const { vertexShader: baseVert, fragmentShader: baseFrag, uniforms: baseUniforms } = physical;
const baseDefines = physical.defines ?? {};
const uniforms: Record<string, THREE.IUniform> = THREE.UniformsUtils.clone(baseUniforms);
const defaults = new BaseMaterial(cfg.material || {}) as T & {
color?: THREE.Color;
roughness?: number;
metalness?: number;
envMap?: THREE.Texture;
envMapIntensity?: number;
};
if (defaults.color) uniforms.diffuse.value = defaults.color;
if ('roughness' in defaults) uniforms.roughness.value = defaults.roughness;
if ('metalness' in defaults) uniforms.metalness.value = defaults.metalness;
if ('envMap' in defaults) uniforms.envMap.value = defaults.envMap;
if ('envMapIntensity' in defaults) uniforms.envMapIntensity.value = defaults.envMapIntensity;
Object.entries(cfg.uniforms ?? {}).forEach(([key, u]) => {
uniforms[key] =
u !== null && typeof u === 'object' && 'value' in u
? (u as THREE.IUniform<unknown>)
: ({ value: u } as THREE.IUniform<unknown>);
});
let vert = `${cfg.header}\n${cfg.vertexHeader ?? ''}\n${baseVert}`;
let frag = `${cfg.header}\n${cfg.fragmentHeader ?? ''}\n${baseFrag}`;
for (const [inc, code] of Object.entries(cfg.vertex ?? {})) {
vert = vert.replace(inc, `${inc}\n${code}`);
}
for (const [inc, code] of Object.entries(cfg.fragment ?? {})) {
frag = frag.replace(inc, `${inc}\n${code}`);
}
const mat = new THREE.ShaderMaterial({
defines: { ...baseDefines },
uniforms,
vertexShader: vert,
fragmentShader: frag,
lights: true,
fog: !!cfg.material?.fog
});
return mat;
}
const CanvasWrapper: FC<{ children: ReactNode }> = ({ children }) => (
<Canvas dpr={[1, 2]} frameloop="always" className="w-full h-full relative">
{children}
</Canvas>
);
const hexToNormalizedRGB = (hex: string): [number, number, number] => {
const clean = hex.replace('#', '');
const r = parseInt(clean.substring(0, 2), 16);
const g = parseInt(clean.substring(2, 4), 16);
const b = parseInt(clean.substring(4, 6), 16);
return [r / 255, g / 255, b / 255];
};
const noise = `
float random (in vec2 st) {
return fract(sin(dot(st.xy,
vec2(12.9898,78.233)))*
43758.5453123);
}
float noise (in vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) +
(c - a)* u.y * (1.0 - u.x) +
(d - b) * u.x * u.y;
}
vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}
vec3 fade(vec3 t) {return t*t*t*(t*(t*6.0-15.0)+10.0);}
float cnoise(vec3 P){
vec3 Pi0 = floor(P);
vec3 Pi1 = Pi0 + vec3(1.0);
Pi0 = mod(Pi0, 289.0);
Pi1 = mod(Pi1, 289.0);
vec3 Pf0 = fract(P);
vec3 Pf1 = Pf0 - vec3(1.0);
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
vec4 iy = vec4(Pi0.yy, Pi1.yy);
vec4 iz0 = Pi0.zzzz;
vec4 iz1 = Pi1.zzzz;
vec4 ixy = permute(permute(ix) + iy);
vec4 ixy0 = permute(ixy + iz0);
vec4 ixy1 = permute(ixy + iz1);
vec4 gx0 = ixy0 / 7.0;
vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
gx0 = fract(gx0);
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
vec4 sz0 = step(gz0, vec4(0.0));
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
vec4 gx1 = ixy1 / 7.0;
vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
gx1 = fract(gx1);
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
vec4 sz1 = step(gz1, vec4(0.0));
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);
vec4 norm0 = taylorInvSqrt(vec4(dot(g000,g000),dot(g010,g010),dot(g100,g100),dot(g110,g110)));
g000 *= norm0.x; g010 *= norm0.y; g100 *= norm0.z; g110 *= norm0.w;
vec4 norm1 = taylorInvSqrt(vec4(dot(g001,g001),dot(g011,g011),dot(g101,g101),dot(g111,g111)));
g001 *= norm1.x; g011 *= norm1.y; g101 *= norm1.z; g111 *= norm1.w;
float n000 = dot(g000, Pf0);
float n100 = dot(g100, vec3(Pf1.x,Pf0.yz));
float n010 = dot(g010, vec3(Pf0.x,Pf1.y,Pf0.z));
float n110 = dot(g110, vec3(Pf1.xy,Pf0.z));
float n001 = dot(g001, vec3(Pf0.xy,Pf1.z));
float n101 = dot(g101, vec3(Pf1.x,Pf0.y,Pf1.z));
float n011 = dot(g011, vec3(Pf0.x,Pf1.yz));
float n111 = dot(g111, Pf1);
vec3 fade_xyz = fade(Pf0);
vec4 n_z = mix(vec4(n000,n100,n010,n110),vec4(n001,n101,n011,n111),fade_xyz.z);
vec2 n_yz = mix(n_z.xy,n_z.zw,fade_xyz.y);
float n_xyz = mix(n_yz.x,n_yz.y,fade_xyz.x);
return 2.2 * n_xyz;
}
`;
interface BeamsProps {
beamWidth?: number;
beamHeight?: number;
beamNumber?: number;
lightColor?: string;
speed?: number;
noiseIntensity?: number;
scale?: number;
rotation?: number;
}
const Beams: FC<BeamsProps> = ({
beamWidth = 2,
beamHeight = 15,
beamNumber = 12,
lightColor = '#ffffff',
speed = 2,
noiseIntensity = 1.75,
scale = 0.2,
rotation = 0
}) => {
const meshRef = useRef<THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>>(null!);
const beamMaterial = useMemo(
() =>
extendMaterial(THREE.MeshStandardMaterial, {
header: `
varying vec3 vEye;
varying float vNoise;
varying vec2 vUv;
varying vec3 vPosition;
uniform float time;
uniform float uSpeed;
uniform float uNoiseIntensity;
uniform float uScale;
${noise}`,
vertexHeader: `
float getPos(vec3 pos) {
vec3 noisePos =
vec3(pos.x * 0., pos.y - uv.y, pos.z + time * uSpeed * 3.) * uScale;
return cnoise(noisePos);
}
vec3 getCurrentPos(vec3 pos) {
vec3 newpos = pos;
newpos.z += getPos(pos);
return newpos;
}
vec3 getNormal(vec3 pos) {
vec3 curpos = getCurrentPos(pos);
vec3 nextposX = getCurrentPos(pos + vec3(0.01, 0.0, 0.0));
vec3 nextposZ = getCurrentPos(pos + vec3(0.0, -0.01, 0.0));
vec3 tangentX = normalize(nextposX - curpos);
vec3 tangentZ = normalize(nextposZ - curpos);
return normalize(cross(tangentZ, tangentX));
}`,
fragmentHeader: '',
vertex: {
'#include <begin_vertex>': `transformed.z += getPos(transformed.xyz);`,
'#include <beginnormal_vertex>': `objectNormal = getNormal(position.xyz);`
},
fragment: {
'#include <dithering_fragment>': `
float randomNoise = noise(gl_FragCoord.xy);
gl_FragColor.rgb -= randomNoise / 15. * uNoiseIntensity;`
},
material: { fog: true },
uniforms: {
diffuse: new THREE.Color(...hexToNormalizedRGB('#000000')),
time: { shared: true, mixed: true, linked: true, value: 0 },
roughness: 0.3,
metalness: 0.3,
uSpeed: { shared: true, mixed: true, linked: true, value: speed },
envMapIntensity: 10,
uNoiseIntensity: noiseIntensity,
uScale: scale
}
}),
[speed, noiseIntensity, scale]
);
return (
<CanvasWrapper>
<group rotation={[0, 0, degToRad(rotation)]}>
<PlaneNoise ref={meshRef} material={beamMaterial} count={beamNumber} width={beamWidth} height={beamHeight} />
<DirLight color={lightColor} position={[0, 3, 10]} />
</group>
<ambientLight intensity={1} />
<color attach="background" args={['#000000']} />
<PerspectiveCamera makeDefault position={[0, 0, 20]} fov={30} />
</CanvasWrapper>
);
};
function createStackedPlanesBufferGeometry(
n: number,
width: number,
height: number,
spacing: number,
heightSegments: number
): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry();
const numVertices = n * (heightSegments + 1) * 2;
const numFaces = n * heightSegments * 2;
const positions = new Float32Array(numVertices * 3);
const indices = new Uint32Array(numFaces * 3);
const uvs = new Float32Array(numVertices * 2);
let vertexOffset = 0;
let indexOffset = 0;
let uvOffset = 0;
const totalWidth = n * width + (n - 1) * spacing;
const xOffsetBase = -totalWidth / 2;
for (let i = 0; i < n; i++) {
const xOffset = xOffsetBase + i * (width + spacing);
const uvXOffset = Math.random() * 300;
const uvYOffset = Math.random() * 300;
for (let j = 0; j <= heightSegments; j++) {
const y = height * (j / heightSegments - 0.5);
const v0 = [xOffset, y, 0];
const v1 = [xOffset + width, y, 0];
positions.set([...v0, ...v1], vertexOffset * 3);
const uvY = j / heightSegments;
uvs.set([uvXOffset, uvY + uvYOffset, uvXOffset + 1, uvY + uvYOffset], uvOffset);
if (j < heightSegments) {
const a = vertexOffset,
b = vertexOffset + 1,
c = vertexOffset + 2,
d = vertexOffset + 3;
indices.set([a, b, c, c, b, d], indexOffset);
indexOffset += 6;
}
vertexOffset += 2;
uvOffset += 4;
}
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
geometry.computeVertexNormals();
return geometry;
}
const MergedPlanes = forwardRef<
THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>,
{
material: THREE.ShaderMaterial;
width: number;
count: number;
height: number;
}
>(({ material, width, count, height }, ref) => {
const mesh = useRef<THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>>(null!);
useImperativeHandle(ref, () => mesh.current);
const geometry = useMemo(
() => createStackedPlanesBufferGeometry(count, width, height, 0, 100),
[count, width, height]
);
useFrame((_, delta) => {
mesh.current.material.uniforms.time.value += 0.1 * delta;
});
return <mesh ref={mesh} geometry={geometry} material={material} />;
});
MergedPlanes.displayName = 'MergedPlanes';
const PlaneNoise = forwardRef<
THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>,
{
material: THREE.ShaderMaterial;
width: number;
count: number;
height: number;
}
>((props, ref) => (
<MergedPlanes ref={ref} material={props.material} width={props.width} count={props.count} height={props.height} />
));
PlaneNoise.displayName = 'PlaneNoise';
const DirLight: FC<{ position: [number, number, number]; color: string }> = ({ position, color }) => {
const dir = useRef<THREE.DirectionalLight>(null!);
useEffect(() => {
if (!dir.current) return;
const cam = dir.current.shadow.camera as THREE.Camera & {
top: number;
bottom: number;
left: number;
right: number;
far: number;
};
cam.top = 24;
cam.bottom = -24;
cam.left = -24;
cam.right = 24;
cam.far = 64;
dir.current.shadow.bias = -0.004;
}, []);
return <directionalLight ref={dir} color={color} intensity={1} position={position} />;
};
export default Beams;

View File

@ -0,0 +1,198 @@
'use client'
import React, { useEffect, useRef, RefObject } from 'react'
import { gsap } from 'gsap'
const lerp = (a: number, b: number, n: number): number => (1 - n) * a + n * b
const getMousePos = (e: MouseEvent, container?: HTMLElement | null): { x: number; y: number } => {
if (container) {
const bounds = container.getBoundingClientRect()
return {
x: e.clientX - bounds.left,
y: e.clientY - bounds.top,
}
}
return { x: e.clientX, y: e.clientY }
}
interface CrosshairProps {
color?: string
containerRef?: RefObject<HTMLDivElement | null> | null
}
const Crosshair: React.FC<CrosshairProps> = ({ color = 'white', containerRef = null }) => {
const cursorRef = useRef<HTMLDivElement>(null)
const lineHorizontalRef = useRef<HTMLDivElement>(null)
const lineVerticalRef = useRef<HTMLDivElement>(null)
const filterXRef = useRef<SVGFETurbulenceElement>(null)
const filterYRef = useRef<SVGFETurbulenceElement>(null)
let mouse = { x: 0, y: 0 }
useEffect(() => {
const handleMouseMove = (ev: Event) => {
const mouseEvent = ev as MouseEvent
mouse = getMousePos(mouseEvent, containerRef?.current || undefined)
if (containerRef?.current) {
const bounds = containerRef.current.getBoundingClientRect()
if (
mouseEvent.clientX < bounds.left ||
mouseEvent.clientX > bounds.right ||
mouseEvent.clientY < bounds.top ||
mouseEvent.clientY > bounds.bottom
) {
gsap.to([lineHorizontalRef.current, lineVerticalRef.current].filter(Boolean), { opacity: 0 })
} else {
gsap.to([lineHorizontalRef.current, lineVerticalRef.current].filter(Boolean), { opacity: 1 })
}
}
}
const target: HTMLElement | Window = containerRef?.current || window
target.addEventListener('mousemove', handleMouseMove)
const renderedStyles: {
[key: string]: { previous: number; current: number; amt: number }
} = {
tx: { previous: 0, current: 0, amt: 0.15 },
ty: { previous: 0, current: 0, amt: 0.15 },
}
gsap.set([lineHorizontalRef.current, lineVerticalRef.current].filter(Boolean), { opacity: 0 })
const onMouseMove = (ev: Event) => {
const mouseEvent = ev as MouseEvent
mouse = getMousePos(mouseEvent, containerRef?.current || undefined)
renderedStyles.tx.previous = renderedStyles.tx.current = mouse.x
renderedStyles.ty.previous = renderedStyles.ty.current = mouse.y
gsap.to([lineHorizontalRef.current, lineVerticalRef.current].filter(Boolean), {
duration: 0.9,
ease: 'Power3.easeOut',
opacity: 1,
})
requestAnimationFrame(render)
target.removeEventListener('mousemove', onMouseMove)
}
target.addEventListener('mousemove', onMouseMove)
const primitiveValues = { turbulence: 0 }
const tl = gsap
.timeline({
paused: true,
onStart: () => {
if (lineHorizontalRef.current) {
lineHorizontalRef.current.style.filter = 'url(#filter-noise-x)'
}
if (lineVerticalRef.current) {
lineVerticalRef.current.style.filter = 'url(#filter-noise-y)'
}
},
onUpdate: () => {
if (filterXRef.current && filterYRef.current) {
filterXRef.current.setAttribute('baseFrequency', primitiveValues.turbulence.toString())
filterYRef.current.setAttribute('baseFrequency', primitiveValues.turbulence.toString())
}
},
onComplete: () => {
if (lineHorizontalRef.current) lineHorizontalRef.current.style.filter = 'none'
if (lineVerticalRef.current) lineVerticalRef.current.style.filter = 'none'
},
})
.to(primitiveValues, {
duration: 0.5,
ease: 'power1',
startAt: { turbulence: 1 },
turbulence: 0,
})
const enter = () => tl.restart()
const leave = () => {
tl.progress(1).kill()
}
const render = () => {
renderedStyles.tx.current = mouse.x
renderedStyles.ty.current = mouse.y
for (const key in renderedStyles) {
const style = renderedStyles[key]
style.previous = lerp(style.previous, style.current, style.amt)
}
if (lineHorizontalRef.current && lineVerticalRef.current) {
gsap.set(lineVerticalRef.current, { x: renderedStyles.tx.previous })
gsap.set(lineHorizontalRef.current, { y: renderedStyles.ty.previous })
}
requestAnimationFrame(render)
}
const links: NodeListOf<HTMLAnchorElement> = containerRef?.current
? containerRef.current.querySelectorAll('a')
: document.querySelectorAll('a')
links.forEach(link => {
link.addEventListener('mouseenter', enter)
link.addEventListener('mouseleave', leave)
})
return () => {
target.removeEventListener('mousemove', handleMouseMove)
target.removeEventListener('mousemove', onMouseMove)
links.forEach(link => {
link.removeEventListener('mouseenter', enter)
link.removeEventListener('mouseleave', leave)
})
}
}, [containerRef])
return (
<div
ref={cursorRef}
className={`${containerRef ? 'absolute' : 'fixed'} top-0 left-0 w-full h-full pointer-events-none z-[10000]`}
>
<svg className="absolute top-0 left-0 w-full h-full">
<defs>
<filter id="filter-noise-x">
<feTurbulence
type="fractalNoise"
baseFrequency="0.000001"
numOctaves="1"
ref={filterXRef}
/>
<feDisplacementMap in="SourceGraphic" scale="40" />
</filter>
<filter id="filter-noise-y">
<feTurbulence
type="fractalNoise"
baseFrequency="0.000001"
numOctaves="1"
ref={filterYRef}
/>
<feDisplacementMap in="SourceGraphic" scale="40" />
</filter>
</defs>
</svg>
<div
ref={lineHorizontalRef}
className="absolute w-full h-px pointer-events-none opacity-0 translate-y-1/2"
style={{ background: color }}
/>
<div
ref={lineVerticalRef}
className="absolute h-full w-px pointer-events-none opacity-0 translate-x-1/2"
style={{ background: color }}
/>
</div>
)
}
export default Crosshair

View File

@ -0,0 +1,26 @@
import React from 'react';
import { useTranslation } from '../i18n/useTranslation';
export default function Footer() {
const { t } = useTranslation();
return (
<footer className="relative z-50 w-full bg-[#0F172A] py-4 px-6 shadow-inner">
<div className="container mx-auto flex justify-between items-center">
<div className="text-sm text-white/70">
© {new Date().getFullYear()} {t('footer.company')} - {t('footer.rights')}
</div>
<div className="flex space-x-4">
<a href="#" className="text-sm text-white/70 hover:text-[#8D6B1D] transition-colors">
{t('footer.privacy')}
</a>
<a href="#" className="text-sm text-white/70 hover:text-[#8D6B1D] transition-colors">
{t('footer.terms')}
</a>
<a href="#" className="text-sm text-white/70 hover:text-[#8D6B1D] transition-colors">
{t('footer.contact')}
</a>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,96 @@
'use client';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { useTranslation } from '../i18n/useTranslation';
import { SUPPORTED_LANGUAGES, LANGUAGE_NAMES } from '../i18n/config';
interface LanguageSwitcherProps {
variant?: 'light' | 'dark';
}
// Flag Icons mit Emoji (viel sauberer als selbst gezeichnete CSS-Flaggen)
const FlagIcon = ({ countryCode, className = "size-5" }: { countryCode: string; className?: string }) => {
const flags = {
'de': '🇩🇪',
'en': '🇬🇧'
};
return (
<span className={`${className} flex items-center justify-center text-base`}>
{flags[countryCode as keyof typeof flags] || '🏳️'}
</span>
);
};
export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcherProps) {
const { language, setLanguage } = useTranslation();
const getButtonStyles = () => {
if (variant === 'dark') {
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white/10 px-3 py-2 text-sm font-semibold text-white inset-ring-1 inset-ring-white/5 hover:bg-white/20';
}
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-gray-100 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-gray-300 hover:bg-gray-200';
};
const getMenuStyles = () => {
if (variant === 'dark') {
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-white/10 rounded-md bg-gray-800 outline-1 -outline-offset-1 outline-white/10 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
}
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-100 rounded-md bg-white outline-1 -outline-offset-1 outline-gray-200 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
};
const getItemStyles = (isActive: boolean) => {
if (variant === 'dark') {
return `group flex items-center px-4 py-2 text-sm ${
isActive
? 'bg-[#8D6B1D] text-white'
: 'text-gray-300 data-focus:bg-white/5 data-focus:text-white data-focus:outline-hidden'
}`;
}
return `group flex items-center px-4 py-2 text-sm ${
isActive
? 'bg-[#8D6B1D] text-white'
: 'text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden'
}`;
};
return (
<Menu as="div" className="relative inline-block">
<MenuButton className={getButtonStyles()}>
<FlagIcon countryCode={language} className="size-4" />
{LANGUAGE_NAMES[language]}
<ChevronDownIcon aria-hidden="true" className="-mr-1 size-5 text-gray-500" />
</MenuButton>
<MenuItems
transition
className={getMenuStyles()}
>
<div className="py-1">
{SUPPORTED_LANGUAGES.map((lang) => (
<MenuItem key={lang}>
<button
onClick={() => setLanguage(lang)}
className={getItemStyles(language === lang)}
>
<FlagIcon
countryCode={lang}
className={`mr-3 size-5 ${
variant === 'dark'
? (language === lang ? 'opacity-100' : 'opacity-70 group-data-focus:opacity-100')
: (language === lang ? 'opacity-100' : 'opacity-80 group-data-focus:opacity-100')
}`}
/>
<span className="flex-1 text-left">{LANGUAGE_NAMES[lang]}</span>
{language === lang && (
<span className="ml-2 text-xs font-bold"></span>
)}
</button>
</MenuItem>
))}
</div>
</MenuItems>
</Menu>
);
}

View File

@ -0,0 +1,81 @@
'use client';
import React, { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import Header from './nav/Header';
import Footer from './Footer';
import PageTransitionEffect from './animation/pageTransitionEffect';
// Utility to detect mobile devices
function isMobileDevice() {
if (typeof navigator === 'undefined') return false;
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
interface PageLayoutProps {
children: React.ReactNode;
showHeader?: boolean;
showFooter?: boolean;
className?: string;
contentClassName?: string;
}
export default function PageLayout({
children,
showHeader = true,
showFooter = true,
className = 'bg-white text-gray-900',
contentClassName = 'flex-1 relative z-10 w-full',
}: PageLayoutProps) {
const isMobile = isMobileDevice();
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
const pathname = usePathname();
// Global scrollbar restore / leak cleanup (runs on navigation)
useEffect(() => {
const html = document.documentElement;
const body = document.body;
// ensure a visible/stable vertical scrollbar on desktop
html.style.overflowY = 'scroll';
body.style.overflowY = 'auto';
// clear common scroll-lock leftovers (gap where scrollbar should be)
if (html.style.overflow === 'hidden') html.style.overflow = '';
if (body.style.overflow === 'hidden') body.style.overflow = '';
html.style.paddingRight = '';
body.style.paddingRight = '';
}, [pathname]);
return (
<div className={`min-h-screen w-full flex flex-col ${className}`}>
{showHeader && (
<div className="relative z-50 w-full flex-shrink-0">
<Header setGlobalLoggingOut={setIsLoggingOut} />
</div>
)}
{/* Main content */}
<div className={contentClassName}>
<PageTransitionEffect>{children}</PageTransitionEffect>
</div>
{showFooter && (
<div className="relative z-50 w-full flex-shrink-0">
<Footer />
</div>
)}
{/* Global logout transition overlay (covers whole page) */}
{isLoggingOut && (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3 text-white">
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white animate-spin" />
<p className="text-sm font-medium">Logging you out...</p>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,223 @@
import React, { useRef, useEffect, useState } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);
export interface SplitTextProps {
text: string;
className?: string;
delay?: number;
duration?: number;
ease?: string | ((t: number) => number);
splitType?: 'chars' | 'words' | 'lines' | 'words, chars';
from?: gsap.TweenVars;
to?: gsap.TweenVars;
threshold?: number;
rootMargin?: string;
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
textAlign?: React.CSSProperties['textAlign'];
onLetterAnimationComplete?: () => void;
}
const SplitText: React.FC<SplitTextProps> = ({
text,
className = '',
delay = 50,
duration = 1.25,
ease = 'power3.out',
splitType = 'chars',
from = { opacity: 0, y: 40 },
to = { opacity: 1, y: 0 },
threshold = 0.1,
rootMargin = '-100px',
tag = 'p',
textAlign = 'center',
onLetterAnimationComplete
}) => {
const ref = useRef<HTMLParagraphElement>(null);
const animationCompletedRef = useRef(false);
const onCompleteRef = useRef(onLetterAnimationComplete);
const [fontsLoaded, setFontsLoaded] = useState<boolean>(false);
useEffect(() => {
onCompleteRef.current = onLetterAnimationComplete;
}, [onLetterAnimationComplete]);
// Reset animation completion when text changes so we can re-split/re-animate
useEffect(() => {
animationCompletedRef.current = false;
}, [text]);
useEffect(() => {
if (document.fonts.status === 'loaded') {
setFontsLoaded(true);
} else {
document.fonts.ready.then(() => {
setFontsLoaded(true);
});
}
}, []);
useGSAP(
() => {
if (!ref.current || !text || !fontsLoaded) return;
if (animationCompletedRef.current) return;
const el = ref.current as HTMLElement & {
_rbsplitInstance?: GSAPSplitText;
};
if (el._rbsplitInstance) {
try {
el._rbsplitInstance.revert();
} catch (_) {}
el._rbsplitInstance = undefined;
}
const startPct = (1 - threshold) * 100;
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin);
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0;
const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px';
const sign =
marginValue === 0
? ''
: marginValue < 0
? `-=${Math.abs(marginValue)}${marginUnit}`
: `+=${marginValue}${marginUnit}`;
const start = `top ${startPct}%${sign}`;
let targets: Element[] = [];
const assignTargets = (self: GSAPSplitText) => {
if (splitType.includes('chars') && (self as GSAPSplitText).chars?.length)
targets = (self as GSAPSplitText).chars;
if (!targets.length && splitType.includes('words') && self.words.length) targets = self.words;
if (!targets.length && splitType.includes('lines') && self.lines.length) targets = self.lines;
if (!targets.length) targets = self.chars || self.words || self.lines;
};
const splitInstance = new GSAPSplitText(el, {
type: splitType,
smartWrap: true,
autoSplit: splitType === 'lines',
linesClass: 'split-line',
wordsClass: 'split-word',
charsClass: 'split-char',
reduceWhiteSpace: false,
onSplit: (self: GSAPSplitText) => {
assignTargets(self);
return gsap.fromTo(
targets,
{ ...from },
{
...to,
duration,
ease,
stagger: delay / 1000,
scrollTrigger: {
trigger: el,
start,
once: true,
fastScrollEnd: true,
anticipatePin: 0.4
},
onComplete: () => {
animationCompletedRef.current = true;
onCompleteRef.current?.();
},
willChange: 'transform, opacity',
force3D: true
}
);
}
});
el._rbsplitInstance = splitInstance;
return () => {
ScrollTrigger.getAll().forEach(st => {
if (st.trigger === el) st.kill();
});
try {
splitInstance.revert();
} catch (_) {}
el._rbsplitInstance = undefined;
};
},
{
dependencies: [
text,
delay,
duration,
ease,
splitType,
JSON.stringify(from),
JSON.stringify(to),
threshold,
rootMargin,
fontsLoaded
],
scope: ref
}
);
const renderTag = () => {
const style: React.CSSProperties = {
textAlign,
wordWrap: 'break-word',
willChange: 'transform, opacity'
};
const classes = `split-parent overflow-hidden inline-block whitespace-normal ${className}`;
switch (tag) {
case 'h1':
return (
<h1 ref={ref} style={style} className={classes}>
{text}
</h1>
);
case 'h2':
return (
<h2 ref={ref} style={style} className={classes}>
{text}
</h2>
);
case 'h3':
return (
<h3 ref={ref} style={style} className={classes}>
{text}
</h3>
);
case 'h4':
return (
<h4 ref={ref} style={style} className={classes}>
{text}
</h4>
);
case 'h5':
return (
<h5 ref={ref} style={style} className={classes}>
{text}
</h5>
);
case 'h6':
return (
<h6 ref={ref} style={style} className={classes}>
{text}
</h6>
);
default:
return (
<p ref={ref} style={style} className={classes}>
{text}
</p>
);
}
};
return renderTag();
};
export default SplitText;

Some files were not shown because too many files have changed in this diff Show More