Compare commits

...

144 Commits
main ... dev

Author SHA1 Message Date
DeathKaioken
20c39fcd4e feat: invoice 2025-12-15 16:59:16 +01:00
seaznCode
7d908caec3 feat: enhance news management and display features 2025-12-14 18:20:50 +01:00
DeathKaioken
0a8c570610 feat: contract + referral adjustments 2025-12-13 12:00:30 +01:00
DeathKaioken
ac358d4d7d feat: abo + profile section 2025-12-13 11:17:48 +01:00
seaznCode
b8a67f0a2b Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev 2025-12-09 16:40:37 +01:00
seaznCode
615c5e7e0b news - links missing in UI 2025-12-09 16:40:30 +01:00
DeathKaioken
16ccb8de12 beautify: create coffee 2025-12-08 09:51:20 +01:00
seaznCode
1c87ba150e CropModals: two different for now because of aspect ratios - maybe merge into one 2025-12-06 20:30:33 +01:00
seaznCode
20c71636f6 feat: Enhance subscription creation and editing with image cropping and improved UI
- Added image cropping functionality in CreateSubscriptionPage and EditSubscriptionPage.
- Updated price input to handle decimal values and formatting.
- Improved UI elements for image upload sections, including better messaging and styling.
- Refactored affiliate links page to fetch data from an API and handle loading/error states.
- Added Affiliate Management button in the header for easier navigation.
2025-12-06 20:29:58 +01:00
seaznCode
0662044b85 fix: update button label from 'Coffee Subscription Management' to 'Coffee Management' 2025-12-06 14:28:57 +01:00
seaznCode
540096c7bc chore: remove unnecessary peer dependencies from package-lock.json 2025-12-06 14:28:48 +01:00
DeathKaioken
48c50ee5f1 refactor: matrix stuff 2025-12-06 12:34:04 +01:00
DeathKaioken
25b8e10ca0 feat: added tax rates to abo 2025-12-06 11:57:11 +01:00
DeathKaioken
f1e344ae00 feat: next upgrade 2025-12-06 11:17:15 +01:00
DeathKaioken
aa5e3ed1c0 feat: add financial manager with vat backend other still dummy 2025-12-06 11:13:40 +01:00
DeathKaioken
bd737e48b8 feat: add backend coffeeabo 2025-12-06 10:06:45 +01:00
seaznCode
6a96b27d2e feat: update pool management functionality with new fields and state handling 2025-12-04 18:26:51 +01:00
seaznCode
6831b92169 add comment for functionality explanation 2025-12-01 21:15:41 +01:00
seaznCode
7b6735be0e feat: add middleware to protect admin routes with authentication check 2025-11-30 19:50:33 +01:00
seaznCode
05f1773d87 refactor: adjust profile completion logic based on admin verification status 2025-11-30 19:48:31 +01:00
seaznCode
09673fd8a9 refactor: update personal and company user type handling in BasicInformation component 2025-11-30 13:31:43 +01:00
seaznCode
fa1058381f refactor: update ID input fields and button text for consistency in Company ID upload flow 2025-11-30 13:31:34 +01:00
seaznCode
da7047566e refactor: enhance profile data handling and loading state in ProfilePage 2025-11-30 13:31:20 +01:00
seaznCode
01ce0f5346 refactor: enhance user status display and button accessibility in UserDetailModal 2025-11-30 13:30:16 +01:00
seaznCode
e261baa7ed refactor: update button text from 'Back to Dashboard' to 'Go to Dashboard' 2025-11-30 13:30:02 +01:00
seaznCode
c94b7a91e8 refactor: streamline user status handling by using backend status directly 2025-11-30 13:29:56 +01:00
seaznCode
23691ec50c Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev 2025-11-30 13:29:37 +01:00
seaznCode
fbe8f09f2f feat: add dashboard button to header for quick navigation 2025-11-30 13:29:35 +01:00
DeathKaioken
6e2298eca9 feat: add personal matrix 2025-11-30 13:24:43 +01:00
DeathKaioken
c1e250bab1 beautify: loginform 2025-11-30 12:33:31 +01:00
DeathKaioken
18a873ffe3 feat: add matrix managemet backend 2025-11-30 12:21:10 +01:00
seaznCode
6bf1ca006e feat: update success messages for document uploads and contract signing to include redirect notice + remove cow 2025-11-29 15:23:18 +01:00
seaznCode
1f91f09777 feat: comment out registration link in LoginForm component 2025-11-29 13:55:19 +01:00
seaznCode
c0a1879c95 feat: enhance coffee management with image upload and preview functionality + change it from "Create Subscriptions" to "Create Coffee" 2025-11-29 13:50:40 +01:00
DeathKaioken
51c54eb905 feat: pool management fuckhead 2025-11-29 13:13:36 +01:00
seaznCode
198e41e601 feat: edit subscription 2025-11-20 17:48:51 +01:00
seaznCode
d54a4024cb feat: Implement subscription deletion confirmation modal in admin subscriptions page
refactor: Update header component to conditionally show shop navigation

feat: Add environment variable check to control shop visibility in public and VIP shop pages

refactor: Clean up VIP shop page by removing unused collections and featured products sections
2025-11-20 17:37:56 +01:00
DeathKaioken
cbf81e756b feat: profile backend link | NOT DONE 2025-11-18 01:21:23 +01:00
DeathKaioken
805ed1fdf2 beautify: admin redesign 2025-11-17 23:02:41 +01:00
DeathKaioken
2439928eff beautify: admin navigation 2025-11-17 22:36:08 +01:00
DeathKaioken
0b325bf44c feat: matrix management backend link #1 2025-11-17 22:11:53 +01:00
DeathKaioken
e7bfe43250 beautify: added all needed sections for profile page --> dummy data 2025-11-17 18:03:29 +01:00
DeathKaioken
fb67f4b680 bug: merge conflict 2025-11-17 17:23:16 +01:00
DeathKaioken
9e194da309 bug: loginform + header navigation adjusted 2025-11-17 17:19:11 +01:00
DeathKaioken
aa447348b2 feat: add user coffee abo site 2025-11-17 17:08:08 +01:00
seaznCode
886919e4dc Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev 2025-11-13 20:14:10 +01:00
seaznCode
ea1cba42bd feat: Implement Create Subscription page and Coffee management hooks 2025-11-13 20:13:27 +01:00
DeathKaioken
757b530e14 feat: add coffee- abo page dummy data 2025-11-13 19:35:12 +01:00
05cbe87d60 Merge pull request 'sz/contract-mgmt' (#6) from sz/contract-mgmt into dev
Reviewed-on: #6
2025-11-08 14:58:56 +00:00
seaznCode
a450637194 feat: Implement contract preview loading and refresh functionality in CompanySignContractPage 2025-11-08 15:46:59 +01:00
seaznCode
0d225cb0ac feat: Add contract preview functionality in UserDetailModal and PersonalSignContractPage 2025-11-04 20:54:02 +01:00
seaznCode
6f8573fe16 feat: Add UserDetailModal component and update API for user status management
- Implemented UserDetailModal component for displaying and editing user details.
- Added functionality for archiving/unarchiving users and toggling admin verification.
- Enhanced user profile editing capabilities with form inputs for personal and company profiles.
- Introduced loading and error handling states for better user experience.
- Updated API utility to include a new endpoint for updating user status.
- Modified DetailedUserInfo interface to accommodate new user role 'super_admin'.
2025-11-01 18:47:21 +01:00
DeathKaioken
294d4eb8a3 bug: hydration fix 2025-10-28 22:01:29 +01:00
DeathKaioken
2eca3007e4 feat: add contract Management 2025-10-28 21:55:47 +01:00
DeathKaioken
f7205ed8f6 beautify: page tranistion / loginform 2025-10-28 20:17:13 +01:00
seaznCode
5709f48dc3 feat: implement user archiving and unarchiving functionality in admin panel 2025-10-23 21:30:29 +02:00
seaznCode
4e9ae3a826 style: enhance styling for token refresh test page components 2025-10-23 19:58:08 +02:00
seaznCode
d764532d24 feat: update tutorial steps and enhance contract signing flow with tutorial parameter handling 2025-10-22 21:55:22 +02:00
seaznCode
5174b3dc78 Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev 2025-10-22 21:43:35 +02:00
seaznCode
4024085dc4 feat: implement tutorial flow with URL parameter handling for navigation 2025-10-22 21:43:29 +02:00
DeathKaioken
ac4b742214 feat: add matrix detail with dummy data 2025-10-22 20:59:47 +02:00
DeathKaioken
9f5da2c43d beautify: modal registered user component 2025-10-22 20:28:02 +02:00
DeathKaioken
6fa4f02fb2 feat: added level tracker and registereed users via your referral 2025-10-22 20:25:16 +02:00
12e0aa4fd4 Merge pull request 'feat: implement automatic token refresh and add token expiry logging' (#5) from sz/token-refresh into dev
Reviewed-on: #5
2025-10-22 17:42:13 +00:00
DeathKaioken
97231ee7b6 beautify: adjust personal register upload id 2025-10-22 19:41:11 +02:00
DeathKaioken
e0a18f42ea beautify: company upload id 2025-10-22 19:33:58 +02:00
seaznCode
d6870e72b3 feat: implement automatic token refresh and add token expiry logging 2025-10-22 19:25:46 +02:00
DeathKaioken
6cd2a991d4 beautify: fix personal and company upload id 2025-10-22 19:18:29 +02:00
86c7be381b Merge pull request 'feat: implement tutorial modal with step-by-step guidance for new users' (#4) from sz/tut-modal into dev
Reviewed-on: #4
2025-10-22 16:52:31 +00:00
seaznCode
5de28f2eaf feat: implement tutorial modal with step-by-step guidance for new users 2025-10-22 18:51:34 +02:00
DeathKaioken
cf6ff3998e beautify: quickaction dashboard verify mail button 2025-10-22 18:43:02 +02:00
seaznCode
db0a8707d3 refactor: simplify button enablement logic for ID upload and additional info 2025-10-22 18:34:13 +02:00
seaznCode
d4f5196146 feat: add UserDetailModal and integrate detailed user view in admin pages 2025-10-22 18:29:49 +02:00
DeathKaioken
3ee6e90128 feat: add Matrix Management 2025-10-16 16:35:17 +02:00
DeathKaioken
7625ded0e9 beautify: quick dashboard adjustments 2025-10-16 15:00:12 +02:00
DeathKaioken
eab249ab1f beautify: unified admin dashboard style 2025-10-16 10:27:56 +02:00
DeathKaioken
4d10052ce8 beautify: user-management like referral management 2025-10-16 10:22:30 +02:00
DeathKaioken
9bb8acdce9 feat: add export csv button 2025-10-16 10:19:53 +02:00
DeathKaioken
d561e7da82 feat: add frontend matrix management 2025-10-16 10:09:31 +02:00
DeathKaioken
fb82536a09 feat: cant spam email verify mail 2025-10-16 09:28:17 +02:00
DeathKaioken
604556ca06 feat: register Backend link 2025-10-16 08:48:43 +02:00
DeathKaioken
943079d94f beatutify: adjust dashboard 2025-10-16 08:41:55 +02:00
DeathKaioken
bc8df1938b feat: invalid ref link / register protect 2025-10-16 08:23:19 +02:00
DeathKaioken
f93b053569 feat: admin navigation in header 2025-10-16 08:07:31 +02:00
DeathKaioken
ffd7daeb11 feat: login+Permission protection referral Management 2025-10-16 08:00:20 +02:00
DeathKaioken
09083e066a feat: referral Management nav in Header if permission there 2025-10-16 07:56:46 +02:00
DeathKaioken
d0bf865552 add: referral Management with Backend Link | ROUTE NOT PROTECTED 2025-10-16 07:44:53 +02:00
DeathKaioken
0fdd727821 beautify: fixed gray overlay in header when on dashboard site 2025-10-16 06:47:09 +02:00
DeathKaioken
e01a0e2792 add: login redirect - if register steps not completted -> quicacktion-dashboard, if steps are already completted and status is active -> redirect to dashboard 2025-10-16 06:40:53 +02:00
92d96d0644 Merge pull request 'feat: implement admin user management features including fetching, verifying, and displaying user stats' (#3) from admin-site into dev
Reviewed-on: #3
2025-10-15 16:48:12 +00:00
seaznCode
3e27a02e36 feat: implement admin user management features including fetching, verifying, and displaying user stats 2025-10-14 23:22:59 +02:00
seaznCode
c16ce3093c feat: add admin page routing 2025-10-14 19:06:07 +02:00
seaznCode
94bc1e9219 feat: Update text colors for improved accessibility and consistency across login and registration forms 2025-10-14 19:01:03 +02:00
seaznCode
68a9eef41a feat: Enhance text color utilities for better contrast and accessibility 2025-10-14 19:00:56 +02:00
seaznCode
20710484fe fix: remove double scrollbar 2025-10-14 18:47:01 +02:00
seaznCode
4ec56fd12f feat: Prevent double email sending by adding a ref and reset on failure in EmailVerifyPage 2025-10-12 16:09:02 +02:00
1bdfd38ef5 Merge pull request 'qa-dashboard' (#2) from qa-dashboard into dev
Reviewed-on: #2
2025-10-12 12:09:50 +00:00
seaznCode
d96f13474c feat: Add country selection dropdown with predefined options in company profile form 2025-10-12 13:58:02 +02:00
seaznCode
8798979fe9 feat: Add nationality and country selection with validation for date of birth 2025-10-12 13:56:39 +02:00
seaznCode
ab69142c34 feat: Improve validation logic and error messaging in company sign contract page + update to use dummy PDF file 2025-10-12 13:56:09 +02:00
seaznCode
894aa4d0b4 feat: Enhance validation logic and error messaging in personal sign contract page + send dummy pdf file not txt 2025-10-12 13:55:58 +02:00
seaznCode
0ba58d7538 feat: Automatically send verification email on page load and update UI accordingly 2025-10-12 13:45:42 +02:00
seaznCode
25fff9b1c3 feat: Implement user status management with custom hook
- Added `useUserStatus` hook to manage user status fetching and state.
- Integrated user status in Quick Action Dashboard and related pages.
- Enhanced error handling and loading states for user status.
- Updated profile completion and document upload flows to refresh user status after actions.
- Created a centralized API utility for handling requests and responses.
- Refactored authentication token management to use session storage.
2025-10-11 19:47:07 +02:00
seaznCode
bc89babc13 second shop concept 2025-10-06 18:11:39 +02:00
DeathKaioken
6aec40b660 feature: add user verify page 2025-10-04 00:55:19 +02:00
DeathKaioken
ffb1fafc7e feature: add admin user-management page 2025-10-04 00:48:22 +02:00
DeathKaioken
b2cfb1aa34 feature: add admin dashboard 2025-10-04 00:45:38 +02:00
DeathKaioken
10d3d341bc feature: add sign contract for personal and company 2025-10-04 00:42:00 +02:00
DeathKaioken
affa6912a9 feature: add additional information page for personal and company user 2025-10-04 00:37:49 +02:00
DeathKaioken
b14c72cb8d feature: add personal & company register upload id page 2025-10-04 00:31:09 +02:00
DeathKaioken
66ea6ad002 beautify: footer fix email verify 2025-10-04 00:24:25 +02:00
DeathKaioken
80d66300cd feature: add quickaction email verify page 2025-10-04 00:20:17 +02:00
DeathKaioken
54f946461c feature: add register quick action dashboard 2025-10-04 00:17:07 +02:00
DeathKaioken
696174bbc4 feature: add referral statistic to dashboard 2025-10-04 00:08:09 +02:00
DeathKaioken
57e0f4ecac feature: add password reset page 2025-10-04 00:05:33 +02:00
DeathKaioken
4ec041a49f beautify: loginform Planet Animation 2025-10-03 23:14:25 +02:00
seaznCode
69f586aded Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev 2025-10-03 22:47:39 +02:00
seaznCode
c646c30748 beautify: vip shop 2025-10-03 22:47:37 +02:00
DeathKaioken
6f3d6ef515 beautify: top margin register 2025-10-03 22:47:10 +02:00
DeathKaioken
2bddd8360b beautify: register #1 2025-10-03 22:40:25 +02:00
DeathKaioken
d5cb8e673d beautify: header account mobile and desktop 2025-10-03 22:21:30 +02:00
seaznCode
35f20dbb4e feat: add vip and public shop 2025-10-03 22:20:35 +02:00
DeathKaioken
ce2cfec9f3 bug: Page Transition Effect fix 2025-10-03 22:13:30 +02:00
DeathKaioken
b1efd5c345 Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev 2025-10-03 22:12:07 +02:00
DeathKaioken
1eeafee58e add: Page Transition Effect 2025-10-03 22:11:24 +02:00
seaznCode
6ebe4eed3d fix: footer problems 2025-10-03 22:04:12 +02:00
DeathKaioken
aa77b42fe0 beautify: mobile fixes for header 2025-10-03 21:50:42 +02:00
DeathKaioken
375bfc46b1 feature: add Memberships Page with dummy data 2025-10-03 21:37:08 +02:00
seaznCode
875923d5a6 feat: add about us page 2025-10-03 21:36:39 +02:00
seaznCode
3fc0753c7d beautify: add poly background for affiliate-links 2025-10-03 21:26:06 +02:00
DeathKaioken
f8a16ebf5a beautify: dropdown fix 2025-10-03 21:24:29 +02:00
seaznCode
f2b0ee448d fix: footer stay on bottom now 2025-10-03 21:17:40 +02:00
DeathKaioken
2e26e4bea1 beautify: header gradient add 2025-10-03 21:14:17 +02:00
seaznCode
0cb174bd21 fix: scrolling is back lol 2025-10-03 21:11:07 +02:00
seaznCode
fc624522f5 beautify: shop header 2025-10-03 21:08:30 +02:00
seaznCode
0e7d2d41dc fix: double scrollbar + margins 2025-10-03 21:08:13 +02:00
seaznCode
a6c3251a6f Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev 2025-10-03 20:49:44 +02:00
seaznCode
4367740652 beautify: login + header
upload: background imgs
2025-10-03 20:48:28 +02:00
DeathKaioken
42130be8e8 feature: affiliate-links page 2025-10-03 20:30:55 +02:00
DeathKaioken
37fbc6ae25 beautify: added Light mode 2025-10-03 19:56:36 +02:00
DeathKaioken
1d22393675 beautify: Initial Page design 2025-10-03 19:32:21 +02:00
5b59266c16 Merge pull request 'shiiiiit' (#1) from sz/migrate-old-to-nextjs into dev
Reviewed-on: #1
2025-10-02 13:09:46 +00:00
seaznCode
762fcd1285 shiiiiit 2025-10-02 15:09:03 +02:00
seaznCode
b89e8e7058 init next 2025-09-29 18:46:34 +02:00
189 changed files with 42204 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.

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;

30
middleware.ts Normal file
View File

@ -0,0 +1,30 @@
/**
* 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'
// Move accessToken to HttpOnly cookie in future for better security
// Backend sets 'refreshToken' cookie on login; use it as auth presence
const AUTH_COOKIES = ['refreshToken']
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
// Only guard admin routes
if (pathname.startsWith('/admin')) {
const hasAuthCookie = AUTH_COOKIES.some((name) => !!req.cookies.get(name)?.value)
if (!hasAuthCookie) {
const loginUrl = new URL('/login', req.url)
return NextResponse.redirect(loginUrl)
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/admin/:path*'],
}

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;

10436
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
package.json Normal file
View File

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

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

View File

@ -0,0 +1,144 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement';
type Props = {
refreshKey?: number;
};
type ContractTemplate = {
id: string;
name: string;
version: number;
status: 'draft' | 'published' | 'archived' | string;
updatedAt?: string;
};
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800 border border-gray-300',
published: 'bg-green-100 text-green-800 border border-green-300',
archived: 'bg-yellow-100 text-yellow-800 border border-yellow-300',
};
const cls = map[status] || 'bg-blue-100 text-blue-800 border border-blue-300';
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{status}</span>;
}
export default function ContractTemplateList({ refreshKey = 0 }: Props) {
const [items, setItems] = useState<ContractTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState('');
const {
listTemplates,
openPreviewInNewTab,
generatePdf,
downloadPdf,
updateTemplateState,
downloadBlobFile,
} = useContractManagement();
const filtered = useMemo(() => {
const term = q.trim().toLowerCase();
if (!term) return items;
return items.filter((i) => i.name.toLowerCase().includes(term) || String(i.version).includes(term) || i.status.toLowerCase().includes(term));
}, [items, q]);
const load = async () => {
setLoading(true);
try {
const data = await listTemplates();
const mapped: ContractTemplate[] = data.map((x: any) => ({
id: x.id ?? x._id ?? x.uuid,
name: x.name ?? 'Untitled',
version: Number(x.version ?? 1),
status: (x.state === 'active') ? 'published' : 'draft',
updatedAt: x.updatedAt ?? x.modifiedAt ?? x.updated_at,
}));
setItems(mapped);
} catch {
setItems((prev) => prev.length ? prev : [
{ id: 'ex1', name: 'Sample Contract A', version: 1, status: 'draft', updatedAt: new Date().toISOString() },
{ id: 'ex2', name: 'NDA Template', version: 3, status: 'published', updatedAt: new Date().toISOString() },
]);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshKey]);
const onToggleState = async (id: string, current: string) => {
const target = current === 'published' ? 'inactive' : 'active';
try {
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
} catch {}
};
const onPreview = (id: string) => openPreviewInNewTab(id);
const onGenPdf = async (id: string) => {
try {
const blob = await generatePdf(id, { preview: true });
downloadBlobFile(blob, `${id}-preview.pdf`);
} catch {}
};
const onDownloadPdf = async (id: string) => {
try {
const blob = await downloadPdf(id);
downloadBlobFile(blob, `${id}.pdf`);
} catch {}
};
return (
<div className="space-y-4">
<div className="flex gap-2 items-center">
<input
placeholder="Search templates…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
/>
<button
onClick={load}
disabled={loading}
className="rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-4 py-2 text-sm font-medium shadow disabled:opacity-60"
>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map((c) => (
<div key={c.id} className="rounded-xl border border-gray-100 bg-white shadow-sm p-4 flex flex-col gap-2 hover:shadow-md transition">
<div className="flex items-center gap-2">
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
<StatusBadge status={c.status} />
</div>
<p className="text-xs text-gray-500">Version {c.version}{c.updatedAt ? ` • Updated ${new Date(c.updatedAt).toLocaleString()}` : ''}</p>
<div className="flex flex-wrap gap-2 mt-2">
<button onClick={() => onPreview(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Preview</button>
<button onClick={() => onGenPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">PDF</button>
<button onClick={() => onDownloadPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Download</button>
<button onClick={() => onToggleState(c.id, c.status)} className={`px-3 py-1 text-xs rounded-lg font-semibold transition
${c.status === 'published'
? 'bg-red-100 hover:bg-red-200 text-red-700 border border-red-200'
: 'bg-indigo-600 hover:bg-indigo-500 text-white border border-indigo-600'}`}>
{c.status === 'published' ? 'Deactivate' : 'Activate'}
</button>
</div>
</div>
))}
{!filtered.length && (
<div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</div>
)}
</div>
</div>
);
}

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

View File

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

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>
)
}

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

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>
</>
)
}

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

@ -0,0 +1,307 @@
'use client'
import PageLayout from '../components/PageLayout'
import {
UsersIcon,
ExclamationTriangleIcon,
CpuChipIcon,
ServerStackIcon,
ArrowRightIcon,
Squares2X2Icon,
BanknotesIcon,
ClipboardDocumentListIcon
} from '@heroicons/react/24/outline'
import { useMemo, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAdminUsers } from '../hooks/useAdminUsers'
export default function AdminDashboardPage() {
const router = useRouter()
const { userStats, isAdmin } = useAdminUsers()
const [isClient, setIsClient] = useState(false)
// Handle client-side mounting
useEffect(() => {
setIsClient(true)
}, [])
// Fallback for loading/no data
const displayStats = userStats || {
totalUsers: 0,
adminUsers: 0,
verificationPending: 0,
activeUsers: 0,
personalUsers: 0,
companyUsers: 0
}
const permissionStats = useMemo(() => ({
permissions: 1 // TODO: fetch permission definitions
}), [])
const serverStats = useMemo(() => ({
status: 'Online',
uptime: '4 days, 8 hours',
cpu: '0%',
memory: '0.1 / 7.8',
recentErrors: [] as { id: string; ts: string; msg: string }[]
}), [])
// Show loading during SSR/initial client render
if (!isClient) {
return (
<PageLayout>
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="text-center">
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-blue-900">Loading...</p>
</div>
</div>
</PageLayout>
)
}
// Access check (only after client-side hydration)
if (!isAdmin) {
return (
<PageLayout>
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
<p className="text-gray-600">You need admin privileges to access this page.</p>
</div>
</div>
</div>
</PageLayout>
)
}
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Admin Dashboard</h1>
<p className="text-lg text-blue-700 mt-2">
Manage all administrative features, user management, permissions, and global settings.
</p>
</div>
</header>
{/* Warning banner */}
<div className="rounded-2xl border border-red-300 bg-red-50 text-red-700 px-8 py-6 flex gap-3 items-start text-base mb-8 shadow">
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
<div className="leading-relaxed">
<p className="font-semibold mb-0.5">
Warning: Settings and actions below this point can have consequences for the entire system!
</p>
<p className="text-red-600/80 hidden sm:block">
Manage all administrative features, user management, permissions, and global settings.
</p>
</div>
</div>
{/* Stats Card */}
<div className="mb-8 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6">
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Total Users</div>
<div className="text-xl font-semibold text-blue-900">{displayStats.totalUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Admins</div>
<div className="text-xl font-semibold text-indigo-700">{displayStats.adminUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Active</div>
<div className="text-xl font-semibold text-green-700">{displayStats.activeUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Pending Verification</div>
<div className="text-xl font-semibold text-amber-700">{displayStats.verificationPending}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Personal</div>
<div className="text-xl font-semibold text-blue-700">{displayStats.personalUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Company</div>
<div className="text-xl font-semibold text-purple-700">{displayStats.companyUsers}</div>
</div>
</div>
{/* Management Shortcuts Card */}
<div className="mb-8">
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
<div className="flex items-start gap-4 mb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-blue-900">Management Shortcuts</h2>
<p className="text-sm text-blue-700 mt-0.5">
Quick access to common admin modules.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
type="button"
onClick={() => router.push('/admin/matrix-management')}
className="group w-full flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 px-4 py-4 transition"
>
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-blue-100 border border-blue-200">
<Squares2X2Icon className="h-6 w-6 text-blue-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-blue-900">Matrix Management</div>
<div className="text-xs text-blue-700">Configure matrices and users</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
</button>
<button
type="button"
onClick={() => router.push('/admin/subscriptions')}
className="group w-full flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 hover:bg-amber-100 px-4 py-4 transition"
>
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-amber-100 border border-amber-200">
<BanknotesIcon className="h-6 w-6 text-amber-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-amber-900">Coffee Subscription Management</div>
<div className="text-xs text-amber-700">Plans, billing and renewals</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-amber-600 opacity-70 group-hover:opacity-100" />
</button>
<button
type="button"
onClick={() => router.push('/admin/contract-management')}
className="group w-full flex items-center justify-between rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 px-4 py-4 transition"
>
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-indigo-100 border border-indigo-200">
<ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-indigo-900">Contract Management</div>
<div className="text-xs text-indigo-700">Templates, approvals, status</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
</button>
<button
type="button"
onClick={() => router.push('/admin/user-management')}
className="group w-full flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 hover:bg-blue-50 px-4 py-4 transition"
>
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-blue-100 border border-blue-200">
<UsersIcon className="h-6 w-6 text-blue-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-blue-900">User Management</div>
<div className="text-xs text-blue-700">Browse, search, and manage all users</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
</button>
<button
type="button"
onClick={() => router.push('/admin/news-management')}
className="group w-full flex items-center justify-between rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 px-4 py-4 transition"
>
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-green-100 border border-green-200">
<ClipboardDocumentListIcon className="h-6 w-6 text-green-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-green-900">News Management</div>
<div className="text-xs text-green-700">Create and manage news articles</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-green-600 opacity-70 group-hover:opacity-100" />
</button>
</div>
</div>
</div>
{/* Server Status & Logs */}
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
<div className="flex items-start gap-4 mb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gray-100">
<ServerStackIcon className="h-7 w-7 text-gray-700" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">
Server Status & Logs
</h2>
<p className="text-sm text-gray-500 mt-0.5">
System health, resource usage & recent error insights.
</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* Metrics */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<span className={`h-2.5 w-2.5 rounded-full ${serverStats.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
<p className="text-base">
<span className="font-semibold">Server Status:</span>{' '}
<span className={serverStats.status === 'Online' ? 'text-emerald-600 font-medium' : 'text-red-600 font-medium'}>
{serverStats.status === 'Online' ? 'Server Online' : 'Offline'}
</span>
</p>
</div>
<div className="text-sm space-y-1 text-gray-600">
<p><span className="font-medium text-gray-700">Uptime:</span> {serverStats.uptime}</p>
<p><span className="font-medium text-gray-700">CPU Usage:</span> {serverStats.cpu}</p>
<p><span className="font-medium text-gray-700">Memory Usage:</span> {serverStats.memory} GB</p>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<CpuChipIcon className="h-4 w-4" />
<span>Autoscaled environment (mock)</span>
</div>
</div>
{/* Divider */}
<div className="hidden lg:block border-l border-gray-200" />
{/* Logs */}
<div className="lg:col-span-2">
<h3 className="text-base font-semibold text-gray-800 mb-3">
Recent Error Logs
</h3>
{serverStats.recentErrors.length === 0 && (
<p className="text-sm text-gray-500 italic">
No recent logs.
</p>
)}
{/* Placeholder for future logs list */}
{/* TODO: Replace with mapped log entries */}
<div className="mt-6">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-gray-50 hover:bg-gray-100 text-gray-700 text-sm font-medium px-4 py-3 transition"
// TODO: navigate to logs / monitoring page
onClick={() => {}}
>
View Full Logs
<ArrowRightIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</main>
</div>
</PageLayout>
)
}

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: 0,
createdAt: String(item.created_at ?? new Date().toISOString()),
}));
log("✅ Pools: Mapped sample:", mapped.slice(0, 3));
if (!cancelled) setPools(mapped);
} catch (e: any) {
log("❌ Pools: Network or parsing error:", e?.message || e);
if (!cancelled) setError('Network error while loading pools.');
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [BASE_URL]);
return {
pools,
loading,
error,
refresh: async () => {
const url = `${BASE_URL}/api/admin/pools`; // reverted to /api/admin/pools
log("🔁 Pools: Refresh GET", url);
const res = await authFetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) {
log("❌ Pools: Refresh failed status:", res.status);
return false;
}
const body = await res.json();
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
setPools(apiItems.map(item => ({
id: String(item.id),
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
description: String(item.description ?? ''),
price: Number(item.price ?? 0),
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
is_active: Boolean(item.is_active),
membersCount: 0,
createdAt: String(item.created_at ?? new Date().toISOString()),
})));
log("✅ Pools: Refresh succeeded, items:", apiItems.length);
return true;
}
};
}

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

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

View File

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

View File

@ -0,0 +1,109 @@
import React from "react";
// Utility to detect mobile devices
function isMobileDevice() {
if (typeof navigator === "undefined") return false;
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
}
function GlobalAnimatedBackground() {
// Always use dashboard style for a uniform look
const bgGradient = "linear-gradient(135deg, #1e293b 0%, #334155 100%)";
// Detect small screens (mobile/tablet)
const isMobile = isMobileDevice();
if (isMobile) {
// Render only the static background gradient and overlay, no animation
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 0,
width: "100vw",
height: "100vh",
background: bgGradient,
transition: "background 0.5s",
pointerEvents: "none",
}}
aria-hidden="true"
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
</div>
);
}
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 0,
width: "100vw",
height: "100vh",
background: bgGradient,
transition: "background 0.5s",
pointerEvents: "none",
}}
aria-hidden="true"
>
{/* Overlays */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
<div className="absolute top-10 left-10 w-64 h-1 bg-blue-300 opacity-50 animate-slide-loop"></div>
<div className="absolute bottom-20 right-20 w-48 h-1 bg-blue-200 opacity-40 animate-slide-loop"></div>
<div className="absolute top-1/3 left-1/4 w-72 h-1 bg-blue-400 opacity-30 animate-slide-loop"></div>
<div className="absolute top-16 left-1/3 w-32 h-32 bg-blue-500 rounded-full opacity-50 animate-float"></div>
<div className="absolute bottom-24 right-1/4 w-40 h-40 bg-blue-600 rounded-full opacity-40 animate-float"></div>
<div className="absolute top-1/2 left-1/2 w-24 h-24 bg-blue-700 rounded-full opacity-30 animate-float"></div>
<div className="absolute top-1/4 left-1/5 w-20 h-20 bg-blue-300 rounded-lg opacity-40 animate-float-slow"></div>
<div className="absolute bottom-1/3 right-1/3 w-28 h-28 bg-blue-400 rounded-lg opacity-30 animate-float-slow"></div>
<style>
{`
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
}
@keyframes float-slow {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes slide-loop {
0% {
transform: translateX(0);
opacity: 1;
}
80% {
opacity: 1;
}
100% {
transform: translateX(-100vw);
opacity: 0;
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-float-slow {
animation: float-slow 8s ease-in-out infinite;
}
.animate-slide-loop {
animation: slide-loop 12s linear infinite;
}
`}
</style>
</div>
);
}
export default GlobalAnimatedBackground;

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,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,48 @@
'use client';
import React from 'react';
import Header from './nav/Header';
import Footer from './Footer';
import PageTransitionEffect from './animation/pageTransitionEffect';
// Utility to detect mobile devices
function isMobileDevice() {
if (typeof navigator === 'undefined') return false;
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
interface PageLayoutProps {
children: React.ReactNode;
showHeader?: boolean;
showFooter?: boolean;
}
export default function PageLayout({
children,
showHeader = true,
showFooter = true
}: PageLayoutProps) {
const isMobile = isMobileDevice();
return (
<div className="min-h-screen w-full flex flex-col bg-white text-gray-900">
{showHeader && (
<div className="relative z-50 w-full flex-shrink-0">
<Header />
</div>
)}
{/* Main content */}
<div className="flex-1 relative z-10 w-full">
<PageTransitionEffect>{children}</PageTransitionEffect>
</div>
{showFooter && (
<div className="relative z-50 w-full flex-shrink-0">
<Footer />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,339 @@
'use client'
import { Fragment } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import {
XMarkIcon,
EnvelopeIcon,
IdentificationIcon,
UserIcon,
DocumentTextIcon,
ClockIcon,
ArrowRightIcon,
CheckCircleIcon,
HandRaisedIcon,
HeartIcon,
CheckIcon
} from '@heroicons/react/24/outline'
interface TutorialStep {
id: number
title: string
description: string
details: string[]
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
buttonText: string
buttonAction: () => void
canProceed: boolean
}
interface TutorialModalProps {
isOpen: boolean
onClose: () => void
currentStep: number
steps: TutorialStep[]
onNext: () => void
onPrevious: () => void
}
export default function TutorialModal({
isOpen,
onClose,
currentStep,
steps,
onNext,
onPrevious
}: TutorialModalProps) {
const step = steps[currentStep - 1]
if (!step) return null
const isLastStep = currentStep === steps.length
const isFirstStep = currentStep === 1
// Helper function to check if step is completed
const isStepCompleted = (stepId: number) => {
if (stepId === 2) return step.buttonText.includes("✅") // Email verified
if (stepId === 3) return step.buttonText.includes("✅") // ID uploaded
if (stepId === 4) return step.buttonText.includes("✅") // Profile completed
if (stepId === 5) return step.buttonText.includes("✅") // Agreement signed
return false
}
// Get clean button text without emoji
const getCleanButtonText = (text: string) => {
return text.replace(/✅/g, '').trim().replace(/!$/, '')
}
const stepCompleted = isStepCompleted(step.id)
const buttonText = stepCompleted ? getCleanButtonText(step.buttonText) : step.buttonText
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-blue-900/60 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative w-full max-w-5xl h-[60vh]">
<div className="relative isolate overflow-hidden bg-slate-50 h-full after:pointer-events-none after:absolute after:inset-0 after:inset-ring after:inset-ring-gray-200/50 sm:rounded-3xl after:sm:rounded-3xl lg:flex lg:gap-x-12 lg:px-8 w-full">
{/* Background Gradient */}
<svg
viewBox="0 0 1024 1024"
aria-hidden="true"
className="absolute -top-48 -left-48 -z-10 size-96 mask-[radial-gradient(closest-side,white,transparent)]"
>
<circle r={512} cx={512} cy={512} fill="url(#tutorial-gradient)" fillOpacity="0.7" />
<defs>
<radialGradient id="tutorial-gradient">
<stop stopColor="#3B82F6" />
<stop offset={1} stopColor="#8B5CF6" />
</radialGradient>
</defs>
</svg>
{/* Close Button */}
<button
type="button"
className="absolute right-4 top-4 z-10 rounded-md bg-gray-200/70 p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-300/70 focus:outline-none focus:ring-2 focus:ring-blue-500 backdrop-blur-sm"
onClick={onClose}
>
<span className="sr-only">Close</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
{/* Content Section - Left Half */}
<div className="lg:flex-1 lg:max-w-md text-center lg:text-left py-8 px-6 flex flex-col justify-center">
{/* Icon */}
<div className="mx-auto lg:mx-0 flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mb-4 ring-2 ring-blue-200">
<step.icon className="h-6 w-6 text-blue-600" aria-hidden="true" />
</div>
{/* Title */}
<h2 className="text-xl font-semibold tracking-tight whitespace-nowrap text-gray-800 sm:text-2xl">
{step.title}
</h2>
{/* Description */}
<p className="mt-3 text-sm text-gray-600 leading-relaxed h-12 overflow-hidden">
{step.description}
</p>
{/* Details */}
<ul className="mt-4 text-left text-gray-600 space-y-2">
{step.details.map((detail, index) => (
<li key={index} className="flex items-start gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-blue-500 mt-1.5 flex-shrink-0" />
<span className="text-xs leading-5">{detail}</span>
</li>
))}
</ul>
{/* Progress indicator */}
<div className="mt-6">
<div className="flex items-center justify-between text-xs text-gray-500 mb-2">
<span>Step {currentStep} of {steps.length}</span>
<span>{Math.round((currentStep / steps.length) * 100)}% Complete</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-gradient-to-r from-blue-500 to-purple-500 h-1.5 rounded-full transition-all duration-500"
style={{ width: `${(currentStep / steps.length) * 100}%` }}
/>
</div>
</div>
{/* Action Buttons */}
<div className="mt-6 flex items-center justify-center gap-x-3 lg:justify-start">
<button
type="button"
onClick={step.buttonAction}
disabled={(!step.canProceed && currentStep !== steps.length) || buttonText.includes("Waiting for admin review") || stepCompleted}
className={`rounded-md px-3 py-2 text-sm font-semibold inset-ring inset-ring-gray-200/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all flex items-center gap-2 ${
stepCompleted
? 'bg-green-600 text-white'
: (step.canProceed || currentStep === steps.length) && !buttonText.includes("Waiting for admin review")
? 'bg-blue-600 text-white hover:bg-blue-500 shadow-lg hover:shadow-xl'
: 'bg-gray-300 text-gray-500'
}`}
>
{stepCompleted && <CheckIcon className="h-4 w-4" />}
{buttonText}
</button>
</div>
{/* Navigation Buttons */}
{(!isFirstStep || !isLastStep) && (
<div className="mt-4 flex items-center justify-center gap-x-4 lg:justify-start">
<button
type="button"
onClick={onPrevious}
disabled={isFirstStep}
className={`text-xs font-semibold transition-colors ${
isFirstStep
? 'text-slate-50 cursor-default'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Go back
</button>
{!isLastStep && (
<button
type="button"
onClick={onNext}
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
>
Continue
</button>
)}
</div>
)}
</div>
{/* Visual Section - Right Half */}
<div className="relative lg:flex-1 mt-4 lg:mt-0 h-32 lg:h-full lg:min-h-[150px] flex items-end justify-end">
{/* <img
src="/images/misc/cow.png"
alt="Profit Planet Mascot"
className="max-h-full max-w-full object-contain opacity-90 pl-30"
/> */}
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
// Tutorial step data
export const createTutorialSteps = (
emailVerified: boolean,
idUploaded: boolean,
additionalInfo: boolean,
contractSigned: boolean,
userType: string,
onVerifyEmail: () => void,
onUploadId: () => void,
onCompleteInfo: () => void,
onSignContract: () => void,
onCloseTutorial: () => void,
onNext: () => void
): TutorialStep[] => [
{
id: 1,
title: "Hello there! 👋 Welcome to Profit Planet",
description: "We're so happy you've decided to join us! This quick tutorial will guide you through setting up your account in just a few simple steps. Let's make this journey together - it'll only take a few minutes!",
details: [
"We'll walk you through each step personally",
"Everything is designed to be simple and clear",
"You can skip steps if you want to come back later",
"Our team is here to help if you need anything"
],
icon: HandRaisedIcon,
buttonText: "Let's get started! 🚀",
buttonAction: onNext,
canProceed: true
},
{
id: 2,
title: "Let's verify your email address 📧",
description: "First things first - we'd love to make sure we can reach you! Please check your email inbox for a friendly message from us and the verification code.",
details: [
"Check your email inbox for our welcome message",
"Copy & paste the verification code into the field",
"Don't see it? Check your spam folder - sometimes it hides there",
"Need a new email? Just click below and we'll send another"
],
icon: EnvelopeIcon,
buttonText: emailVerified ? "Email verified! ✅" : "Verify my email",
buttonAction: emailVerified ? onNext : onVerifyEmail,
canProceed: true
},
{
id: 3,
title: "Time to upload your ID 📋",
description: "Now we need to get to know you better! Please upload a clear photo of your official ID. Don't worry - this information is completely secure and helps us keep everyone safe.",
details: [
"Take a clear, well-lit photo of your ID document",
"Make sure all text is easily readable",
"Passport, driver's license, or national ID all work perfectly",
"We protect your privacy - this is just for verification"
],
icon: IdentificationIcon,
buttonText: idUploaded ? "ID uploaded! ✅" : "Upload my ID",
buttonAction: idUploaded ? onNext : onUploadId,
canProceed: true
},
{
id: 4,
title: "Complete your profile 👤",
description: `Almost there! Now let's fill out your ${userType === 'personal' ? 'personal' : 'company'} profile. This helps us customize your experience and ensure everything runs smoothly.`,
details: userType === 'personal' ? [
"Share your full name and date of birth with us",
"Add your current address (we keep this private)",
"Include a phone number so we can reach you if needed",
"All information is required for account security"
] : [
"Tell us about your company and business details",
"Add your business address and contact information",
"Upload any business documents we might need",
"Make sure everything matches your official records"
],
icon: UserIcon,
buttonText: additionalInfo ? "Profile completed! ✅" : "Complete my profile",
buttonAction: additionalInfo ? onNext : onCompleteInfo,
canProceed: true
},
{
id: 5,
title: "Ready to sign your contract! 📝",
description: "Perfect! You've completed all the preparation steps. Now it's time to review and sign your personalized contract to finalize your account setup.",
details: [
"Review the terms and conditions carefully",
"Your contract has been prepared based on your information",
"Digital signature makes the process quick and secure",
"This is the final step to activate your account"
],
icon: DocumentTextIcon,
buttonText: contractSigned ? "Contract signed! ✅" : "Review & sign contract",
buttonAction: contractSigned ? onNext : onSignContract,
canProceed: emailVerified && idUploaded && additionalInfo
},
{
id: 6,
title: "You're all set! 🎉 Welcome to the family",
description: "Congratulations! Our team will now review your information and have you approved very soon!",
details: [
"Our team will carefully review everything you've submitted",
"This usually takes just 1-2 business days",
"We'll send you a celebratory email once you're approved",
"In the meantime, feel free to explore your dashboard"
],
icon: HeartIcon,
buttonText: "Perfect! I understand 💫",
buttonAction: onCloseTutorial,
canProceed: true
}
]

View File

@ -0,0 +1,757 @@
'use client'
import { Fragment, useState, useEffect } from 'react'
import { Dialog, Transition, Listbox } from '@headlessui/react'
import {
XMarkIcon,
UserIcon,
DocumentTextIcon,
ShieldCheckIcon,
CalendarIcon,
EnvelopeIcon,
PhoneIcon,
MapPinIcon,
BuildingOfficeIcon,
IdentificationIcon,
CheckCircleIcon,
XCircleIcon,
ChevronUpDownIcon,
CheckIcon
} from '@heroicons/react/24/outline'
import { AdminAPI, DetailedUserInfo } from '../utils/api'
import useAuthStore from '../store/authStore'
interface UserDetailModalProps {
isOpen: boolean
onClose: () => void
userId: string | null
onUserUpdated?: () => void
}
type UserStatus = 'inactive' | 'pending' | 'active' | 'suspended' | 'archived'
const STATUS_OPTIONS: { value: UserStatus; label: string; color: string }[] = [
{ value: 'pending', label: 'Pending', color: 'amber' },
{ value: 'active', label: 'Active', color: 'green' },
{ value: 'suspended', label: 'Suspended', color: 'rose' },
{ value: 'archived', label: 'Archived', color: 'gray' },
{ value: 'inactive', label: 'Inactive', color: 'gray' }
]
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [selectedStatus, setSelectedStatus] = useState<UserStatus>('pending')
const token = useAuthStore(state => state.accessToken)
// Contract preview state (lazy-loaded)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
const [previewError, setPreviewError] = useState<string | null>(null)
useEffect(() => {
if (isOpen && userId && token) {
fetchUserDetails()
}
}, [isOpen, userId, token])
useEffect(() => {
if (userDetails?.userStatus?.status) {
setSelectedStatus(userDetails.userStatus.status as UserStatus)
}
}, [userDetails])
const fetchUserDetails = async () => {
if (!userId || !token) return
setLoading(true)
setError(null)
try {
const response = await AdminAPI.getDetailedUserInfo(token, userId)
if (response.success) {
setUserDetails(response)
} else {
throw new Error(response.message || 'Failed to fetch user details')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch user details'
setError(errorMessage)
console.error('UserDetailModal.fetchUserDetails error:', err)
} finally {
setLoading(false)
}
}
const handleStatusChange = async (newStatus: UserStatus) => {
if (!userId || !token || newStatus === selectedStatus) return
setSaving(true)
setError(null)
try {
const response = await AdminAPI.updateUserStatus(token, userId, newStatus)
if (response.success) {
setSelectedStatus(newStatus)
await fetchUserDetails()
if (onUserUpdated) {
onUserUpdated()
}
} else {
throw new Error(response.message || 'Failed to update user status')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update user status'
setError(errorMessage)
console.error('UserDetailModal.handleStatusChange error:', err)
} finally {
setSaving(false)
}
}
const handleToggleAdminVerification = async () => {
if (!userId || !token || !userDetails?.userStatus) return
setSaving(true)
setError(null)
try {
const newValue = userDetails.userStatus.is_admin_verified === 1 ? 0 : 1
const response = await AdminAPI.updateUserVerification(token, userId, newValue)
if (response.success) {
await fetchUserDetails()
if (onUserUpdated) {
onUserUpdated()
}
} else {
throw new Error(response.message || 'Failed to update verification status')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update verification status'
setError(errorMessage)
console.error('UserDetailModal.handleToggleAdminVerification error:', err)
} finally {
setSaving(false)
}
}
const loadContractPreview = async () => {
if (!userId || !token || !userDetails) return
setPreviewLoading(true)
setPreviewError(null)
try {
const html = await AdminAPI.getContractPreviewHtml(token, String(userId), userDetails.user.user_type)
setPreviewHtml(html)
} catch (e: any) {
console.error('UserDetailModal.loadContractPreview error:', e)
setPreviewError(e?.message || 'Failed to load contract preview')
setPreviewHtml(null)
} finally {
setPreviewLoading(false)
}
}
const formatDate = (dateString: string | undefined | null) => {
if (!dateString) return 'N/A'
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const formatFileSize = (bytes: number | undefined) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
const getStatusColor = (status: UserStatus) => {
const option = STATUS_OPTIONS.find(opt => opt.value === status)
return option?.color || 'gray'
}
const getStatusBadgeClass = (color: string) => {
const colorMap: Record<string, string> = {
amber: 'bg-amber-100 text-amber-800 border-amber-200',
green: 'bg-green-100 text-green-800 border-green-200',
rose: 'bg-rose-100 text-rose-800 border-rose-200',
gray: 'bg-gray-100 text-gray-800 border-gray-200'
}
return colorMap[color] || colorMap.gray
}
if (!isOpen) return null
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-6">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white shadow-xl transition-all w-full max-w-5xl max-h-[85vh] flex flex-col">
{/* Close Button */}
<div className="absolute right-0 top-0 z-10 pr-4 pt-4">
<button
type="button"
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
onClick={onClose}
>
<span className="sr-only">Close</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
{/* Scrollable Content Area */}
<div className="overflow-y-auto px-4 pb-4 pt-5 sm:p-6">
<div className="w-full">
{loading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
) : error ? (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
</div>
</div>
</div>
) : userDetails ? (
<div className="space-y-6">
{/* Header Section with User Info & Status */}
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg px-6 py-8 text-white">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-full">
{userDetails.user.user_type === 'company' ? (
<BuildingOfficeIcon className="h-10 w-10 text-white" />
) : (
<UserIcon className="h-10 w-10 text-white" />
)}
</div>
<div className="text-left">
<h2 className="text-2xl font-bold">
{userDetails.user.user_type === 'personal'
? `${userDetails.personalProfile?.first_name || ''} ${userDetails.personalProfile?.last_name || ''}`.trim()
: userDetails.companyProfile?.company_name || 'Unknown'}
</h2>
<p className="text-indigo-100 mt-1">{userDetails.user.email}</p>
<div className="flex items-center gap-2 mt-3">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
userDetails.user.user_type === 'personal'
? 'bg-blue-100 text-blue-800'
: 'bg-purple-100 text-purple-800'
}`}>
{userDetails.user.user_type === 'personal' ? 'Personal' : 'Company'}
</span>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
userDetails.user.role === 'admin' || userDetails.user.role === 'super_admin'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}>
{userDetails.user.role === 'super_admin' ? 'Super Admin' : userDetails.user.role}
</span>
</div>
</div>
</div>
{/* Status Badge */}
{userDetails.userStatus && (
<div className="bg-white rounded-lg px-4 py-3 text-gray-900">
<div className="text-xs text-gray-500 mb-1">Current Status</div>
<div className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold border ${
getStatusBadgeClass(getStatusColor(userDetails.userStatus.status as UserStatus))
}`}>
{userDetails.userStatus.status.charAt(0).toUpperCase() + userDetails.userStatus.status.slice(1)}
</div>
</div>
)}
</div>
</div>
{/* Admin Controls Section */}
<div className="bg-gray-50 rounded-lg p-6 border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<ShieldCheckIcon className="h-5 w-5 text-indigo-600" />
Admin Controls
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Status Dropdown */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Change Status
</label>
<Listbox value={selectedStatus} onChange={handleStatusChange} disabled={saving}>
<div className="relative">
<Listbox.Button className="relative w-full cursor-pointer rounded-lg bg-white py-2.5 pl-3 pr-10 text-left border border-gray-300 hover:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-black">
<span className="block truncate font-medium text-black">
{STATUS_OPTIONS.find(opt => opt.value === selectedStatus)?.label || selectedStatus}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{STATUS_OPTIONS.map((option) => (
<Listbox.Option
key={option.value}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
active ? 'bg-indigo-100 text-indigo-900' : 'text-gray-900'
}`
}
value={option.value}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-semibold' : 'font-normal'}`}>
{option.label}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-indigo-600">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
{/* Admin Verification Toggle */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Admin Verification
</label>
{userDetails?.userStatus && (
<p className="text-xs text-gray-500 mb-2">
{userDetails.userStatus.email_verified === 1 && userDetails.userStatus.profile_completed === 1 && userDetails.userStatus.documents_uploaded === 1 && userDetails.userStatus.contract_signed === 1
? 'All steps completed. You can verify this user.'
: 'User has not yet completed all required steps.'}
</p>
)}
<button
type="button"
onClick={handleToggleAdminVerification}
disabled={saving || !(userDetails?.userStatus && userDetails.userStatus.email_verified === 1 && userDetails.userStatus.profile_completed === 1 && userDetails.userStatus.documents_uploaded === 1 && userDetails.userStatus.contract_signed === 1)}
title={!(userDetails?.userStatus && userDetails.userStatus.email_verified === 1 && userDetails.userStatus.profile_completed === 1 && userDetails.userStatus.documents_uploaded === 1 && userDetails.userStatus.contract_signed === 1) ? 'Complete all steps before admin verification' : undefined}
className={`w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
userDetails.userStatus?.is_admin_verified === 1
? 'bg-amber-600 hover:bg-amber-500 text-white focus-visible:outline-amber-600'
: 'bg-green-600 hover:bg-green-500 text-white focus-visible:outline-green-600'
}`}
>
{saving ? (
<>
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
Updating...
</>
) : (
<>
<ShieldCheckIcon className="h-4 w-4" />
{userDetails.userStatus?.is_admin_verified === 1 ? 'Unverify User' : 'Verify User'}
</>
)}
</button>
</div>
</div>
</div>
{/* Contract Preview (admin verify flow) */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
Contract Preview
</h3>
<div className="flex items-center gap-2">
<button
type="button"
onClick={loadContractPreview}
disabled={previewLoading}
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
>
{previewLoading ? 'Loading…' : (previewHtml ? 'Refresh Preview' : 'Load Preview')}
</button>
<button
type="button"
onClick={() => {
if (!previewHtml) return
const blob = new Blob([previewHtml], { type: 'text/html' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
}}
disabled={!previewHtml}
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm disabled:opacity-60"
>
Open in new tab
</button>
</div>
</div>
<div className="px-6 py-5">
{previewError && (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 mb-4">
{previewError}
</div>
)}
{previewLoading && (
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
Loading preview
</div>
)}
{!previewLoading && previewHtml && (
<div className="rounded-md border border-gray-200 overflow-hidden">
<iframe
title="Contract Preview"
className="w-full h-[600px] bg-white"
srcDoc={previewHtml}
/>
</div>
)}
{!previewLoading && !previewHtml && !previewError && (
<p className="text-sm text-gray-500">Click "Load Preview" to render the latest active contract template for this user.</p>
)}
</div>
</div>
{/* Profile Information */}
{userDetails.user.user_type === 'personal' && userDetails.personalProfile && (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<UserIcon className="h-5 w-5 text-gray-600" />
Personal Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">First Name</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.first_name || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">Last Name</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.last_name || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
Phone
</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.phone || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<CalendarIcon className="h-4 w-4 inline mr-1.5" />
Date of Birth
</dt>
<dd className="text-sm text-gray-900 font-medium">{formatDate(userDetails.personalProfile.date_of_birth)}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
Address
</dt>
<dd className="text-sm text-gray-900 font-medium">
{userDetails.personalProfile.address || 'N/A'}
{userDetails.personalProfile.city && <>, {userDetails.personalProfile.city}</>}
{userDetails.personalProfile.zip_code && <>, {userDetails.personalProfile.zip_code}</>}
{userDetails.personalProfile.country && <>, {userDetails.personalProfile.country}</>}
</dd>
</div>
</dl>
</div>
</div>
)}
{/* Company Profile Information */}
{userDetails.user.user_type === 'company' && userDetails.companyProfile && (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<BuildingOfficeIcon className="h-5 w-5 text-gray-600" />
Company Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">Company Name</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.company_name || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">Registration Number</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.registration_number || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">Tax ID</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.tax_id || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
Phone
</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.phone || 'N/A'}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
Address
</dt>
<dd className="text-sm text-gray-900 font-medium">
{userDetails.companyProfile.address || 'N/A'}
{userDetails.companyProfile.city && <>, {userDetails.companyProfile.city}</>}
{userDetails.companyProfile.zip_code && <>, {userDetails.companyProfile.zip_code}</>}
{userDetails.companyProfile.country && <>, {userDetails.companyProfile.country}</>}
</dd>
</div>
</dl>
</div>
</div>
)}
{/* Account Status */}
{userDetails.userStatus && (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<CheckCircleIcon className="h-5 w-5 text-gray-600" />
Registration Progress
</h3>
</div>
<div className="px-6 py-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-3">
{userDetails.userStatus.email_verified === 1 ? (
<CheckCircleIcon className="h-6 w-6 text-green-500" />
) : (
<XCircleIcon className="h-6 w-6 text-gray-300" />
)}
<span className="text-sm font-medium text-gray-700">Email Verified</span>
</div>
<div className="flex items-center gap-3">
{userDetails.userStatus.profile_completed === 1 ? (
<CheckCircleIcon className="h-6 w-6 text-green-500" />
) : (
<XCircleIcon className="h-6 w-6 text-gray-300" />
)}
<span className="text-sm font-medium text-gray-700">Profile Completed</span>
</div>
<div className="flex items-center gap-3">
{userDetails.userStatus.documents_uploaded === 1 ? (
<CheckCircleIcon className="h-6 w-6 text-green-500" />
) : (
<XCircleIcon className="h-6 w-6 text-gray-300" />
)}
<span className="text-sm font-medium text-gray-700">Documents Uploaded</span>
</div>
<div className="flex items-center gap-3">
{userDetails.userStatus.contract_signed === 1 ? (
<CheckCircleIcon className="h-6 w-6 text-green-500" />
) : (
<XCircleIcon className="h-6 w-6 text-gray-300" />
)}
<span className="text-sm font-medium text-gray-700">Contract Signed</span>
</div>
</div>
</div>
</div>
)}
{/* Documents Section */}
{(userDetails.documents.length > 0 || userDetails.contracts.length > 0 || userDetails.idDocuments.length > 0) && (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
Documents ({userDetails.documents.length + userDetails.contracts.length + userDetails.idDocuments.length})
</h3>
</div>
<div className="px-6 py-5 space-y-4">
{/* Regular Documents */}
{userDetails.documents.length > 0 && (
<div>
<h5 className="text-sm font-medium text-gray-700 mb-3">Uploaded Documents</h5>
<div className="space-y-2">
{userDetails.documents.map((doc) => (
<div key={doc.id} className="flex items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-200">
<div className="flex items-center gap-3">
<DocumentTextIcon className="h-5 w-5 text-gray-400" />
<div>
<div className="text-sm font-medium text-gray-900">{doc.file_name}</div>
<div className="text-xs text-gray-500">{formatFileSize(doc.file_size)}</div>
</div>
</div>
<span className="text-xs text-gray-500">{formatDate(doc.uploaded_at)}</span>
</div>
))}
</div>
</div>
)}
{/* Contracts */}
{userDetails.contracts.length > 0 && (
<div>
<h5 className="text-sm font-medium text-gray-700 mb-3">Contracts</h5>
<div className="space-y-2">
{userDetails.contracts.map((contract) => (
<div key={contract.id} className="flex items-center justify-between bg-blue-50 p-3 rounded-lg border border-blue-200">
<div className="flex items-center gap-3">
<DocumentTextIcon className="h-5 w-5 text-blue-600" />
<div>
<div className="text-sm font-medium text-gray-900">{contract.file_name}</div>
<div className="text-xs text-gray-500">{formatFileSize(contract.file_size)}</div>
</div>
</div>
<span className="text-xs text-gray-500">{formatDate(contract.uploaded_at)}</span>
</div>
))}
</div>
</div>
)}
{/* ID Documents */}
{userDetails.idDocuments.length > 0 && (
<div>
<h5 className="text-sm font-medium text-gray-700 mb-3">ID Documents</h5>
<div className="space-y-4">
{userDetails.idDocuments.map((idDoc) => (
<div key={idDoc.id} className="bg-purple-50 p-4 rounded-lg border border-purple-200">
<div className="flex items-center gap-2 mb-3">
<IdentificationIcon className="h-5 w-5 text-purple-600" />
<span className="text-sm font-medium text-gray-900">{idDoc.document_type}</span>
<span className="text-xs text-gray-500 ml-auto">{formatDate(idDoc.uploaded_at)}</span>
</div>
{(idDoc.frontUrl || idDoc.backUrl) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{idDoc.frontUrl && (
<div>
<p className="text-xs font-medium text-gray-600 mb-2">Front</p>
<img
src={idDoc.frontUrl}
alt="ID Front"
className="w-full h-40 object-cover rounded border border-gray-300"
/>
</div>
)}
{idDoc.backUrl && (
<div>
<p className="text-xs font-medium text-gray-600 mb-2">Back</p>
<img
src={idDoc.backUrl}
alt="ID Back"
className="w-full h-40 object-cover rounded border border-gray-300"
/>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Permissions */}
{userDetails.permissions.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<ShieldCheckIcon className="h-5 w-5 text-gray-600" />
Permissions ({userDetails.permissions.length})
</h3>
</div>
<div className="px-6 py-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{userDetails.permissions.map((perm) => (
<div
key={perm.id}
className={`flex items-center gap-3 p-3 rounded-lg border ${
perm.is_active
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}
>
{perm.is_active ? (
<CheckCircleIcon className="h-5 w-5 text-green-600 flex-shrink-0" />
) : (
<XCircleIcon className="h-5 w-5 text-gray-400 flex-shrink-0" />
)}
<div>
<div className="text-sm font-medium text-gray-900">{perm.name}</div>
{perm.description && (
<div className="text-xs text-gray-500 mt-0.5">{perm.description}</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2.5 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-gray-500"
>
Close
</button>
</div>
</div>
) : null}
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}

View File

@ -0,0 +1,860 @@
'use client'
import { Fragment, useState, useEffect } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import {
XMarkIcon,
UserIcon,
DocumentTextIcon,
ShieldCheckIcon,
CalendarIcon,
EnvelopeIcon,
PhoneIcon,
MapPinIcon,
BuildingOfficeIcon,
IdentificationIcon,
CheckCircleIcon,
XCircleIcon,
PencilSquareIcon,
TrashIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'
import { AdminAPI, DetailedUserInfo } from '../utils/api'
import useAuthStore from '../store/authStore'
interface UserDetailModalProps {
isOpen: boolean
onClose: () => void
userId: string | null
onUserUpdated?: () => void
}
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [archiving, setArchiving] = useState(false)
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false)
const [editedProfile, setEditedProfile] = useState<any>(null)
const token = useAuthStore(state => state.accessToken)
useEffect(() => {
if (isOpen && userId && token) {
fetchUserDetails()
setIsEditing(false)
setShowArchiveConfirm(false)
setEditedProfile(null)
}
}, [isOpen, userId, token])
const fetchUserDetails = async () => {
if (!userId || !token) return
setLoading(true)
setError(null)
try {
const response = await AdminAPI.getDetailedUserInfo(token, userId)
if (response.success) {
setUserDetails(response)
// Initialize edited profile with current data
if (response.personalProfile) {
setEditedProfile(response.personalProfile)
} else if (response.companyProfile) {
setEditedProfile(response.companyProfile)
}
} else {
throw new Error(response.message || 'Failed to fetch user details')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch user details'
setError(errorMessage)
console.error('UserDetailModal.fetchUserDetails error:', err)
} finally {
setLoading(false)
}
}
const handleArchiveUser = async () => {
if (!userId || !token) return
setArchiving(true)
setError(null)
try {
const isCurrentlyInactive = userDetails?.userStatus?.status === 'inactive'
if (isCurrentlyInactive) {
// Unarchive user
const response = await AdminAPI.unarchiveUser(token, userId)
if (response.success) {
onClose()
if (onUserUpdated) {
onUserUpdated()
}
} else {
throw new Error(response.message || 'Failed to unarchive user')
}
} else {
// Archive user
const response = await AdminAPI.archiveUser(token, userId)
if (response.success) {
onClose()
if (onUserUpdated) {
onUserUpdated()
}
} else {
throw new Error(response.message || 'Failed to archive user')
}
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to archive/unarchive user'
setError(errorMessage)
console.error('UserDetailModal.handleArchiveUser error:', err)
} finally {
setArchiving(false)
setShowArchiveConfirm(false)
}
}
const handleSaveProfile = async () => {
if (!userId || !token || !editedProfile || !userDetails) return
setSaving(true)
setError(null)
try {
const userType = userDetails.user.user_type
const response = await AdminAPI.updateUserProfile(token, userId, editedProfile, userType)
if (response.success) {
// Refresh user details
await fetchUserDetails()
setIsEditing(false)
if (onUserUpdated) {
onUserUpdated()
}
} else {
throw new Error(response.message || 'Failed to update user profile')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update user profile'
setError(errorMessage)
console.error('UserDetailModal.handleSaveProfile error:', err)
} finally {
setSaving(false)
}
}
const handleToggleAdminVerification = async () => {
if (!userId || !token || !userDetails) return
setSaving(true)
setError(null)
try {
const newVerificationStatus = userDetails.userStatus?.is_admin_verified === 1 ? 0 : 1
// Note: You'll need to implement this API method
const response = await AdminAPI.updateUserVerification(token, userId, newVerificationStatus)
if (response.success) {
// Refresh user details
await fetchUserDetails()
if (onUserUpdated) {
onUserUpdated()
}
} else {
throw new Error(response.message || 'Failed to update verification status')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update verification status'
setError(errorMessage)
console.error('UserDetailModal.handleToggleAdminVerification error:', err)
} finally {
setSaving(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const StatusBadge = ({ status, verified }: { status: boolean, verified?: boolean }) => {
if (verified) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
<CheckCircleIcon className="h-3 w-3" />
Verified
</span>
)
}
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
status
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}>
{status ? <CheckCircleIcon className="h-3 w-3" /> : <XCircleIcon className="h-3 w-3" />}
{status ? 'Complete' : 'Incomplete'}
</span>
)
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-6">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white shadow-xl transition-all w-full max-w-4xl max-h-[85vh] flex flex-col">
<div className="absolute right-0 top-0 z-10 pr-4 pt-4">
<button
type="button"
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
onClick={onClose}
>
<span className="sr-only">Close</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
{/* Scrollable Content Area */}
<div className="overflow-y-auto px-4 pb-4 pt-5 sm:p-6">
<div className="w-full">
<Dialog.Title as="h3" className="text-lg font-semibold leading-6 text-gray-900 mb-6 flex items-center gap-2 pr-8">
User Details
{isEditing && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">
<PencilSquareIcon className="h-3 w-3" />
Edit Mode
</span>
)}
</Dialog.Title>
{loading && (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 rounded-full border-2 border-blue-500 border-b-transparent animate-spin" />
<span className="ml-3 text-gray-600">Loading user details...</span>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4 mb-6">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{userDetails && (
<div className="space-y-6">
{/* Basic User Info */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-3 mb-4">
<UserIcon className="h-5 w-5 text-gray-600" />
<h4 className="text-sm font-medium text-gray-900">Basic Information</h4>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Email:</span>
<span className="ml-2 text-gray-600">{userDetails.user.email}</span>
</div>
<div>
<span className="font-medium text-gray-700">Type:</span>
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.user_type}</span>
</div>
<div>
<span className="font-medium text-gray-700">Role:</span>
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.role}</span>
</div>
<div>
<span className="font-medium text-gray-700">Created:</span>
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.created_at)}</span>
</div>
{userDetails.user.last_login_at && (
<div>
<span className="font-medium text-gray-700">Last Login:</span>
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.last_login_at)}</span>
</div>
)}
</div>
</div>
{/* Verification Status */}
{userDetails.userStatus && (
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center gap-3 mb-4">
<ShieldCheckIcon className="h-5 w-5 text-blue-600" />
<h4 className="text-sm font-medium text-gray-900">Verification Status</h4>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700">Email</span>
<StatusBadge status={userDetails.userStatus.email_verified === 1} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700">Profile</span>
<StatusBadge status={userDetails.userStatus.profile_completed === 1} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700">Documents</span>
<StatusBadge status={userDetails.userStatus.documents_uploaded === 1} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700">Contract</span>
<StatusBadge status={userDetails.userStatus.contract_signed === 1} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700">Admin Verified</span>
<StatusBadge
status={userDetails.userStatus.is_admin_verified === 1}
verified={userDetails.userStatus.is_admin_verified === 1}
/>
</div>
</div>
</div>
)}
{/* Profile Information */}
{(userDetails.personalProfile || userDetails.companyProfile) && (
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center gap-3 mb-4">
{userDetails.user.user_type === 'personal' ? (
<UserIcon className="h-5 w-5 text-green-600" />
) : (
<BuildingOfficeIcon className="h-5 w-5 text-green-600" />
)}
<h4 className="text-sm font-medium text-gray-900">Profile Information</h4>
</div>
{isEditing && editedProfile ? (
// Edit mode - show input fields
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
{userDetails.personalProfile && (
<>
<div>
<label className="block font-medium text-gray-700 mb-1">First Name</label>
<input
type="text"
value={editedProfile.first_name || ''}
onChange={(e) => setEditedProfile({...editedProfile, first_name: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Last Name</label>
<input
type="text"
value={editedProfile.last_name || ''}
onChange={(e) => setEditedProfile({...editedProfile, last_name: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Phone</label>
<input
type="text"
value={editedProfile.phone || ''}
onChange={(e) => setEditedProfile({...editedProfile, phone: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Date of Birth</label>
<input
type="date"
value={editedProfile.date_of_birth || ''}
onChange={(e) => setEditedProfile({...editedProfile, date_of_birth: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div className="sm:col-span-2">
<label className="block font-medium text-gray-700 mb-1">Address</label>
<input
type="text"
value={editedProfile.address || ''}
onChange={(e) => setEditedProfile({...editedProfile, address: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">City</label>
<input
type="text"
value={editedProfile.city || ''}
onChange={(e) => setEditedProfile({...editedProfile, city: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Postal Code</label>
<input
type="text"
value={editedProfile.zip_code || ''}
onChange={(e) => setEditedProfile({...editedProfile, zip_code: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Country</label>
<input
type="text"
value={editedProfile.country || ''}
onChange={(e) => setEditedProfile({...editedProfile, country: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
</>
)}
{userDetails.companyProfile && (
<>
<div>
<label className="block font-medium text-gray-700 mb-1">Company Name</label>
<input
type="text"
value={editedProfile.company_name || ''}
onChange={(e) => setEditedProfile({...editedProfile, company_name: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Tax ID</label>
<input
type="text"
value={editedProfile.tax_id || ''}
onChange={(e) => setEditedProfile({...editedProfile, tax_id: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Registration Number</label>
<input
type="text"
value={editedProfile.registration_number || ''}
onChange={(e) => setEditedProfile({...editedProfile, registration_number: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Phone</label>
<input
type="text"
value={editedProfile.phone || ''}
onChange={(e) => setEditedProfile({...editedProfile, phone: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div className="sm:col-span-2">
<label className="block font-medium text-gray-700 mb-1">Address</label>
<input
type="text"
value={editedProfile.address || ''}
onChange={(e) => setEditedProfile({...editedProfile, address: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">City</label>
<input
type="text"
value={editedProfile.city || ''}
onChange={(e) => setEditedProfile({...editedProfile, city: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Postal Code</label>
<input
type="text"
value={editedProfile.zip_code || ''}
onChange={(e) => setEditedProfile({...editedProfile, zip_code: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div>
<label className="block font-medium text-gray-700 mb-1">Country</label>
<input
type="text"
value={editedProfile.country || ''}
onChange={(e) => setEditedProfile({...editedProfile, country: e.target.value})}
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
/>
</div>
</>
)}
</div>
) : (
// View mode - show readonly data
<>
{userDetails.personalProfile && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Name:</span>
<span className="ml-2 text-gray-600">
{userDetails.personalProfile.first_name} {userDetails.personalProfile.last_name}
</span>
</div>
{userDetails.personalProfile.phone && (
<div>
<span className="font-medium text-gray-700">Phone:</span>
<span className="ml-2 text-gray-600">{userDetails.personalProfile.phone}</span>
</div>
)}
{userDetails.personalProfile.date_of_birth && (
<div>
<span className="font-medium text-gray-700">Date of Birth:</span>
<span className="ml-2 text-gray-600">{formatDate(userDetails.personalProfile.date_of_birth)}</span>
</div>
)}
{userDetails.personalProfile.address && (
<div className="sm:col-span-2">
<span className="font-medium text-gray-700">Address:</span>
<span className="ml-2 text-gray-600">
{userDetails.personalProfile.address}, {userDetails.personalProfile.zip_code} {userDetails.personalProfile.city}, {userDetails.personalProfile.country}
</span>
</div>
)}
</div>
)}
{userDetails.companyProfile && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Company Name:</span>
<span className="ml-2 text-gray-600">{userDetails.companyProfile.company_name}</span>
</div>
{userDetails.companyProfile.tax_id && (
<div>
<span className="font-medium text-gray-700">Tax ID:</span>
<span className="ml-2 text-gray-600">{userDetails.companyProfile.tax_id}</span>
</div>
)}
{userDetails.companyProfile.registration_number && (
<div>
<span className="font-medium text-gray-700">Registration Number:</span>
<span className="ml-2 text-gray-600">{userDetails.companyProfile.registration_number}</span>
</div>
)}
{userDetails.companyProfile.phone && (
<div>
<span className="font-medium text-gray-700">Phone:</span>
<span className="ml-2 text-gray-600">{userDetails.companyProfile.phone}</span>
</div>
)}
{userDetails.companyProfile.address && (
<div className="sm:col-span-2">
<span className="font-medium text-gray-700">Address:</span>
<span className="ml-2 text-gray-600">
{userDetails.companyProfile.address}, {userDetails.companyProfile.zip_code} {userDetails.companyProfile.city}, {userDetails.companyProfile.country}
</span>
</div>
)}
</div>
)}
</>
)}
</div>
)}
{/* Documents */}
{(userDetails.documents.length > 0 || userDetails.contracts.length > 0 || userDetails.idDocuments.length > 0) && (
<div className="bg-purple-50 rounded-lg p-4">
<div className="flex items-center gap-3 mb-4">
<DocumentTextIcon className="h-5 w-5 text-purple-600" />
<h4 className="text-sm font-medium text-gray-900">Documents</h4>
</div>
{/* Regular Documents */}
{userDetails.documents.length > 0 && (
<div className="mb-4">
<h5 className="text-xs font-medium text-gray-700 mb-2">Uploaded Documents</h5>
<div className="space-y-2">
{userDetails.documents.map((doc) => (
<div key={doc.id} className="flex items-center justify-between bg-white p-2 rounded border">
<div>
<span className="text-sm font-medium text-gray-900">{doc.file_name}</span>
<span className="text-xs text-gray-500 ml-2">({formatFileSize(doc.file_size)})</span>
</div>
<span className="text-xs text-gray-500">{formatDate(doc.uploaded_at)}</span>
</div>
))}
</div>
</div>
)}
{/* Contracts */}
{userDetails.contracts.length > 0 && (
<div className="mb-4">
<h5 className="text-xs font-medium text-gray-700 mb-2">Contracts</h5>
<div className="space-y-2">
{userDetails.contracts.map((contract) => (
<div key={contract.id} className="flex items-center justify-between bg-white p-2 rounded border">
<div>
<span className="text-sm font-medium text-gray-900">{contract.file_name}</span>
<span className="text-xs text-gray-500 ml-2">({formatFileSize(contract.file_size)})</span>
</div>
<span className="text-xs text-gray-500">{formatDate(contract.uploaded_at)}</span>
</div>
))}
</div>
</div>
)}
{/* ID Documents */}
{userDetails.idDocuments.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-700 mb-2">ID Documents</h5>
<div className="space-y-4">
{userDetails.idDocuments.map((idDoc) => (
<div key={idDoc.id} className="bg-white p-3 rounded border">
<div className="flex items-center gap-2 mb-2">
<IdentificationIcon className="h-4 w-4 text-gray-600" />
<span className="text-sm font-medium text-gray-900">{idDoc.document_type}</span>
<span className="text-xs text-gray-500">{formatDate(idDoc.uploaded_at)}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{idDoc.frontUrl && (
<div>
<p className="text-xs text-gray-700 mb-1">Front:</p>
<img
src={idDoc.frontUrl}
alt="ID Front"
className="max-w-full h-32 object-contain border rounded"
/>
</div>
)}
{idDoc.backUrl && (
<div>
<p className="text-xs text-gray-700 mb-1">Back:</p>
<img
src={idDoc.backUrl}
alt="ID Back"
className="max-w-full h-32 object-contain border rounded"
/>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Permissions */}
{userDetails.permissions.length > 0 && (
<div className="bg-indigo-50 rounded-lg p-4">
<div className="flex items-center gap-3 mb-4">
<ShieldCheckIcon className="h-5 w-5 text-indigo-600" />
<h4 className="text-sm font-medium text-gray-900">Permissions</h4>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{userDetails.permissions.map((permission) => (
<div key={permission.id} className="bg-white p-2 rounded border">
<div className="text-sm font-medium text-gray-900">{permission.name}</div>
{permission.description && (
<div className="text-xs text-gray-600">{permission.description}</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
<div className="mt-5 sm:mt-6">
{showArchiveConfirm ? (
// Archive/Unarchive Confirmation Dialog
<div className={`${userDetails?.userStatus?.status === 'inactive' ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'} border rounded-lg p-4 mb-4`}>
<div className="flex items-center gap-3 mb-3">
<ExclamationTriangleIcon className={`h-5 w-5 ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-600' : 'text-red-600'}`} />
<h4 className={`text-sm font-medium ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-900' : 'text-red-900'}`}>
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
</h4>
</div>
<p className={`text-sm ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-700' : 'text-red-700'} mb-4`}>
{userDetails?.userStatus?.status === 'inactive'
? 'Are you sure you want to unarchive this user? This will reactivate their account.'
: 'Are you sure you want to archive this user? This action will disable their account but preserve all their data.'}
</p>
<div className="flex gap-3">
<button
type="button"
onClick={handleArchiveUser}
disabled={archiving}
className={`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
userDetails?.userStatus?.status === 'inactive'
? 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600'
: 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600'
}`}
>
{archiving ? (
<>
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchiving...' : 'Archiving...'}
</>
) : (
<>
<TrashIcon className="h-4 w-4" />
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
</>
)}
</button>
<button
type="button"
onClick={() => setShowArchiveConfirm(false)}
disabled={archiving}
className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</div>
) : (
// Normal action buttons
<div className="flex flex-col sm:flex-row gap-3">
{isEditing ? (
<>
<button
type="button"
onClick={handleSaveProfile}
disabled={saving}
className="inline-flex items-center justify-center gap-2 rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<>
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
Saving...
</>
) : (
<>
<CheckCircleIcon className="h-4 w-4" />
Save Changes
</>
)}
</button>
<button
type="button"
onClick={() => {
setIsEditing(false)
// Reset edited profile to original
if (userDetails?.personalProfile) {
setEditedProfile(userDetails.personalProfile)
} else if (userDetails?.companyProfile) {
setEditedProfile(userDetails.companyProfile)
}
}}
disabled={saving}
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
<XCircleIcon className="h-4 w-4" />
Cancel
</button>
</>
) : (
<>
<button
type="button"
onClick={() => setIsEditing(true)}
disabled={saving}
className="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<PencilSquareIcon className="h-4 w-4" />
Edit Profile
</button>
{userDetails?.userStatus && (
<button
type="button"
onClick={handleToggleAdminVerification}
disabled={saving}
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
userDetails.userStatus.is_admin_verified === 1
? 'bg-amber-600 hover:bg-amber-500 text-white focus-visible:outline-amber-600'
: 'bg-green-600 hover:bg-green-500 text-white focus-visible:outline-green-600'
}`}
>
{saving ? (
<>
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
Updating...
</>
) : (
<>
<ShieldCheckIcon className="h-4 w-4" />
{userDetails.userStatus.is_admin_verified === 1 ? 'Unverify User' : 'Verify User'}
</>
)}
</button>
)}
<button
type="button"
onClick={() => setShowArchiveConfirm(true)}
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-offset-2 ${
userDetails?.userStatus?.status === 'inactive'
? 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600'
: 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600'
}`}
>
<TrashIcon className="h-4 w-4" />
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-gray-500"
>
Close
</button>
</>
)}
</div>
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}

View File

@ -0,0 +1,95 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
import { Text } from './text'
const sizes = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
export function Alert({
size = 'md',
className,
children,
...props
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
Headless.DialogProps,
'as' | 'className'
>) {
return (
<Headless.Dialog {...props}>
<Headless.DialogBackdrop
transition
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/15 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
/>
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
<div className="grid min-h-full grid-rows-[1fr_auto_1fr] justify-items-center p-8 sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<Headless.DialogPanel
transition
className={clsx(
className,
sizes[size],
'row-start-2 w-full rounded-2xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:rounded-2xl sm:p-6 dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
'transition duration-100 will-change-transform data-closed:opacity-0 data-enter:ease-out data-closed:data-enter:scale-95 data-leave:ease-in'
)}
>
{children}
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
)
}
export function AlertTitle({
className,
...props
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
return (
<Headless.DialogTitle
{...props}
className={clsx(
className,
'text-center text-base/6 font-semibold text-balance text-zinc-950 sm:text-left sm:text-sm/6 sm:text-wrap dark:text-white'
)}
/>
)
}
export function AlertDescription({
className,
...props
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
return (
<Headless.Description
as={Text}
{...props}
className={clsx(className, 'mt-2 text-center text-pretty sm:text-left')}
/>
)
}
export function AlertBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div {...props} className={clsx(className, 'mt-4')} />
}
export function AlertActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
className,
'mt-6 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:mt-4 sm:flex-row sm:*:w-auto'
)}
/>
)
}

View File

@ -0,0 +1,104 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
import Image from 'next/image';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname();
const DELAY_MS = 200;
const EXIT_DURATION = 0.7; // slow the fade/slide-out a bit
const [mounted, setMounted] = useState(false);
const [showOverlay, setShowOverlay] = useState(true);
const [overlayExit, setOverlayExit] = useState(false);
const delayT = useRef<number | null>(null);
useEffect(() => setMounted(true), []);
// Exit overlay shortly after route change (200ms)
useEffect(() => {
setShowOverlay(true);
setOverlayExit(false);
if (delayT.current) clearTimeout(delayT.current);
delayT.current = window.setTimeout(() => setOverlayExit(true), DELAY_MS);
return () => {
if (delayT.current) clearTimeout(delayT.current);
};
}, [pathname]);
// Prevent scroll while overlay is visible
useEffect(() => {
if (!mounted) return;
const prev = document.documentElement.style.overflow;
if (showOverlay) document.documentElement.style.overflow = 'hidden';
return () => {
document.documentElement.style.overflow = prev;
};
}, [showOverlay, mounted]);
return (
<>
<AnimatePresence mode="wait" onExitComplete={() => window.scrollTo(0, 0)}>
<motion.div
key={pathname}
variants={{
hidden: { opacity: 0, x: 0, y: 20 },
enter: { opacity: 1, x: 0, y: 0 },
exit: { opacity: 0, x: 0, y: -20 },
}}
initial="hidden"
animate="enter"
exit="exit"
transition={{ type: 'tween', duration: 0.3 }}
className="w-full"
>
{children}
</motion.div>
</AnimatePresence>
{/* Client-only portal overlay with header gradient (no delay, default timing) */}
{mounted &&
showOverlay &&
createPortal(
<motion.div
initial={false}
animate={overlayExit ? { y: '-100%', opacity: 0 } : { y: 0, opacity: 1 }}
transition={{ duration: EXIT_DURATION, ease: [0.22, 1, 0.36, 1] }}
onAnimationComplete={() => {
if (overlayExit) {
setShowOverlay(false);
window.scrollTo(0, 0);
}
}}
className="fixed inset-0 z-[999999] flex items-center justify-center"
style={{
background:
'linear-gradient(135deg, #0F1D37 0%, #0A162A 50%, #081224 100%)',
}}
>
<div className="flex flex-col items-center">
<Image
src="/images/logos/pp_logo_gold_transparent.png"
alt="Profit Planet"
width={160}
height={160}
className="w-32 h-32 object-contain"
priority
/>
<div
role="status"
aria-live="polite"
className="mt-6 h-10 w-10 rounded-full border-4 border-[#D4AF37] border-t-transparent animate-spin"
/>
</div>
</motion.div>,
document.body
)}
</>
);
};
export default PageTransitionEffect;

View File

@ -0,0 +1,11 @@
import type React from 'react'
export function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<main className="flex min-h-dvh flex-col p-2">
<div className="flex grow items-center justify-center p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
{children}
</div>
</main>
)
}

View File

@ -0,0 +1,87 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'
type AvatarProps = {
src?: string | null
square?: boolean
initials?: string
alt?: string
className?: string
}
export function Avatar({
src = null,
square = false,
initials,
alt = '',
className,
...props
}: AvatarProps & React.ComponentPropsWithoutRef<'span'>) {
return (
<span
data-slot="avatar"
{...props}
className={clsx(
className,
// Basic layout
'inline-grid shrink-0 align-middle [--avatar-radius:20%] *:col-start-1 *:row-start-1',
'outline -outline-offset-1 outline-black/10 dark:outline-white/10',
// Border radius
square ? 'rounded-(--avatar-radius) *:rounded-(--avatar-radius)' : 'rounded-full *:rounded-full'
)}
>
{initials && (
<svg
className="size-full fill-current p-[5%] text-[48px] font-medium uppercase select-none"
viewBox="0 0 100 100"
aria-hidden={alt ? undefined : 'true'}
>
{alt && <title>{alt}</title>}
<text x="50%" y="50%" alignmentBaseline="middle" dominantBaseline="middle" textAnchor="middle" dy=".125em">
{initials}
</text>
</svg>
)}
{src && <img className="size-full" src={src} alt={alt} />}
</span>
)
}
export const AvatarButton = forwardRef(function AvatarButton(
{
src,
square = false,
initials,
alt,
className,
...props
}: AvatarProps &
(
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
),
ref: React.ForwardedRef<HTMLButtonElement>
) {
let classes = clsx(
className,
square ? 'rounded-[20%]' : 'rounded-full',
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
)
return typeof props.href === 'string' ? (
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
<TouchTarget>
<Avatar src={src} square={square} initials={initials} alt={alt} />
</TouchTarget>
</Link>
) : (
<Headless.Button {...props} className={classes} ref={ref}>
<TouchTarget>
<Avatar src={src} square={square} initials={initials} alt={alt} />
</TouchTarget>
</Headless.Button>
)
})

View File

@ -0,0 +1,82 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'
const colors = {
red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:group-data-hover:bg-red-500/20',
orange:
'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25 dark:bg-orange-500/10 dark:text-orange-400 dark:group-data-hover:bg-orange-500/20',
amber:
'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400 dark:group-data-hover:bg-amber-400/15',
yellow:
'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30 dark:bg-yellow-400/10 dark:text-yellow-300 dark:group-data-hover:bg-yellow-400/15',
lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30 dark:bg-lime-400/10 dark:text-lime-300 dark:group-data-hover:bg-lime-400/15',
green:
'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25 dark:bg-green-500/10 dark:text-green-400 dark:group-data-hover:bg-green-500/20',
emerald:
'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:group-data-hover:bg-emerald-500/20',
teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25 dark:bg-teal-500/10 dark:text-teal-300 dark:group-data-hover:bg-teal-500/20',
cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-300 dark:group-data-hover:bg-cyan-400/15',
sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:group-data-hover:bg-sky-500/20',
blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25 dark:text-blue-400 dark:group-data-hover:bg-blue-500/25',
indigo:
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25 dark:text-indigo-400 dark:group-data-hover:bg-indigo-500/20',
violet:
'bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25 dark:text-violet-400 dark:group-data-hover:bg-violet-500/20',
purple:
'bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25 dark:text-purple-400 dark:group-data-hover:bg-purple-500/20',
fuchsia:
'bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25 dark:bg-fuchsia-400/10 dark:text-fuchsia-400 dark:group-data-hover:bg-fuchsia-400/20',
pink: 'bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25 dark:bg-pink-400/10 dark:text-pink-400 dark:group-data-hover:bg-pink-400/20',
rose: 'bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25 dark:bg-rose-400/10 dark:text-rose-400 dark:group-data-hover:bg-rose-400/20',
zinc: 'bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20 dark:bg-white/5 dark:text-zinc-400 dark:group-data-hover:bg-white/10',
}
type BadgeProps = { color?: keyof typeof colors }
export function Badge({ color = 'zinc', className, ...props }: BadgeProps & React.ComponentPropsWithoutRef<'span'>) {
return (
<span
{...props}
className={clsx(
className,
'inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5 forced-colors:outline',
colors[color]
)}
/>
)
}
export const BadgeButton = forwardRef(function BadgeButton(
{
color = 'zinc',
className,
children,
...props
}: BadgeProps & { className?: string; children: React.ReactNode } & (
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
),
ref: React.ForwardedRef<HTMLElement>
) {
let classes = clsx(
className,
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
)
return typeof props.href === 'string' ? (
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
<TouchTarget>
<Badge color={color}>{children}</Badge>
</TouchTarget>
</Link>
) : (
<Headless.Button {...props} className={classes} ref={ref}>
<TouchTarget>
<Badge color={color}>{children}</Badge>
</TouchTarget>
</Headless.Button>
)
})

View File

@ -0,0 +1,204 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { Link } from './link'
const styles = {
base: [
// Base
'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
// Sizing
'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
// Focus
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
// Disabled
'data-disabled:opacity-50',
// Icon
'*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]',
],
solid: [
// Optical border, implemented as the button background to avoid corner artifacts
'border-transparent bg-(--btn-border)',
// Dark mode: border is rendered on `after` so background is set to button background
'dark:bg-(--btn-bg)',
// Button background, implemented as foreground layer to stack on top of pseudo-border layer
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)',
// Drop shadow, applied to the inset `before` layer so it blends with the border
'before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
'dark:before:hidden',
// Dark mode: Subtle white outline is applied using a border
'dark:border-white/5',
// Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]',
// Inner highlight shadow
'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
// White overlay on hover
'data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)',
// Dark mode: `after` layer expands to cover entire button
'dark:after:-inset-px dark:after:rounded-lg',
// Disabled
'data-disabled:before:shadow-none data-disabled:after:shadow-none',
],
outline: [
// Base
'border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/2.5 data-hover:bg-zinc-950/2.5',
// Dark mode
'dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-active:bg-white/5 dark:data-hover:bg-white/5',
// Icon
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
],
plain: [
// Base
'border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5',
// Dark mode
'dark:text-white dark:data-active:bg-white/10 dark:data-hover:bg-white/10',
// Icon
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
],
colors: {
'dark/zinc': [
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
'dark:text-white dark:[--btn-bg:var(--color-zinc-600)] dark:[--btn-hover-overlay:var(--color-white)]/5',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
light: [
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
'dark:text-white dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
],
'dark/white': [
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
],
dark: [
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
'dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
white: [
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
'dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]',
],
zinc: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90',
'dark:[--btn-hover-overlay:var(--color-white)]/5',
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
],
indigo: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90',
'[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]',
],
cyan: [
'text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25',
'[--btn-icon:var(--color-cyan-500)]',
],
red: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90',
'[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]',
],
orange: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90',
'[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]',
],
amber: [
'text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80',
'[--btn-icon:var(--color-amber-600)]',
],
yellow: [
'text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80',
'[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]',
],
lime: [
'text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80',
'[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]',
],
green: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
emerald: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
teal: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
sky: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80',
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
],
blue: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-blue-600)] [--btn-border:var(--color-blue-700)]/90',
'[--btn-icon:var(--color-blue-400)] data-active:[--btn-icon:var(--color-blue-300)] data-hover:[--btn-icon:var(--color-blue-300)]',
],
violet: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90',
'[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]',
],
purple: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90',
'[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]',
],
fuchsia: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90',
'[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]',
],
pink: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90',
'[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]',
],
rose: [
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90',
'[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]',
],
},
}
type ButtonProps = (
| { color?: keyof typeof styles.colors; outline?: never; plain?: never }
| { color?: never; outline: true; plain?: never }
| { color?: never; outline?: never; plain: true }
) & { className?: string; children: React.ReactNode } & (
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
)
export const Button = forwardRef(function Button(
{ color, outline, plain, className, children, ...props }: ButtonProps,
ref: React.ForwardedRef<HTMLElement>
) {
let classes = clsx(
className,
styles.base,
outline ? styles.outline : plain ? styles.plain : clsx(styles.solid, styles.colors[color ?? 'dark/zinc'])
)
return typeof props.href === 'string' ? (
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
<TouchTarget>{children}</TouchTarget>
</Link>
) : (
<Headless.Button {...props} className={clsx(classes, 'cursor-default')} ref={ref}>
<TouchTarget>{children}</TouchTarget>
</Headless.Button>
)
})
/**
* Expand the hit area to at least 44×44px on touch devices
*/
export function TouchTarget({ children }: { children: React.ReactNode }) {
return (
<>
<span
className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden"
aria-hidden="true"
/>
{children}
</>
)
}

View File

@ -0,0 +1,157 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
data-slot="control"
{...props}
className={clsx(
className,
// Basic groups
'space-y-3',
// With descriptions
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
export function CheckboxField({
className,
...props
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
return (
<Headless.Field
data-slot="field"
{...props}
className={clsx(
className,
// Base layout
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
// Control layout
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
// Label layout
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
// Description layout
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
// With description
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
const base = [
// Basic layout
'relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
// Background color when checked
'group-data-checked:before:bg-(--checkbox-checked-bg)',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
'dark:before:hidden',
// Background color applied to control in dark mode
'dark:bg-white/5 dark:group-data-checked:bg-(--checkbox-checked-bg)',
// Border
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
// Inner highlight shadow
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
'dark:after:-inset-px dark:after:hidden dark:after:rounded-[0.3125rem] dark:group-data-checked:after:block',
// Focus ring
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
// Disabled state
'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--checkbox-check:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
// Forced colors mode
'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
'dark:forced-colors:[--checkbox-check:HighlightText] dark:forced-colors:[--checkbox-checked-bg:Highlight] dark:forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
]
const colors = {
'dark/zinc': [
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
'dark:[--checkbox-checked-bg:var(--color-zinc-600)]',
],
'dark/white': [
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
'dark:[--checkbox-check:var(--color-zinc-900)] dark:[--checkbox-checked-bg:var(--color-white)] dark:[--checkbox-checked-border:var(--color-zinc-950)]/15',
],
white:
'[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
orange:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
amber:
'[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
yellow:
'[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
green:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
emerald:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
indigo:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
violet:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
purple:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
fuchsia:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
}
type Color = keyof typeof colors
export function Checkbox({
color = 'dark/zinc',
className,
...props
}: {
color?: Color
className?: string
} & Omit<Headless.CheckboxProps, 'as' | 'className'>) {
return (
<Headless.Checkbox
data-slot="control"
{...props}
className={clsx(className, 'group inline-flex focus:outline-hidden')}
>
<span className={clsx([base, colors[color]])}>
<svg
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
viewBox="0 0 14 14"
fill="none"
>
{/* Checkmark icon */}
<path
className="opacity-100 group-data-indeterminate:opacity-0"
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Indeterminate icon */}
<path
className="opacity-0 group-data-indeterminate:opacity-100"
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Headless.Checkbox>
)
}

View File

@ -0,0 +1,188 @@
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { useState } from 'react'
export function Combobox<T>({
options,
displayValue,
filter,
anchor = 'bottom',
className,
placeholder,
autoFocus,
'aria-label': ariaLabel,
children,
...props
}: {
options: T[]
displayValue: (value: T | null) => string | undefined
filter?: (value: T, query: string) => boolean
className?: string
placeholder?: string
autoFocus?: boolean
'aria-label'?: string
children: (value: NonNullable<T>) => React.ReactElement
} & Omit<Headless.ComboboxProps<T, false>, 'as' | 'multiple' | 'children'> & { anchor?: 'top' | 'bottom' }) {
const [query, setQuery] = useState('')
const filteredOptions =
query === ''
? options
: options.filter((option) =>
filter ? filter(option, query) : displayValue(option)?.toLowerCase().includes(query.toLowerCase())
)
return (
<Headless.Combobox {...props} multiple={false} virtual={{ options: filteredOptions }} onClose={() => setQuery('')}>
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
'dark:before:hidden',
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
// Invalid state
'has-data-invalid:before:shadow-red-500/10',
])}
>
<Headless.ComboboxInput
autoFocus={autoFocus}
data-slot="control"
aria-label={ariaLabel}
displayValue={(option: T) => displayValue(option) ?? ''}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
className={clsx([
className,
// Basic layout
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Horizontal padding
'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
// Background color
'bg-transparent dark:bg-white/5',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-500 dark:data-invalid:data-hover:border-red-500',
// Disabled state
'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
// System icons
'dark:scheme-dark',
])}
/>
<Headless.ComboboxButton className="group absolute inset-y-0 right-0 flex items-center px-2">
<svg
className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 group-data-hover:stroke-zinc-700 sm:size-4 dark:stroke-zinc-400 dark:group-data-hover:stroke-zinc-300 forced-colors:stroke-[CanvasText]"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
>
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Headless.ComboboxButton>
</span>
<Headless.ComboboxOptions
transition
anchor={anchor}
className={clsx(
// Anchor positioning
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
// Base styles,
'isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible',
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
'outline outline-transparent focus:outline-hidden',
// Handle scrolling when menu won't fit in viewport
'overflow-y-scroll overscroll-contain',
// Popover background
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
// Shadows
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
// Transitions
'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
)}
>
{({ option }) => children(option)}
</Headless.ComboboxOptions>
</Headless.Combobox>
)
}
export function ComboboxOption<T>({
children,
className,
...props
}: { className?: string; children?: React.ReactNode } & Omit<
Headless.ComboboxOptionProps<'div', T>,
'as' | 'className'
>) {
let sharedClasses = clsx(
// Base
'flex min-w-0 items-center',
// Icons
'*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
'*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
// Avatars
'*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
)
return (
<Headless.ComboboxOption
{...props}
className={clsx(
// Basic layout
'group/option grid w-full cursor-default grid-cols-[1fr_--spacing(5)] items-baseline gap-x-2 rounded-lg py-2.5 pr-2 pl-3.5 sm:grid-cols-[1fr_--spacing(4)] sm:py-1.5 sm:pr-2 sm:pl-3',
// Typography
'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
// Focus
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
// Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
// Disabled
'data-disabled:opacity-50'
)}
>
<span className={clsx(className, sharedClasses)}>{children}</span>
<svg
className="relative col-start-2 hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Headless.ComboboxOption>
)
}
export function ComboboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
}
export function ComboboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
return (
<span
{...props}
className={clsx(
className,
'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400'
)}
>
<span className="flex-1 truncate">{children}</span>
</span>
)
}

View File

@ -0,0 +1,870 @@
'use client'
import { useState, useEffect } from 'react'
import {
EnvelopeIcon,
DocumentTextIcon,
UserPlusIcon,
DocumentCheckIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'
import useAuthStore from '../../store/authStore'
import { useUserStatus } from '../../hooks/useUserStatus'
interface QuickAction {
id: string
title: string
description: string
icon: any
color: string
status: 'pending' | 'completed' | 'unavailable'
onClick: () => void
}
// UserStatus interface is now imported from useUserStatus hook
export default function QuickActions() {
const { userStatus, loading, error, refreshStatus } = useUserStatus()
const [showEmailVerification, setShowEmailVerification] = useState(false)
const [showDocumentUpload, setShowDocumentUpload] = useState(false)
const [showProfileCompletion, setShowProfileCompletion] = useState(false)
const [showContractSigning, setShowContractSigning] = useState(false)
const [isClient, setIsClient] = useState(false)
const user = useAuthStore(state => state.user)
// Handle SSR hydration
useEffect(() => {
setIsClient(true)
}, [])
// Debug logging (can be removed in production)
useEffect(() => {
if (isClient && process.env.NODE_ENV === 'development') {
console.log('🔍 [QuickActions] userStatus changed:', userStatus)
console.log('🔍 [QuickActions] loading state:', loading)
console.log('🔍 [QuickActions] error state:', error)
}
}, [isClient, userStatus, loading, error])
// Don't render until client-side hydration is complete
if (!isClient) {
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Account Setup</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white rounded-lg p-6 shadow-sm border border-gray-200">
<div className="flex items-start">
<div className="bg-gray-200 rounded-lg p-3 w-12 h-12"></div>
<div className="ml-4 flex-1">
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-3 bg-gray-200 rounded"></div>
</div>
</div>
</div>
))}
</div>
</div>
)
}
const getActionStatus = (action: string): 'pending' | 'completed' | 'unavailable' => {
// If loading or no userStatus, show initial states (email is pending, others unavailable)
if (!userStatus) {
if (process.env.NODE_ENV === 'development') {
console.log(`🔍 [getActionStatus] No userStatus for action: ${action}`)
}
switch (action) {
case 'email':
return 'pending'
default:
return 'unavailable'
}
}
if (process.env.NODE_ENV === 'development') {
console.log(`🔍 [getActionStatus] ${action}:`, {
email_verified: userStatus.email_verified,
documents_uploaded: userStatus.documents_uploaded,
profile_completed: userStatus.profile_completed,
contract_signed: userStatus.contract_signed
})
}
switch (action) {
case 'email':
return userStatus.email_verified ? 'completed' : 'pending'
case 'documents':
return !userStatus.email_verified ? 'unavailable' :
userStatus.documents_uploaded ? 'completed' : 'pending'
case 'profile':
return !userStatus.documents_uploaded ? 'unavailable' :
userStatus.profile_completed ? 'completed' : 'pending'
case 'contract':
return !userStatus.profile_completed ? 'unavailable' :
userStatus.contract_signed ? 'completed' : 'pending'
default:
return 'pending'
}
}
const quickActions: QuickAction[] = [
{
id: 'email',
title: 'Verify Email',
description: 'Confirm your email address to activate your account',
icon: EnvelopeIcon,
color: 'bg-blue-500',
status: getActionStatus('email'),
onClick: () => setShowEmailVerification(true)
},
{
id: 'documents',
title: 'Upload ID Documents',
description: user?.userType === 'company' ? 'Upload company registration documents' : 'Upload personal identification documents',
icon: DocumentTextIcon,
color: 'bg-green-500',
status: getActionStatus('documents'),
onClick: () => setShowDocumentUpload(true)
},
{
id: 'profile',
title: 'Complete Profile',
description: 'Add additional information to complete your profile',
icon: UserPlusIcon,
color: 'bg-purple-500',
status: getActionStatus('profile'),
onClick: () => setShowProfileCompletion(true)
},
{
id: 'contract',
title: 'Sign Contract',
description: 'Review and sign your service agreement',
icon: DocumentCheckIcon,
color: 'bg-orange-500',
status: getActionStatus('contract'),
onClick: () => setShowContractSigning(true)
}
]
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircleIcon className="h-5 w-5 text-green-500" />
case 'unavailable':
return <XCircleIcon className="h-5 w-5 text-gray-400" />
default:
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'completed':
return 'Completed'
case 'unavailable':
return 'Locked'
default:
return 'Pending'
}
}
// Show error state if there's an error
if (error && !userStatus) {
return (
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Account Setup</h2>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex">
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error loading account status</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
<div className="mt-4">
<button
type="button"
onClick={refreshStatus}
className="bg-red-100 px-2 py-1 text-xs font-semibold text-red-800 hover:bg-red-200 rounded"
>
Try again
</button>
</div>
</div>
</div>
</div>
</div>
)
}
return (
<>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Account Setup</h2>
{loading && (
<div className="flex items-center text-sm text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#8D6B1D] mr-2"></div>
Updating status...
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{quickActions.map((action) => (
<button
key={action.id}
onClick={action.onClick}
disabled={action.status === 'unavailable' || loading}
className={`
bg-white rounded-lg p-6 shadow-sm border text-left group transition-all duration-200
${loading ? 'opacity-75' : ''}
${action.status === 'unavailable'
? 'border-gray-200 cursor-not-allowed opacity-60'
: action.status === 'completed'
? 'border-green-200 hover:border-green-300'
: 'border-gray-200 hover:shadow-md hover:border-gray-300'
}
`}
>
<div className="flex items-start">
<div className={`
${loading ? 'animate-pulse bg-gray-300' : action.color} rounded-lg p-3 transition-transform
${action.status === 'unavailable' ? 'opacity-60' : 'group-hover:scale-105'}
${action.status === 'completed' && !loading ? 'bg-green-500' : ''}
`}>
<action.icon className="h-6 w-6 text-white" />
</div>
<div className="ml-4 flex-1">
<div className="flex items-center justify-between mb-1">
<h3 className={`
text-lg font-medium transition-colors
${loading ? 'text-gray-400' : ''}
${action.status === 'unavailable'
? 'text-gray-400'
: action.status === 'completed'
? 'text-green-700'
: 'text-gray-900 group-hover:text-[#8D6B1D]'
}
`}>
{action.title}
</h3>
{loading ? (
<div className="animate-pulse bg-gray-300 rounded-full h-5 w-5"></div>
) : (
getStatusIcon(action.status)
)}
</div>
<p className={`
text-sm mt-1
${loading ? 'text-gray-400' : ''}
${action.status === 'unavailable' ? 'text-gray-400' : 'text-gray-600'}
`}>
{action.description}
</p>
<div className="mt-2">
{loading ? (
<div className="animate-pulse bg-gray-200 rounded-full h-5 w-16"></div>
) : (
<span className={`
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${action.status === 'completed'
? 'bg-green-100 text-green-800'
: action.status === 'unavailable'
? 'bg-gray-100 text-gray-800'
: 'bg-yellow-100 text-yellow-800'
}
`}>
{getStatusText(action.status)}
</span>
)}
</div>
</div>
</div>
</button>
))}
</div>
</div>
{/* Modals */}
{showEmailVerification && (
<EmailVerificationModal
onClose={() => setShowEmailVerification(false)}
onSuccess={refreshStatus}
/>
)}
{showDocumentUpload && (
<DocumentUploadModal
userType={user?.userType || 'personal'}
onClose={() => setShowDocumentUpload(false)}
onSuccess={refreshStatus}
/>
)}
{showProfileCompletion && (
<ProfileCompletionModal
userType={user?.userType || 'personal'}
onClose={() => setShowProfileCompletion(false)}
onSuccess={refreshStatus}
/>
)}
{showContractSigning && (
<ContractSigningModal
userType={user?.userType || 'personal'}
onClose={() => setShowContractSigning(false)}
onSuccess={refreshStatus}
/>
)}
</>
)
}
// Email Verification Modal Component
function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, onSuccess: () => void }) {
const [verificationCode, setVerificationCode] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const token = useAuthStore(state => state.accessToken)
const sendVerificationEmail = async () => {
if (!token) return
setLoading(true)
setError('')
setMessage('')
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
const data = await response.json()
if (response.ok) {
setMessage('Verification email sent! Check your inbox.')
} else {
setError(data.message || 'Failed to send verification email')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
const verifyCode = async () => {
if (!token || !verificationCode.trim()) return
setLoading(true)
setError('')
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/verify-email-code`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ code: verificationCode })
})
const data = await response.json()
if (response.ok) {
setMessage('Email verified successfully!')
setTimeout(() => {
onSuccess()
onClose()
}, 1500)
} else {
setError(data.message || 'Invalid verification code')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Email Verification</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircleIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-4">
We'll send a verification code to your email address.
</p>
<button
onClick={sendVerificationEmail}
disabled={loading}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Sending...' : 'Send Verification Email'}
</button>
</div>
<div>
<label htmlFor="code" className="block text-sm font-medium text-gray-700 mb-1">
Verification Code
</label>
<input
type="text"
id="code"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
placeholder="Enter 6-digit code"
maxLength={6}
/>
</div>
<button
onClick={verifyCode}
disabled={loading || !verificationCode.trim()}
className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Verifying...' : 'Verify Email'}
</button>
{message && (
<div className="text-green-600 text-sm text-center">{message}</div>
)}
{error && (
<div className="text-red-600 text-sm text-center">{error}</div>
)}
</div>
</div>
</div>
)
}
// Document Upload Modal Component
function DocumentUploadModal({ userType, onClose, onSuccess }: {
userType: string,
onClose: () => void,
onSuccess: () => void
}) {
const [frontFile, setFrontFile] = useState<File | null>(null)
const [backFile, setBackFile] = useState<File | null>(null)
const [idType, setIdType] = useState('')
const [idNumber, setIdNumber] = useState('')
const [expiryDate, setExpiryDate] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const token = useAuthStore(state => state.accessToken)
const handleUpload = async () => {
if (!token || !frontFile || !idType || !idNumber || !expiryDate) {
setError('Please fill in all required fields and select front image')
return
}
setLoading(true)
setError('')
setMessage('')
const formData = new FormData()
formData.append('front', frontFile)
if (backFile) {
formData.append('back', backFile)
}
formData.append('idType', idType)
formData.append('idNumber', idNumber)
formData.append('expiryDate', expiryDate)
try {
const endpoint = userType === 'company' ? '/api/upload/company-id' : '/api/upload/personal-id'
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
const data = await response.json()
if (response.ok) {
setMessage('Documents uploaded successfully!')
setTimeout(() => {
onSuccess()
onClose()
}, 1500)
} else {
setError(data.message || 'Failed to upload documents')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Upload {userType === 'company' ? 'Company' : 'Personal'} Documents
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircleIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{userType === 'company' ? 'Company Registration (Front)' : 'ID Document (Front)'}
</label>
<input
type="file"
accept="image/*,.pdf"
onChange={(e) => setFrontFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{userType === 'company' ? 'Additional Documents (Optional)' : 'ID Document (Back)'}
</label>
<input
type="file"
accept="image/*,.pdf"
onChange={(e) => setBackFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{userType === 'company' ? 'Document Type' : 'ID Type'} <span className="text-red-500">*</span>
</label>
<select
value={idType}
onChange={(e) => setIdType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
>
<option value="">Select Document Type</option>
{userType === 'company' ? (
<>
<option value="business_registration">Business Registration</option>
<option value="tax_certificate">Tax Certificate</option>
<option value="business_license">Business License</option>
<option value="other">Other</option>
</>
) : (
<>
<option value="passport">Passport</option>
<option value="driver_license">Driver's License</option>
<option value="national_id">National ID</option>
<option value="other">Other</option>
</>
)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{userType === 'company' ? 'Registration/Document Number' : 'ID Number'} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={idNumber}
onChange={(e) => setIdNumber(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
placeholder={userType === 'company' ? 'Enter registration number' : 'Enter ID number'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Expiry Date <span className="text-red-500">*</span>
</label>
<input
type="date"
value={expiryDate}
onChange={(e) => setExpiryDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<button
onClick={handleUpload}
disabled={loading || !frontFile || !idType || !idNumber || !expiryDate}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Uploading...' : 'Upload Documents'}
</button>
{message && (
<div className="text-green-600 text-sm text-center">{message}</div>
)}
{error && (
<div className="text-red-600 text-sm text-center">{error}</div>
)}
</div>
</div>
</div>
)
}
// Profile Completion Modal Component
function ProfileCompletionModal({ userType, onClose, onSuccess }: {
userType: string,
onClose: () => void,
onSuccess: () => void
}) {
const [formData, setFormData] = useState<any>({})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const token = useAuthStore(state => state.accessToken)
const handleSubmit = async () => {
if (!token) return
setLoading(true)
setError('')
setMessage('')
try {
const endpoint = userType === 'company' ? '/api/profile/company/complete' : '/api/profile/personal/complete'
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
})
const data = await response.json()
if (response.ok) {
setMessage('Profile completed successfully!')
setTimeout(() => {
onSuccess()
onClose()
}, 1500)
} else {
setError(data.message || 'Failed to complete profile')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Complete Profile</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircleIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
{userType === 'company' ? (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
<input
type="text"
value={formData.companyName || ''}
onChange={(e) => setFormData({...formData, companyName: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Industry</label>
<input
type="text"
value={formData.industry || ''}
onChange={(e) => setFormData({...formData, industry: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
</>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone Number</label>
<input
type="tel"
value={formData.phone || ''}
onChange={(e) => setFormData({...formData, phone: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Address</label>
<textarea
value={formData.address || ''}
onChange={(e) => setFormData({...formData, address: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
rows={3}
/>
</div>
</>
)}
<button
onClick={handleSubmit}
disabled={loading}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Saving...' : 'Complete Profile'}
</button>
{message && (
<div className="text-green-600 text-sm text-center">{message}</div>
)}
{error && (
<div className="text-red-600 text-sm text-center">{error}</div>
)}
</div>
</div>
</div>
)
}
// Contract Signing Modal Component
function ContractSigningModal({ userType, onClose, onSuccess }: {
userType: string,
onClose: () => void,
onSuccess: () => void
}) {
const [contractFile, setContractFile] = useState<File | null>(null)
const [agreed, setAgreed] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const token = useAuthStore(state => state.accessToken)
const handleUpload = async () => {
if (!token || !contractFile || !agreed) return
setLoading(true)
setError('')
setMessage('')
const formData = new FormData()
formData.append('contract', contractFile)
try {
const endpoint = userType === 'company' ? '/api/upload/contract/company' : '/api/upload/contract/personal'
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
const data = await response.json()
if (response.ok) {
setMessage('Contract signed successfully!')
setTimeout(() => {
onSuccess()
onClose()
}, 1500)
} else {
setError(data.message || 'Failed to upload contract')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Sign Contract</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircleIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-4">
Please review and upload your signed service agreement.
</p>
<label className="block text-sm font-medium text-gray-700 mb-2">
Signed Contract Document
</label>
<input
type="file"
accept="image/*,.pdf"
onChange={(e) => setContractFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div className="flex items-start">
<input
type="checkbox"
id="agreement"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
className="mt-1 h-4 w-4 text-[#8D6B1D] focus:ring-[#8D6B1D] border-gray-300 rounded"
/>
<label htmlFor="agreement" className="ml-2 text-sm text-gray-600">
I have read, understood, and agree to the terms and conditions of this service agreement.
</label>
</div>
<button
onClick={handleUpload}
disabled={loading || !contractFile || !agreed}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Uploading...' : 'Upload Signed Contract'}
</button>
{message && (
<div className="text-green-600 text-sm text-center">{message}</div>
)}
{error && (
<div className="text-red-600 text-sm text-center">{error}</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,66 @@
import React from "react";
type DeleteConfirmationModalProps = {
open: boolean;
title?: string;
description?: string;
confirmText?: string;
cancelText?: string;
loading?: boolean;
onConfirm: () => void;
onCancel: () => void;
children?: React.ReactNode;
};
export default function DeleteConfirmationModal({
open,
title = "Delete Item",
description = "Are you sure you want to delete this item? This action cannot be undone.",
confirmText = "Delete",
cancelText = "Cancel",
loading = false,
onConfirm,
onCancel,
children,
}: DeleteConfirmationModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40" onClick={onCancel} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
<div className="p-6">
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-red-100">
<svg width="24" height="24" fill="none" stroke="currentColor" className="text-red-600">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
</div>
<p className="text-sm text-gray-700 mb-4">{description}</p>
{children}
<div className="flex items-center justify-end gap-2 mt-6">
<button
type="button"
onClick={onCancel}
className="text-sm px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200"
disabled={loading}
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className="text-sm px-4 py-2 rounded-lg bg-red-600 hover:bg-red-500 text-white disabled:opacity-60"
>
{loading ? "Deleting…" : confirmText}
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import clsx from 'clsx'
export function DescriptionList({ className, ...props }: React.ComponentPropsWithoutRef<'dl'>) {
return (
<dl
{...props}
className={clsx(
className,
'grid grid-cols-1 text-base/6 sm:grid-cols-[min(50%,--spacing(80))_auto] sm:text-sm/6'
)}
/>
)
}
export function DescriptionTerm({ className, ...props }: React.ComponentPropsWithoutRef<'dt'>) {
return (
<dt
{...props}
className={clsx(
className,
'col-start-1 border-t border-zinc-950/5 pt-3 text-zinc-500 first:border-none sm:border-t sm:border-zinc-950/5 sm:py-3 dark:border-white/5 dark:text-zinc-400 sm:dark:border-white/5'
)}
/>
)
}
export function DescriptionDetails({ className, ...props }: React.ComponentPropsWithoutRef<'dd'>) {
return (
<dd
{...props}
className={clsx(
className,
'pt-1 pb-3 text-zinc-950 sm:border-t sm:border-zinc-950/5 sm:py-3 sm:nth-2:border-none dark:text-white dark:sm:border-white/5'
)}
/>
)
}

View File

@ -0,0 +1,86 @@
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
import { Text } from './text'
const sizes = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
export function Dialog({
size = 'lg',
className,
children,
...props
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
Headless.DialogProps,
'as' | 'className'
>) {
return (
<Headless.Dialog {...props}>
<Headless.DialogBackdrop
transition
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
/>
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<Headless.DialogPanel
transition
className={clsx(
className,
sizes[size],
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
)}
>
{children}
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
)
}
export function DialogTitle({
className,
...props
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
return (
<Headless.DialogTitle
{...props}
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white')}
/>
)
}
export function DialogDescription({
className,
...props
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
}
export function DialogBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div {...props} className={clsx(className, 'mt-6')} />
}
export function DialogActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
className={clsx(
className,
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
)}
/>
)
}

View File

@ -0,0 +1,20 @@
import clsx from 'clsx'
export function Divider({
soft = false,
className,
...props
}: { soft?: boolean } & React.ComponentPropsWithoutRef<'hr'>) {
return (
<hr
role="presentation"
{...props}
className={clsx(
className,
'w-full border-t',
soft && 'border-zinc-950/5 dark:border-white/5',
!soft && 'border-zinc-950/10 dark:border-white/10'
)}
/>
)
}

View File

@ -0,0 +1,183 @@
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
import { Button } from './button'
import { Link } from './link'
export function Dropdown(props: Headless.MenuProps) {
return <Headless.Menu {...props} />
}
export function DropdownButton<T extends React.ElementType = typeof Button>({
as = Button,
...props
}: { className?: string } & Omit<Headless.MenuButtonProps<T>, 'className'>) {
return <Headless.MenuButton as={as} {...props} />
}
export function DropdownMenu({
anchor = 'bottom',
className,
...props
}: { className?: string } & Omit<Headless.MenuItemsProps, 'as' | 'className'>) {
return (
<Headless.MenuItems
{...props}
transition
anchor={anchor}
className={clsx(
className,
// Anchor positioning
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(1)] data-[anchor~=end]:[--anchor-offset:6px] data-[anchor~=start]:[--anchor-offset:-6px] sm:data-[anchor~=end]:[--anchor-offset:4px] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
// Base styles
'isolate w-max rounded-xl p-1',
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
'outline outline-transparent focus:outline-hidden',
// Handle scrolling when menu won't fit in viewport
'overflow-y-auto',
// Popover background
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
// Shadows
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
// Define grid at the menu level if subgrid is supported
'supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]',
// Transitions
'transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0'
)}
/>
)
}
export function DropdownItem({
className,
...props
}: { className?: string } & (
| ({ href?: never } & Omit<Headless.MenuItemProps<'button'>, 'as' | 'className'>)
| ({ href: string } & Omit<Headless.MenuItemProps<typeof Link>, 'as' | 'className'>)
)) {
let classes = clsx(
className,
// Base styles
'group cursor-default rounded-lg px-3.5 py-2.5 focus:outline-hidden sm:px-3 sm:py-1.5',
// Text styles
'text-left text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
// Focus
'data-focus:bg-blue-500 data-focus:text-white',
// Disabled state
'data-disabled:opacity-50',
// Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText] forced-colors:data-focus:*:data-[slot=icon]:text-[HighlightText]',
// Use subgrid when available but fallback to an explicit grid layout if not
'col-span-full grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] items-center supports-[grid-template-columns:subgrid]:grid-cols-subgrid',
// Icons
'*:data-[slot=icon]:col-start-1 *:data-[slot=icon]:row-start-1 *:data-[slot=icon]:mr-2.5 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:mr-2 sm:*:data-[slot=icon]:size-4',
'*:data-[slot=icon]:text-zinc-500 data-focus:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400 dark:data-focus:*:data-[slot=icon]:text-white',
// Avatar
'*:data-[slot=avatar]:mr-2.5 *:data-[slot=avatar]:-ml-1 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:mr-2 sm:*:data-[slot=avatar]:size-5'
)
return typeof props.href === 'string' ? (
<Headless.MenuItem as={Link} {...props} className={classes} />
) : (
<Headless.MenuItem as="button" type="button" {...props} className={classes} />
)
}
export function DropdownHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div {...props} className={clsx(className, 'col-span-5 px-3.5 pt-2.5 pb-1 sm:px-3')} />
}
export function DropdownSection({
className,
...props
}: { className?: string } & Omit<Headless.MenuSectionProps, 'as' | 'className'>) {
return (
<Headless.MenuSection
{...props}
className={clsx(
className,
// Define grid at the section level instead of the item level if subgrid is supported
'col-span-full supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]'
)}
/>
)
}
export function DropdownHeading({
className,
...props
}: { className?: string } & Omit<Headless.MenuHeadingProps, 'as' | 'className'>) {
return (
<Headless.MenuHeading
{...props}
className={clsx(
className,
'col-span-full grid grid-cols-[1fr_auto] gap-x-12 px-3.5 pt-2 pb-1 text-sm/5 font-medium text-zinc-500 sm:px-3 sm:text-xs/5 dark:text-zinc-400'
)}
/>
)
}
export function DropdownDivider({
className,
...props
}: { className?: string } & Omit<Headless.MenuSeparatorProps, 'as' | 'className'>) {
return (
<Headless.MenuSeparator
{...props}
className={clsx(
className,
'col-span-full mx-3.5 my-1 h-px border-0 bg-zinc-950/5 sm:mx-3 dark:bg-white/10 forced-colors:bg-[CanvasText]'
)}
/>
)
}
export function DropdownLabel({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div {...props} data-slot="label" className={clsx(className, 'col-start-2 row-start-1')} {...props} />
}
export function DropdownDescription({
className,
...props
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
return (
<Headless.Description
data-slot="description"
{...props}
className={clsx(
className,
'col-span-2 col-start-2 row-start-2 text-sm/5 text-zinc-500 group-data-focus:text-white sm:text-xs/5 dark:text-zinc-400 forced-colors:group-data-focus:text-[HighlightText]'
)}
/>
)
}
export function DropdownShortcut({
keys,
className,
...props
}: { keys: string | string[]; className?: string } & Omit<Headless.DescriptionProps<'kbd'>, 'as' | 'className'>) {
return (
<Headless.Description
as="kbd"
{...props}
className={clsx(className, 'col-start-5 row-start-1 flex justify-self-end')}
>
{(Array.isArray(keys) ? keys : keys.split('')).map((char, index) => (
<kbd
key={index}
className={clsx([
'min-w-[2ch] text-center font-sans text-zinc-400 capitalize group-data-focus:text-white forced-colors:group-data-focus:text-[HighlightText]',
// Make sure key names that are longer than one character (like "Tab") have extra space
index > 0 && char.length > 1 && 'pl-1',
])}
>
{char}
</kbd>
))}
</Headless.Description>
)
}

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