This commit is contained in:
seaznCode 2026-01-13 16:13:23 +01:00
commit 39bc871fd9
23 changed files with 2743 additions and 1963 deletions

108
package-lock.json generated
View File

@ -45,6 +45,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.14",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"eslint-plugin-react-hooks": "^5.2.0",
@ -96,6 +97,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@ -506,6 +508,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -529,6 +532,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -2660,9 +2664,9 @@
}
},
"node_modules/@next/env": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
"integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@ -2676,9 +2680,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz",
"integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==",
"cpu": [
"arm64"
],
@ -2692,9 +2696,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz",
"integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==",
"cpu": [
"x64"
],
@ -2708,9 +2712,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz",
"integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==",
"cpu": [
"arm64"
],
@ -2724,9 +2728,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz",
"integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==",
"cpu": [
"arm64"
],
@ -2740,9 +2744,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz",
"integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==",
"cpu": [
"x64"
],
@ -2756,9 +2760,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz",
"integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==",
"cpu": [
"x64"
],
@ -2772,9 +2776,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz",
"integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==",
"cpu": [
"arm64"
],
@ -2788,9 +2792,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz",
"integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==",
"cpu": [
"x64"
],
@ -3567,6 +3571,7 @@
"integrity": "sha512-+kLxJpaJzXybyDyFXYADyP1cznTO8HSuBpenGlnKOAkH4hyNINiywvXS/tGJhsrGGP/gM185RA3xpjY0Yg4erA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -3633,6 +3638,7 @@
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.44.1",
"@typescript-eslint/types": "8.44.1",
@ -4156,6 +4162,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4525,9 +4532,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.9.tgz",
"integrity": "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA==",
"version": "2.9.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
"integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@ -4603,6 +4610,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@ -4996,7 +5004,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@ -5420,6 +5429,7 @@
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -5594,6 +5604,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -7691,13 +7702,14 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz",
"integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==",
"license": "MIT",
"dependencies": {
"@next/env": "16.0.7",
"@next/env": "16.1.1",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@ -7709,14 +7721,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.7",
"@next/swc-darwin-x64": "16.0.7",
"@next/swc-linux-arm64-gnu": "16.0.7",
"@next/swc-linux-arm64-musl": "16.0.7",
"@next/swc-linux-x64-gnu": "16.0.7",
"@next/swc-linux-x64-musl": "16.0.7",
"@next/swc-win32-arm64-msvc": "16.0.7",
"@next/swc-win32-x64-msvc": "16.0.7",
"@next/swc-darwin-arm64": "16.1.1",
"@next/swc-darwin-x64": "16.1.1",
"@next/swc-linux-arm64-gnu": "16.1.1",
"@next/swc-linux-arm64-musl": "16.1.1",
"@next/swc-linux-x64-gnu": "16.1.1",
"@next/swc-linux-x64-musl": "16.1.1",
"@next/swc-win32-arm64-msvc": "16.1.1",
"@next/swc-win32-x64-msvc": "16.1.1",
"sharp": "^0.34.4"
},
"peerDependencies": {
@ -8113,6 +8125,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -8831,6 +8844,7 @@
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -8923,6 +8937,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -8932,6 +8947,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -8958,6 +8974,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz",
"integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -9764,7 +9781,8 @@
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
"integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.2.3",
@ -9862,6 +9880,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -10038,6 +10057,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -46,6 +46,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.14",
"eslint": "^9",
"eslint-config-next": "15.5.4",
"eslint-plugin-react-hooks": "^5.2.0",

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

@ -3,23 +3,7 @@ import React, { useMemo, useState } from 'react'
import PageLayout from '../../components/PageLayout'
import { useRouter } from 'next/navigation'
import { useVatRates } from './hooks/getTaxes'
type VatRate = { country: string; code: string; rate: number }
type Bill = {
id: string
customer: string
amount: number
currency: string
date: string
status: 'paid' | 'open' | 'overdue'
}
const dummyBills: Bill[] = [
{ id: 'INV-1001', customer: 'Acme GmbH', amount: 1200, currency: 'EUR', date: '2025-12-01', status: 'paid' },
{ id: 'INV-1002', customer: 'Beta SARL', amount: 860, currency: 'EUR', date: '2025-11-20', status: 'open' },
{ id: 'INV-1003', customer: 'Charlie SpA', amount: 540, currency: 'EUR', date: '2025-11-15', status: 'overdue' },
{ id: 'INV-1004', customer: 'Delta BV', amount: 2300, currency: 'EUR', date: '2025-10-02', status: 'paid' },
]
import { useAdminInvoices } from './hooks/getInvoices'
export default function FinanceManagementPage() {
const router = useRouter()
@ -27,38 +11,60 @@ export default function FinanceManagementPage() {
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 filterDate = (d: string) => new Date(d)
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 // ytd or default
return true
}
const filtered = dummyBills.filter(b => inRange(filterDate(b.date)))
const total = dummyBills.reduce((s, b) => s + b.amount, 0)
const totalRange = filtered.reduce((s, b) => s + b.amount, 0)
return { totalAll: total, totalRange }
}, [timeframe])
const filteredBills = useMemo(() => {
return dummyBills.filter(b => {
const matchesQuery =
billFilter.query === '' ||
b.id.toLowerCase().includes(billFilter.query.toLowerCase()) ||
b.customer.toLowerCase().includes(billFilter.query.toLowerCase())
const matchesStatus = billFilter.status === 'all' || b.status === billFilter.status
const fromOk = billFilter.from ? new Date(b.date) >= new Date(billFilter.from) : true
const toOk = billFilter.to ? new Date(b.date) <= new Date(billFilter.to) : true
return matchesQuery && matchesStatus && fromOk && toOk
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)
})
}, [billFilter])
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, bills: filteredBills })
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} bills`)
console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
}
return (
@ -129,12 +135,13 @@ export default function FinanceManagementPage() {
<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 (ID, customer)"
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"
@ -145,9 +152,11 @@ export default function FinanceManagementPage() {
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="open">Open</option>
<option value="overdue">Overdue</option>
<option value="canceled">Canceled</option>
</select>
<input
type="date"
@ -164,35 +173,59 @@ export default function FinanceManagementPage() {
</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">Date</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">
{filteredBills.map(b => (
<tr key={b.id} className="border-b last:border-0">
<td className="px-3 py-2">{b.id}</td>
<td className="px-3 py-2">{b.customer}</td>
<td className="px-3 py-2">{new Date(b.date).toLocaleDateString()}</td>
<td className="px-3 py-2">{b.amount.toFixed(2)}</td>
{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 ${
b.status === 'paid'
inv.status === 'paid'
? 'bg-green-100 text-green-700'
: b.status === 'open'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-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'
}`}
>
{b.status}
{inv.status}
</span>
</td>
<td className="px-3 py-2 space-x-2">
@ -200,13 +233,7 @@ export default function FinanceManagementPage() {
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
</td>
</tr>
))}
{filteredBills.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-gray-500">
Keine Rechnungen gefunden.
</td>
</tr>
))
)}
</tbody>
</table>

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useMemo } from 'react'
import PageLayout from '../components/PageLayout'
type Affiliate = {
@ -20,6 +20,8 @@ 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() {
@ -63,130 +65,129 @@ export default function AffiliateLinksPage() {
category: { title: affiliate.category, href: '#' },
commissionRate: affiliate.commissionRate
}))
// NEW: fixed categories from the provided image, merged with backend ones
const categories = useMemo(() => {
const fromImage = [
'Technology',
'Energy',
'Finance',
'Healthcare',
'Education',
'Travel',
'Retail',
'Construction',
'Food',
'Automotive',
'Fashion',
'Pets',
]
const set = new Set<string>(fromImage)
affiliates.forEach(a => { if (a.category) set.add(a.category) })
return ['all', ...Array.from(set)]
}, [affiliates])
return (
<PageLayout>
<div className="relative py-24 sm:py-32">
{/* Background Pattern */}
<svg
aria-hidden="true"
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
>
<defs>
<pattern
x="50%"
y={-1}
id="affiliate-pattern"
width={200}
height={200}
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect fill="url(#affiliate-pattern)" width="100%" height="100%" strokeWidth={0} />
</svg>
{/* Colored Blur Effect */}
<div
aria-hidden="true"
className="absolute top-0 right-0 left-1/2 -z-10 -ml-24 transform-gpu overflow-hidden blur-3xl lg:ml-24 xl:ml-48"
>
<div
style={{
clipPath:
'polygon(63.1% 29.5%, 100% 17.1%, 76.6% 3%, 48.4% 0%, 44.6% 4.7%, 54.5% 25.3%, 59.8% 49%, 55.2% 57.8%, 44.4% 57.2%, 27.8% 47.9%, 35.1% 81.5%, 0% 97.7%, 39.2% 100%, 35.2% 81.4%, 97.2% 52.8%, 63.1% 29.5%)',
}}
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
/>
</div>
{/* Additional background layers for better visibility */}
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900"></div>
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.1),transparent_65%)]"></div>
<div className="mx-auto max-w-7xl px-6 lg:px-8 relative z-10">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-4xl font-semibold tracking-tight text-balance text-white sm:text-5xl">
Affiliate Partners
</h2>
<p className="mt-2 text-lg/8 text-gray-300">
Discover our trusted partners and earn commissions through affiliate
links.
<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 mt-16 text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-r-transparent"></div>
<p className="mt-4 text-sm text-gray-400">Loading affiliate partners...</p>
<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 && (
<div className="mx-auto mt-16 max-w-2xl text-center">
<p className="text-red-400">{error}</p>
{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 mt-16 max-w-2xl text-center">
<p className="text-gray-400">No affiliate partners available at the moment.</p>
<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="mx-auto mt-16 grid max-w-2xl grid-cols-1 gap-x-8 gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-3">
{posts.map((post) => (
<article key={post.id} className="flex flex-col items-start justify-between">
<div className="relative w-full">
<img
alt=""
src={post.imageUrl}
className="aspect-video w-full rounded-2xl bg-gray-800 object-cover sm:aspect-2/1 lg:aspect-3/2"
/>
<div className="absolute inset-0 rounded-2xl inset-ring inset-ring-white/10" />
<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="flex max-w-xl grow flex-col justify-between">
<div className="mt-8 flex items-center gap-x-4 text-xs">
<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="relative z-10 rounded-full bg-gray-800/60 px-3 py-1.5 font-medium text-gray-300 hover:bg-gray-800"
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>
<div className="group relative grow">
<h3 className="mt-3 text-lg/6 font-semibold text-white group-hover:text-gray-300">
<a href={post.href} target="_blank" rel="noopener noreferrer">
<span className="absolute inset-0" />
{post.title}
</a>
</h3>
<p className="mt-5 line-clamp-3 text-sm/6 text-gray-400">
{post.description}
</p>
</div>
<div className="relative mt-8 flex items-center gap-x-4 justify-self-end">
{post.commissionRate && (
<span className="text-xs text-gray-500 border border-gray-700 rounded-full px-2 py-1">
{post.commissionRate}
</span>
)}
<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="text-sm font-semibold text-indigo-400 hover:text-indigo-300"
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
Visit Affiliate Link
</a>
<span className="text-[11px] text-gray-500">
External partner website.
</span>
</div>
</div>
</article>
))}
)
})}
</div>
)}
</div>
</main>
</div>
</PageLayout>
)

View File

@ -25,18 +25,42 @@ import {
} from '@heroicons/react/24/outline'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import useAuthStore from '../../store/authStore';
import { Avatar } from '../avatar';
import { Avatar } from '../avatar'
// ENV-BASED FEATURE FLAGS (string envs: treat "false" as off, everything else as on)
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
const DISPLAY_MEMBERSHIP = process.env.NEXT_PUBLIC_DISPLAY_MEMBERSHIP !== 'false'
const DISPLAY_ABOUT_US = process.env.NEXT_PUBLIC_DISPLAY_ABOUT_US !== 'false'
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
const DISPLAY_ABONEMMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMMENTS !== 'false'
const DISPLAY_POOLS = process.env.NEXT_PUBLIC_DISPLAY_POOLS !== 'false'
// Replace current shopItems / informationItems / navLinks block
// ...existing code...
// Replace current shopItems definition with detailed version (adds icon & description)
const shopItems = [
{ name: 'VIP', href: '/shop/vip', description: 'Exclusive VIP shop', icon: ShoppingBagIcon },
{ name: 'Public', href: '/shop/public', description: 'Open catalog for everyone', icon: UsersIcon },
]
// Information dropdown, controlled by env flags
const informationItems = [
{ name: 'Affiliate-Links', href: '/affiliate-links', description: 'Browse our partner links' },
{ name: 'Memberships', href: '/memberships', description: 'Explore membership options' },
{ name: 'About us', href: '/about-us', description: 'Learn more about us' },
];
...(DISPLAY_MEMBERSHIP
? [{ name: 'Memberships', href: '/memberships', description: 'Explore membership options' }]
: []),
...(DISPLAY_ABOUT_US
? [{ name: 'About us', href: '/about-us', description: 'Learn more about us' }]
: []),
]
// Top-level navigation links, controlled by env flags
const navLinks = [
{ name: 'Shop', href: '/shop' },
{ name: 'News', href: '/news' },
];
...(DISPLAY_NEWS ? [{ name: 'News', href: '/news' }] : []),
]
// Toggle visibility of Shop navigation across header (desktop + mobile)
const showShop = false
export default function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@ -261,18 +285,24 @@ export default function Header() {
>
Referral Management
</button>
{DISPLAY_MATRIX && (
<button
onClick={() => router.push('/personal-matrix')}
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Personal Matrix
</button>
)}
{DISPLAY_ABONEMMENTS && (
<button
onClick={() => router.push('/coffee-abonnements')}
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Coffee Abonnements
</button>
)}
</>
)}
@ -428,10 +458,7 @@ export default function Header() {
User Verify
</button>
{/* Updated Management dropdown */}
<div
ref={managementRef}
className="relative"
>
<div ref={managementRef} className="relative">
<button
onClick={() => setAdminMgmtOpen(o => !o)}
aria-haspopup="true"
@ -456,6 +483,8 @@ export default function Header() {
>
User Management
</button>
{DISPLAY_MATRIX && (
<button
onClick={() => { router.push('/admin/matrix-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
@ -463,6 +492,8 @@ export default function Header() {
>
Matrix Management
</button>
)}
<button
onClick={() => { router.push('/admin/contract-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
@ -470,6 +501,9 @@ export default function Header() {
>
Contract Management
</button>
{DISPLAY_ABONEMMENTS && (
<>
<button
onClick={() => { router.push('/admin/subscriptions'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
@ -484,6 +518,10 @@ export default function Header() {
>
Finance Management
</button>
</>
)}
{DISPLAY_POOLS && (
<button
onClick={() => { router.push('/admin/pool-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
@ -491,6 +529,8 @@ export default function Header() {
>
Pool Management
</button>
)}
<button
onClick={() => { router.push('/admin/affiliate-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
@ -498,6 +538,8 @@ export default function Header() {
>
Affiliate Management
</button>
{DISPLAY_NEWS && (
<button
onClick={() => { router.push('/admin/news-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
@ -505,6 +547,7 @@ export default function Header() {
>
News Management
</button>
)}
</div>
</div>
)}
@ -664,18 +707,22 @@ export default function Header() {
>
Referral Management
</button>
{DISPLAY_MATRIX && (
<button
onClick={() => { router.push('/personal-matrix'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Personal Matrix
</button>
)}
{DISPLAY_ABONEMMENTS && (
<button
onClick={() => { router.push('/coffee-abonnements'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Coffee Abonnements
</button>
)}
</>
)}
</div>

View File

@ -0,0 +1,264 @@
'use client'
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode
} from 'react'
import { createRoot } from 'react-dom/client'
type ToastVariant = 'success' | 'error' | 'info' | 'warning'
export interface ToastOptions {
id?: string
title?: string
message: string
variant?: ToastVariant
duration?: number // ms, default 4000
}
// add optional closing flag for exit animation
interface ToastInternal extends ToastOptions {
id: string
closing?: boolean
}
interface ToastContextValue {
showToast: (options: ToastOptions) => void
}
// increase fade duration
const TOAST_ANIMATION_MS = 400 // fade-out duration in ms
// --- global toast store so toasts survive route changes ---
let globalToasts: ToastInternal[] = []
type ToastListener = (toasts: ToastInternal[]) => void
const toastListeners = new Set<ToastListener>()
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
// NEW: portal state (single global React root)
let toastPortalContainer: HTMLDivElement | null = null
let toastPortalRoot: ReturnType<typeof createRoot> | null = null
let toastPortalMounted = false
function notifyToastListeners() {
for (const listener of toastListeners) {
listener(globalToasts)
}
}
function removeToastInternal(id: string) {
// clear auto-dismiss timer
const timeout = toastTimeouts.get(id)
if (timeout) {
clearTimeout(timeout)
toastTimeouts.delete(id)
}
const existing = globalToasts.find(t => t.id === id)
if (!existing) return
if (existing.closing) {
// already closing: hard-remove immediately
globalToasts = globalToasts.filter(t => t.id !== id)
notifyToastListeners()
return
}
// mark as closing to trigger fade-out
globalToasts = globalToasts.map(t =>
t.id === id ? { ...t, closing: true } : t
)
notifyToastListeners()
// remove after animation finishes
setTimeout(() => {
globalToasts = globalToasts.filter(t => t.id !== id)
notifyToastListeners()
}, TOAST_ANIMATION_MS)
}
function addToast(options: ToastOptions) {
const id = options.id ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
const toast: ToastInternal = {
id,
variant: options.variant ?? 'info',
duration: options.duration ?? 4000,
...options
}
globalToasts = [...globalToasts, toast]
notifyToastListeners()
if (toast.duration && toast.duration > 0) {
const timeout = setTimeout(() => {
removeToastInternal(id)
}, toast.duration)
toastTimeouts.set(id, timeout)
}
}
// NEW: mount a global portal once per browser session
function ensureToastPortalMounted() {
if (toastPortalMounted) return
if (typeof document === 'undefined') return
toastPortalMounted = true
toastPortalContainer = document.createElement('div')
document.body.appendChild(toastPortalContainer)
toastPortalRoot = createRoot(toastPortalContainer)
// Defer actual render to avoid triggering nested updates during React render
setTimeout(() => {
if (!toastPortalRoot) return
toastPortalRoot.render(<ToastViewport />)
}, 0)
}
// --- context & provider ---
const ToastContext = createContext<ToastContextValue | undefined>(undefined)
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext)
if (ctx) {
// Normal path: inside <ToastProvider>
return ctx
}
// Fallback path: no provider mounted, still use global store/portal
if (typeof window !== 'undefined') {
ensureToastPortalMounted()
}
return {
showToast: (options: ToastOptions) => {
addToast(options)
}
}
}
interface ToastProviderProps {
children: ReactNode
}
export function ToastProvider({ children }: ToastProviderProps) {
// ensure the global portal is mounted when any provider appears
useEffect(() => {
ensureToastPortalMounted()
}, [])
const showToast = useCallback((options: ToastOptions) => {
addToast(options)
}, [])
return (
<ToastContext.Provider value={{ showToast }}>
{children}
</ToastContext.Provider>
)
}
// NEW: global viewport rendered via portal, independent of pages/providers
function ToastViewport() {
const [toasts, setToasts] = useState<ToastInternal[]>(globalToasts)
useEffect(() => {
const listener: ToastListener = (next) => setToasts(next)
toastListeners.add(listener)
setToasts(globalToasts)
return () => {
toastListeners.delete(listener)
}
}, [])
const handleClose = useCallback((id: string) => {
removeToastInternal(id)
}, [])
if (!toasts.length) return null
return (
<div className="pointer-events-none fixed inset-x-4 bottom-4 z-50 flex justify-end sm:inset-x-auto sm:right-4 sm:w-auto">
<div className="flex max-h-[80vh] w-full flex-col gap-3 overflow-hidden sm:w-80">
{toasts.map(t => (
<ToastItem key={t.id} toast={t} onClose={() => handleClose(t.id)} />
))}
</div>
</div>
)
}
interface ToastItemProps {
toast: ToastInternal
onClose: () => void
}
function ToastItem({ toast, onClose }: ToastItemProps) {
const { title, message, variant } = toast
// local visible state for entry animation
const [visible, setVisible] = useState(false)
useEffect(() => {
const frame = requestAnimationFrame(() => setVisible(true))
return () => cancelAnimationFrame(frame)
}, [])
const variantClasses: Record<ToastVariant, string> = {
success: 'border-emerald-400/80 bg-emerald-900/90',
error: 'border-red-400/80 bg-red-900/90',
info: 'border-sky-400/80 bg-slate-900/90',
warning: 'border-amber-400/80 bg-amber-900/90'
}
const iconBg: Record<ToastVariant, string> = {
success: 'bg-emerald-500/10 text-emerald-300',
error: 'bg-red-500/10 text-red-300',
info: 'bg-sky-500/10 text-sky-300',
warning: 'bg-amber-500/10 text-amber-300'
}
const isClosing = !!toast.closing
const motionClasses = isClosing
? 'opacity-0 translate-y-2'
: visible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-2'
return (
<div
className={`
pointer-events-auto flex w-full items-start gap-3 rounded-xl border-l-4
px-4 py-3 text-sm text-slate-50 shadow-xl shadow-black/40
backdrop-blur-md transform transition-all duration-400
${motionClasses}
${variantClasses[variant ?? 'info']}
`}
>
<div className={`mt-0.5 flex h-8 w-8 items-center justify-center rounded-full ${iconBg[variant ?? 'info']}`}>
{/* Simple dot indicator */}
<span className="h-2 w-2 rounded-full bg-current" />
</div>
<div className="flex-1">
{title && (
<div className="mb-0.5 text-xs font-semibold uppercase tracking-wide text-slate-200">
{title}
</div>
)}
<div className="text-[13px] leading-snug text-slate-50">{message}</div>
</div>
<button
type="button"
onClick={onClose}
className="ml-1 mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-xs text-slate-300 hover:bg-slate-800/70 hover:text-white focus:outline-none focus:ring-2 focus:ring-slate-500"
aria-label="Close notification"
>
×
</button>
</div>
)
}

View File

@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
import { useLogin } from '../hooks/useLogin'
import { useToast } from '../../components/toast/toastComponent'
export default function LoginForm() {
const [showPassword, setShowPassword] = useState(false)
@ -13,11 +14,11 @@ export default function LoginForm() {
password: '',
rememberMe: false
})
const [viewportWidth, setViewportWidth] = useState<number>(
typeof window !== 'undefined' ? window.innerWidth : 1200
)
// FIX: use a static initial width so SSR and first client render match
const [viewportWidth, setViewportWidth] = useState<number>(1200)
const router = useRouter()
const { login, error, setError, loading } = useLogin()
const { showToast } = useToast()
// Responsive ball visibility
useEffect(() => {
@ -30,6 +31,7 @@ export default function LoginForm() {
// Track viewport width for dynamic scaling
useEffect(() => {
const handleResize = () => setViewportWidth(window.innerWidth)
handleResize() // initialize on mount (runs only on client)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
@ -72,11 +74,29 @@ export default function LoginForm() {
if (!validateForm()) return
await login({
const result = await login({
email: formData.email,
password: formData.password,
rememberMe: formData.rememberMe
})
if (result?.success) {
showToast({
variant: 'success',
title: 'Login successful',
message: 'You are now logged in.'
})
const redirectPath = (result as any).redirectPath || '/dashboard'
// instant redirect; toast persists via global store
router.push(redirectPath)
} else {
showToast({
variant: 'error',
title: 'Login failed',
message: result?.error || 'Login failed. Please check your credentials and try again.'
})
}
}
// Dynamic breakpoints

View File

@ -114,7 +114,7 @@ export function useLogin() {
status: progressData.status
})
// Redirect to dashboard only if all steps completed AND status is active
// Redirect decision logic (keep as-is, but do not push here)
if (allStepsCompleted && isActive) {
redirectPath = '/dashboard'
console.log('✅ User fully onboarded, redirecting to dashboard')
@ -128,10 +128,8 @@ export function useLogin() {
console.error('❌ Error fetching user status-progress:', statusError)
}
// Redirect based on status check
router.push(redirectPath)
return { success: true, user: data.user }
// NOTE: no router.push here; caller will handle redirect after showing toast
return { success: true, user: data.user, redirectPath }
} else {
throw new Error(data.message || 'Login failed')
}

View File

@ -6,13 +6,20 @@ import LoginForm from './components/LoginForm'
import PageLayout from '../components/PageLayout'
import useAuthStore from '../store/authStore'
import GlobalAnimatedBackground from '../background/GlobalAnimatedBackground'
import { ToastProvider } from '../components/toast/toastComponent'
export default function LoginPage() {
const [showBackground, setShowBackground] = useState(false)
const [hasHydrated, setHasHydrated] = useState(false)
const router = useRouter()
const user = useAuthStore(state => state.user)
// Check if user is already logged in
// Mark when the component has hydrated on the client
useEffect(() => {
setHasHydrated(true)
}, [])
// Redirect if user is already logged in
useEffect(() => {
if (user) {
router.push('/dashboard')
@ -27,9 +34,10 @@ export default function LoginPage() {
return () => window.removeEventListener('resize', handleResize)
}, [])
// Don't render if user is already logged in
if (user) {
// Don't render if user is already logged in (only after hydration to avoid SSR mismatch)
if (hasHydrated && user) {
return (
<ToastProvider>
<PageLayout>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
@ -38,10 +46,12 @@ export default function LoginPage() {
</div>
</div>
</PageLayout>
</ToastProvider>
)
}
return (
<ToastProvider>
<PageLayout showFooter={true}>
<div
className="relative w-full flex flex-col min-h-screen"
@ -58,5 +68,6 @@ export default function LoginPage() {
</div>
</div>
</PageLayout>
</ToastProvider>
)
}

View File

@ -204,8 +204,18 @@ export default function QuickActionDashboardPage() {
return (
<PageLayout>
<div className="min-h-screen bg-gray-50">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="relative min-h-screen overflow-hidden bg-slate-50">
{/* Animated background */}
<div className="pointer-events-none absolute inset-0 z-0">
{/* Soft gradient blobs */}
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
{/* Subtle radial highlight */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
</div>
<main className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Title */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">

View File

@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { useToast } from '../../../components/toast/toastComponent'
interface CompanyProfileData {
companyName: string
@ -50,6 +51,7 @@ export default function CompanyAdditionalInformationPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const { showToast } = useToast()
const [form, setForm] = useState(init)
const [loading, setLoading] = useState(false)
@ -68,12 +70,24 @@ export default function CompanyAdditionalInformationPage() {
]
for (const k of required) {
if (!form[k].trim()) {
setError('Bitte alle Pflichtfelder ausfüllen.')
const msg = 'Bitte alle Pflichtfelder ausfüllen.'
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
message: msg,
})
return false
}
}
if (!/^([A-Z]{2}\d{2}[A-Z0-9]{10,30})$/i.test(form.iban.replace(/\s+/g,''))) {
setError('Ungültige IBAN.')
const msg = 'Ungültige IBAN.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid IBAN',
message: msg,
})
return false
}
setError('')
@ -86,7 +100,13 @@ export default function CompanyAdditionalInformationPage() {
if (!validate()) return
if (!accessToken) {
setError('Not authenticated. Please log in again.')
const msg = 'Not authenticated. Please log in again.'
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
message: msg,
})
return
}
@ -122,6 +142,11 @@ export default function CompanyAdditionalInformationPage() {
}
setSuccess(true)
showToast({
variant: 'success',
title: 'Profile saved',
message: 'Your company profile has been saved successfully.',
})
// Refresh user status to update profile completion state
await refreshStatus()
@ -141,7 +166,13 @@ export default function CompanyAdditionalInformationPage() {
} catch (error: any) {
console.error('Company profile save error:', error)
setError(error.message || 'Speichern fehlgeschlagen.')
const msg = error.message || 'Speichern fehlgeschlagen.'
setError(msg)
showToast({
variant: 'error',
title: 'Save failed',
message: msg,
})
} finally {
setLoading(false)
}
@ -149,26 +180,18 @@ export default function CompanyAdditionalInformationPage() {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 w-full px-5 sm:px-8 lg:px-12 py-12">
{/* Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
<svg aria-hidden="true" className="absolute inset-0 -z-10 h-full w-full stroke-white/10">
<defs>
<pattern id="company-additional-pattern" x="50%" y={-1} width={200} height={200} patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect fill="url(#company-additional-pattern)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div aria-hidden="true" className="absolute top-0 right-0 left-1/2 -ml-24 blur-3xl transform-gpu overflow-hidden lg:ml-24 xl:ml-48">
<div
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
style={{ clipPath: 'polygon(63.1% 29.5%,100% 17.1%,76.6% 3%,48.4% 0%,44.6% 4.7%,54.5% 25.3%,59.8% 49%,55.2% 57.8%,44.4% 57.2%,27.8% 47.9%,35.1% 81.5%,0% 97.7%,39.2% 100%,35.2% 81.4%,97.2% 52.8%,63.1% 29.5%)' }}
/>
</div>
<div className="relative min-h-screen overflow-hidden bg-slate-50">
{/* Animated background (same as dashboard) */}
<div className="pointer-events-none absolute inset-0 z-0">
{/* Soft gradient blobs */}
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
{/* Subtle radial highlight */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
</div>
<main className="relative z-10 flex flex-col flex-1 w-full px-5 sm:px-8 lg:px-12 py-12">
<form
onSubmit={submit}
className="relative max-w-6xl w-full mx-auto bg-white rounded-2xl shadow-xl ring-1 ring-black/10"
@ -375,7 +398,14 @@ export default function CompanyAdditionalInformationPage() {
</div>
)}
<div className="mt-10 flex justify-end">
<div className="mt-10 flex items-center justify-between">
<button
type="button"
onClick={() => router.push('/quickaction-dashboard')}
className="inline-flex items-center rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50"
>
Back to Dashboard
</button>
<button
type="submit"
disabled={loading || success}
@ -386,6 +416,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
</div>
</form>
</main>
</div>
</PageLayout>
)

View File

@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { useToast } from '../../../components/toast/toastComponent'
interface PersonalProfileData {
dob: string
@ -58,6 +59,7 @@ export default function PersonalAdditionalInformationPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const { showToast } = useToast()
const [form, setForm] = useState(initialData)
const [loading, setLoading] = useState(false)
@ -145,20 +147,38 @@ export default function PersonalAdditionalInformationPage() {
]
for (const k of requiredKeys) {
if (!form[k].trim()) {
setError('Please fill in all required fields.')
const msg = 'Please fill in all required fields.'
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
message: msg,
})
return false
}
}
// Date of birth validation
if (!validateDateOfBirth(form.dob)) {
setError('Invalid date of birth. You must be at least 18 years old.')
const msg = 'Invalid date of birth. You must be at least 18 years old.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid date of birth',
message: msg,
})
return false
}
// very loose IBAN check
if (!/^([A-Z]{2}\d{2}[A-Z0-9]{10,30})$/i.test(form.iban.replace(/\s+/g,''))) {
setError('Invalid IBAN.')
const msg = 'Invalid IBAN.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid IBAN',
message: msg,
})
return false
}
setError('')
@ -171,7 +191,13 @@ export default function PersonalAdditionalInformationPage() {
if (!validate()) return
if (!accessToken) {
setError('Not authenticated. Please log in again.')
const msg = 'Not authenticated. Please log in again.'
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
message: msg,
})
return
}
@ -208,6 +234,11 @@ export default function PersonalAdditionalInformationPage() {
}
setSuccess(true)
showToast({
variant: 'success',
title: 'Profile saved',
message: 'Your personal profile has been saved successfully.',
})
// Refresh user status to update profile completion state
await refreshStatus()
@ -227,7 +258,13 @@ export default function PersonalAdditionalInformationPage() {
} catch (error: any) {
console.error('Personal profile save error:', error)
setError(error.message || 'Save failed. Please try again.')
const msg = error.message || 'Save failed. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Save failed',
message: msg,
})
} finally {
setLoading(false)
}
@ -235,26 +272,18 @@ export default function PersonalAdditionalInformationPage() {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 w-full px-5 sm:px-8 lg:px-12 py-12">
{/* Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
<svg aria-hidden="true" className="absolute inset-0 -z-10 h-full w-full stroke-white/10">
<defs>
<pattern id="personal-additional-pattern" x="50%" y={-1} width={200} height={200} patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect fill="url(#personal-additional-pattern)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div aria-hidden="true" className="absolute top-0 right-0 left-1/2 -ml-24 blur-3xl transform-gpu overflow-hidden lg:ml-24 xl:ml-48">
<div
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
style={{ clipPath: 'polygon(63.1% 29.5%,100% 17.1%,76.6% 3%,48.4% 0%,44.6% 4.7%,54.5% 25.3%,59.8% 49%,55.2% 57.8%,44.4% 57.2%,27.8% 47.9%,35.1% 81.5%,0% 97.7%,39.2% 100%,35.2% 81.4%,97.2% 52.8%,63.1% 29.5%)' }}
/>
</div>
<div className="relative min-h-screen overflow-hidden bg-slate-50">
{/* Animated background (same as dashboard) */}
<div className="pointer-events-none absolute inset-0 z-0">
{/* Soft gradient blobs */}
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
{/* Subtle radial highlight */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
</div>
<main className="relative z-10 flex flex-col flex-1 w-full px-5 sm:px-8 lg:px-12 py-12">
<form
onSubmit={handleSubmit}
className="relative max-w-6xl w-full mx-auto bg-white rounded-2xl shadow-xl ring-1 ring-black/10"
@ -461,7 +490,14 @@ export default function PersonalAdditionalInformationPage() {
</div>
)}
<div className="mt-10 flex justify-end">
<div className="mt-10 flex items-center justify-between">
<button
type="button"
onClick={() => router.push('/quickaction-dashboard')}
className="inline-flex items-center rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50"
>
Back to Dashboard
</button>
<button
type="submit"
disabled={loading || success}
@ -472,6 +508,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
</div>
</form>
</main>
</div>
</PageLayout>
)

View File

@ -4,7 +4,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import PageLayout from '../../components/PageLayout'
import useAuthStore from '../../store/authStore'
import { useUserStatus } from '../../hooks/useUserStatus'
import { useRouter } from 'next/navigation' // NEW
import { useRouter } from 'next/navigation'
import { useToast } from '../../components/toast/toastComponent'
export default function EmailVerifyPage() {
const user = useAuthStore(s => s.user)
@ -18,7 +19,8 @@ export default function EmailVerifyPage() {
const [initialEmailSent, setInitialEmailSent] = useState(false)
const inputsRef = useRef<Array<HTMLInputElement | null>>([])
const emailSentRef = useRef(false)
const router = useRouter() // NEW
const router = useRouter()
const { showToast } = useToast()
// NEW: resend and validity windows
const RESEND_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
@ -63,18 +65,36 @@ export default function EmailVerifyPage() {
setInitialEmailSent(true)
setLastSentAt(Date.now(), user?.email)
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
showToast({
variant: 'success',
title: 'Verification email sent',
message: `We sent a verification email to ${user?.email || 'your email'}.`
})
} else {
console.error('Failed to send initial verification email:', data?.message)
const msg = data?.message || 'Error sending the verification email.'
setError(msg)
emailSentRef.current = false
showToast({
variant: 'error',
title: 'Email not sent',
message: msg
})
}
} catch (error) {
console.error('Error sending initial verification email:', error)
} catch (err) {
console.error('Error sending initial verification email:', err)
const msg = 'Network error while sending the verification email.'
setError(msg)
emailSentRef.current = false
showToast({
variant: 'error',
title: 'Network error',
message: msg
})
}
}
sendInitialEmail()
}, [token, user])
}, [token, user, showToast])
// Cooldown timer
useEffect(() => {
@ -172,11 +192,23 @@ export default function EmailVerifyPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (fullCode.length !== 6) {
setError('Please enter the 6-digit code.')
const msg = 'Please enter the 6-digit code.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid code',
message: msg
})
return
}
if (!token) {
setError('Not authenticated. Please log in again.')
const msg = 'Not authenticated. Please log in again.'
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
message: msg
})
return
}
@ -196,13 +228,15 @@ export default function EmailVerifyPage() {
if (response.ok && data.success) {
setSuccess(true)
await refreshStatus() // Refresh user status
// Redirect after 2 seconds
showToast({
variant: 'success',
title: 'Email verified',
message: 'Your email has been verified successfully.'
})
await refreshStatus()
setTimeout(() => {
// Check if we came from tutorial
const urlParams = new URLSearchParams(window.location.search)
const fromTutorial = urlParams.get('tutorial') === 'true'
if (fromTutorial) {
window.location.href = '/quickaction-dashboard?tutorial=true'
} else {
@ -210,11 +244,23 @@ export default function EmailVerifyPage() {
}
}, 2000)
} else {
setError(data.error || 'Verification failed. Please try again.')
const msg = data.error || 'Verification failed. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Verification failed',
message: msg
})
}
} catch (error) {
console.error('Email verification error:', error)
setError('Network error. Please try again.')
} catch (err) {
console.error('Email verification error:', err)
const msg = 'Network error. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Network error',
message: msg
})
} finally {
setSubmitting(false)
}
@ -232,7 +278,17 @@ export default function EmailVerifyPage() {
setResendCooldown(Math.ceil(remaining / 1000))
return
}
if (!token) return
if (!token) {
const msg = 'Not authenticated. Please log in again.'
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
message: msg
})
return
}
setError('')
try {
@ -250,14 +306,31 @@ export default function EmailVerifyPage() {
setLastSentAt(Date.now(), user?.email)
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
if (!initialEmailSent) setInitialEmailSent(true)
showToast({
variant: 'success',
title: 'Verification email sent',
message: `We sent a new verification email to ${user?.email || 'your email'}.`
})
} else {
setError(data?.message || 'Error sending the email.')
const msg = data?.message || 'Error sending the email.'
setError(msg)
showToast({
variant: 'error',
title: 'Email not sent',
message: msg
})
}
} catch (error) {
console.error('Resend email error:', error)
setError('Network error while sending the email.')
} catch (err) {
console.error('Resend email error:', err)
const msg = 'Network error while sending the email.'
setError(msg)
showToast({
variant: 'error',
title: 'Network error',
message: msg
})
}
}, [token, submitting, success, user, initialEmailSent])
}, [token, submitting, success, user, initialEmailSent, showToast])
// NEW: format seconds to m:ss
const formatMmSs = (total: number) => {
@ -268,54 +341,28 @@ export default function EmailVerifyPage() {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 w-full px-4 sm:px-6 py-16 sm:py-24">
{/* Global full-viewport background (no inner scroll) */}
<div className="fixed inset-0 -z-10">
{/* Gradient base */}
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
{/* Pattern */}
<svg
aria-hidden="true"
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
>
<defs>
<pattern
id="email-verify-pattern"
x="50%"
y={-1}
width={200}
height={200}
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect fill="url(#email-verify-pattern)" width="100%" height="100%" strokeWidth={0} />
</svg>
{/* Colored blur shape */}
<div
aria-hidden="true"
className="absolute top-0 right-0 left-1/2 -ml-24 transform-gpu overflow-hidden blur-3xl lg:ml-24 xl:ml-48"
>
<div
style={{
clipPath:
'polygon(63.1% 29.5%, 100% 17.1%, 76.6% 3%, 48.4% 0%, 44.6% 4.7%, 54.5% 25.3%, 59.8% 49%, 55.2% 57.8%, 44.4% 57.2%, 27.8% 47.9%, 35.1% 81.5%, 0% 97.7%, 39.2% 100%, 35.2% 81.4%, 97.2% 52.8%, 63.1% 29.5%)'
}}
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
/>
</div>
<div className="relative min-h-screen overflow-hidden bg-slate-50">
{/* Animated background (same as dashboard) */}
<div className="pointer-events-none absolute inset-0 z-0">
{/* Soft gradient blobs */}
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
{/* Subtle radial highlight */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
</div>
<main className="relative z-10 flex flex-col flex-1 w-full px-4 sm:px-6 py-16 sm:py-24">
<div className="max-w-xl mx-auto">
<div className="text-center mb-10">
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-white">
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-gray-900">
Verify your email
</h1>
<p className="mt-3 text-gray-300 text-sm sm:text-base">
<p className="mt-3 text-gray-700 text-sm sm:text-base">
{initialEmailSent ? (
<>
We sent a 6-digit code to{' '}
<span className="text-indigo-300 font-medium">
<span className="text-blue-700 font-medium">
{user?.email || 'your email'}
</span>
. Enter it below.
@ -323,7 +370,7 @@ export default function EmailVerifyPage() {
) : (
<>
Sending verification email to{' '}
<span className="text-indigo-300 font-medium">
<span className="text-blue-700 font-medium">
{user?.email || 'your email'}
</span>
...
@ -335,9 +382,10 @@ export default function EmailVerifyPage() {
{/* Card */}
<form
onSubmit={handleSubmit}
className="bg-white/95 dark:bg-gray-900/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-black/10 dark:ring-white/10 px-6 py-8 sm:px-10 sm:py-10"
className="bg-white/95 backdrop-blur rounded-2xl shadow-xl ring-1 ring-black/5 px-6 py-8 sm:px-10 sm:py-10"
>
<fieldset disabled={submitting || success} className="space-y-8">
{/* Inputs */}
<div className="flex justify-center gap-2 sm:gap-3">
{code.map((v, i) => (
<input
@ -353,8 +401,8 @@ export default function EmailVerifyPage() {
onPaste={e => handlePaste(i, e)}
className={`w-12 h-14 sm:w-14 sm:h-16 text-center text-2xl font-semibold rounded-lg border transition-colors outline-none
${v
? 'border-indigo-500 ring-2 ring-indigo-400/40 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
: 'border-gray-300 dark:border-gray-600 bg-white/80 dark:bg-gray-800/70 text-gray-700 dark:text-gray-200'}
? 'border-indigo-500 ring-2 ring-indigo-400/40 bg-white text-gray-900'
: 'border-gray-300 bg-white/80 text-gray-700'}
focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500`}
/>
))}
@ -389,7 +437,7 @@ export default function EmailVerifyPage() {
type="button"
onClick={handleResend}
disabled={!!resendCooldown || submitting || success}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:underline disabled:text-gray-400 disabled:cursor-not-allowed"
className="text-sm font-medium text-indigo-700 hover:underline disabled:text-gray-400 disabled:cursor-not-allowed"
>
{resendCooldown
? `Resend in ${formatMmSs(resendCooldown)}`
@ -397,28 +445,27 @@ export default function EmailVerifyPage() {
</button>
</div>
{/* NEW: Go to Dashboard button */}
<div className="mt-1 text-center">
<button
type="button"
onClick={() => router.push('/quickaction-dashboard')}
className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:underline"
className="text-sm font-medium text-gray-700 hover:underline"
>
Go to Dashboard
</button>
</div>
</fieldset>
{/* Helper text with validity + spam/junk reminder + support */}
<div className="mt-8 text-center text-xs text-gray-500 dark:text-gray-400">
<div className="mt-8 text-center text-xs text-gray-500">
Didnt receive the email? Please check your junk/spam folder. Still having issues?{' '}
<a href="mailto:test@test.com" className="text-indigo-600 dark:text-indigo-400 hover:underline">
<a href="mailto:test@test.com" className="text-indigo-600 hover:underline">
Contact support
</a>
.
</div>
</form>
</div>
</main>
</div>
</PageLayout>
)

View File

@ -6,11 +6,13 @@ import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { API_BASE_URL } from '../../../utils/api'
import { useToast } from '../../../components/toast/toastComponent' // NEW
export default function CompanySignContractPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const { showToast } = useToast() // NEW
const [companyName, setCompanyName] = useState('')
const [repName, setRepName] = useState('')
@ -77,7 +79,7 @@ export default function CompanySignContractPage() {
e.preventDefault()
if (!valid()) {
// Detailed error message to help debug
const issues = []
const issues: string[] = []
if (companyName.trim().length < 3) issues.push('Company name (min 3 characters)')
if (repName.trim().length < 3) issues.push('Representative name (min 3 characters)')
if (repTitle.trim().length < 2) issues.push('Representative title (min 2 characters)')
@ -86,12 +88,24 @@ export default function CompanySignContractPage() {
if (!agreeData) issues.push('Privacy policy accepted')
if (!confirmSignature) issues.push('Electronic signature confirmed')
setError(`Please complete: ${issues.join(', ')}`)
const msg = `Please complete: ${issues.join(', ')}`
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
message: msg,
})
return
}
if (!accessToken) {
setError('Not authenticated. Please log in again.')
const msg = 'Not authenticated. Please log in again.'
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
message: msg,
})
return
}
@ -137,6 +151,11 @@ export default function CompanySignContractPage() {
}
setSuccess(true)
showToast({
variant: 'success',
title: 'Contract signed',
message: 'Your company contract has been signed successfully.',
})
// Refresh user status to update contract signed state
await refreshStatus()
@ -156,7 +175,13 @@ export default function CompanySignContractPage() {
} catch (error: any) {
console.error('Contract signing error:', error)
setError(error.message || 'Signature failed. Please try again.')
const msg = error.message || 'Signature failed. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Signature failed',
message: msg,
})
} finally {
setSubmitting(false)
}
@ -164,26 +189,18 @@ export default function CompanySignContractPage() {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 w-full px-5 sm:px-8 lg:px-12 py-12">
{/* Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
<svg aria-hidden="true" className="absolute inset-0 -z-10 h-full w-full stroke-white/10">
<defs>
<pattern id="company-contract-pattern" x="50%" y={-1} width={200} height={200} patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect fill="url(#company-contract-pattern)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div aria-hidden="true" className="absolute top-0 right-0 left-1/2 -ml-24 blur-3xl transform-gpu overflow-hidden lg:ml-24 xl:ml-48">
<div
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
style={{ clipPath:'polygon(63.1% 29.5%,100% 17.1%,76.6% 3%,48.4% 0%,44.6% 4.7%,54.5% 25.3%,59.8% 49%,55.2% 57.8%,44.4% 57.2%,27.8% 47.9%,35.1% 81.5%,0% 97.7%,39.2% 100%,35.2% 81.4%,97.2% 52.8%,63.1% 29.5%)'}}
/>
</div>
<div className="relative min-h-screen overflow-hidden bg-slate-50">
{/* Animated background (same as dashboard) */}
<div className="pointer-events-none absolute inset-0 z-0">
{/* Soft gradient blobs */}
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
{/* Subtle radial highlight */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
</div>
<main className="relative z-10 flex flex-col flex-1 w-full px-5 sm:px-8 lg:px-12 py-12">
<form
onSubmit={handleSubmit}
className="relative max-w-5xl w-full mx-auto bg-white rounded-2xl shadow-xl ring-1 ring-black/10"
@ -402,7 +419,14 @@ export default function CompanySignContractPage() {
</div>
)}
<div className="mt-10 flex justify-end">
<div className="mt-10 flex items-center justify-between">
<button
type="button"
onClick={() => router.push('/quickaction-dashboard')}
className="inline-flex items-center rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50"
>
Back to Dashboard
</button>
<button
type="submit"
disabled={submitting || success}
@ -413,6 +437,7 @@ export default function CompanySignContractPage() {
</div>
</div>
</form>
</main>
</div>
</PageLayout>
)

View File

@ -6,11 +6,13 @@ import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { API_BASE_URL } from '../../../utils/api'
import { useToast } from '../../../components/toast/toastComponent'
export default function PersonalSignContractPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const { showToast } = useToast()
const [fullName, setFullName] = useState('')
const [location, setLocation] = useState('')
@ -72,19 +74,31 @@ export default function PersonalSignContractPage() {
e.preventDefault()
if (!valid()) {
// Detailed error message to help debug
const issues = []
const issues: string[] = []
if (fullName.trim().length < 3) issues.push('Full name (min 3 characters)')
if (location.trim().length < 2) issues.push('Location (min 2 characters)')
if (!agreeContract) issues.push('Contract read and understood')
if (!agreeData) issues.push('Privacy policy accepted')
if (!confirmSignature) issues.push('Electronic signature confirmed')
setError(`Please complete: ${issues.join(', ')}`)
const msg = `Please complete: ${issues.join(', ')}`
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
message: msg,
})
return
}
if (!accessToken) {
setError('Not authenticated. Please log in again.')
const msg = 'Not authenticated. Please log in again.'
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
message: msg,
})
return
}
@ -128,6 +142,11 @@ export default function PersonalSignContractPage() {
}
setSuccess(true)
showToast({
variant: 'success',
title: 'Contract signed',
message: 'Your personal contract has been signed successfully.',
})
// Refresh user status to update contract signed state
await refreshStatus()
@ -147,7 +166,13 @@ export default function PersonalSignContractPage() {
} catch (error: any) {
console.error('Contract signing error:', error)
setError(error.message || 'Signature failed. Please try again.')
const msg = error.message || 'Signature failed. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Signature failed',
message: msg,
})
} finally {
setSubmitting(false)
}
@ -155,26 +180,18 @@ export default function PersonalSignContractPage() {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 w-full px-5 sm:px-8 lg:px-12 py-12">
{/* Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
<svg aria-hidden="true" className="absolute inset-0 -z-10 h-full w-full stroke-white/10">
<defs>
<pattern id="personal-contract-pattern" x="50%" y={-1} width={200} height={200} patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect fill="url(#personal-contract-pattern)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div aria-hidden="true" className="absolute top-0 right-0 left-1/2 -ml-24 blur-3xl transform-gpu overflow-hidden lg:ml-24 xl:ml-48">
<div
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
style={{ clipPath:'polygon(63.1% 29.5%,100% 17.1%,76.6% 3%,48.4% 0%,44.6% 4.7%,54.5% 25.3%,59.8% 49%,55.2% 57.8%,44.4% 57.2%,27.8% 47.9%,35.1% 81.5%,0% 97.7%,39.2% 100%,35.2% 81.4%,97.2% 52.8%,63.1% 29.5%)'}}
/>
</div>
<div className="relative min-h-screen overflow-hidden bg-slate-50">
{/* Animated background (same as dashboard) */}
<div className="pointer-events-none absolute inset-0 z-0">
{/* Soft gradient blobs */}
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
{/* Subtle radial highlight */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
</div>
<main className="relative z-10 flex flex-col flex-1 w-full px-5 sm:px-8 lg:px-12 py-12">
<form
onSubmit={handleSubmit}
className="relative max-w-5xl w-full mx-auto bg-white rounded-2xl shadow-xl ring-1 ring-black/10"
@ -343,7 +360,14 @@ export default function PersonalSignContractPage() {
</div>
)}
<div className="mt-10 flex justify-end">
<div className="mt-10 flex items-center justify-between">
<button
type="button"
onClick={() => router.push('/quickaction-dashboard')}
className="inline-flex items-center rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50"
>
Back to Dashboard
</button>
<button
type="submit"
disabled={submitting || success}
@ -354,6 +378,7 @@ export default function PersonalSignContractPage() {
</div>
</div>
</form>
</main>
</div>
</PageLayout>
)

View File

@ -3,11 +3,13 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import useAuthStore from '../../../../store/authStore'
import { useUserStatus } from '../../../../hooks/useUserStatus'
import { useToast } from '../../../../components/toast/toastComponent'
export function useCompanyUploadId() {
// Auth + status
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const { showToast } = useToast()
// Form state
const [idNumber, setIdNumber] = useState('')
@ -37,7 +39,13 @@ export function useCompanyUploadId() {
// File handlers
const handleFile = (file: File, which: 'front' | 'extra') => {
if (file.size > 10 * 1024 * 1024) {
setError('File size exceeds 10 MB.')
const msg = 'File size exceeds 10 MB.'
setError(msg)
showToast({
variant: 'error',
title: 'File too large',
message: msg,
})
return
}
setError('')
@ -81,7 +89,13 @@ export function useCompanyUploadId() {
// Validation
const validate = () => {
if (!idNumber.trim() || !idType || !expiryDate || !frontFile) {
setError('Please complete all required fields (marked with *).')
const msg = 'Please complete all required fields (marked with *).'
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
message: msg,
})
return false
}
setError('')
@ -93,7 +107,13 @@ export function useCompanyUploadId() {
e.preventDefault()
if (!validate()) return
if (!accessToken) {
setError('Not authenticated. Please log in again.')
const msg = 'Not authenticated. Please log in again.'
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
message: msg,
})
return
}
@ -116,10 +136,16 @@ export function useCompanyUploadId() {
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Upload failed' }))
throw new Error(errorData.message || 'Upload failed')
const msg = errorData.message || 'Upload failed'
throw new Error(msg)
}
setSuccess(true)
showToast({
variant: 'success',
title: 'Documents uploaded',
message: 'Your company ID documents have been uploaded successfully.',
})
await refreshStatus()
setTimeout(() => {
@ -136,7 +162,13 @@ export function useCompanyUploadId() {
}, 1500)
} catch (err: any) {
console.error('Company ID upload error:', err)
setError(err?.message || 'Upload failed.')
const msg = err?.message || 'Upload failed.'
setError(msg)
showToast({
variant: 'error',
title: 'Upload failed',
message: msg,
})
} finally {
setSubmitting(false)
}

View File

@ -3,9 +3,9 @@
import PageLayout from '../../../components/PageLayout'
import { DocumentArrowUpIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { useCompanyUploadId } from './hooks/useCompanyUploadId'
import useAuthStore from '../../../store/authStore' // NEW
import { useEffect, useState } from 'react' // NEW
import { useRouter } from 'next/navigation' // NEW
import useAuthStore from '../../../store/authStore'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
const DOC_TYPES = ['Personalausweis', 'Reisepass', 'Führerschein', 'Aufenthaltstitel']
@ -25,9 +25,9 @@ export default function CompanyIdUploadPage() {
handleFile, onDrop, clearFile, dropHandlers, openPicker, submit,
} = useCompanyUploadId()
const user = useAuthStore(s => s.user) // NEW
const router = useRouter() // NEW
const [blocked, setBlocked] = useState(false) // NEW
const user = useAuthStore(s => s.user)
const router = useRouter()
const [blocked, setBlocked] = useState(false)
// Guard: only 'company' users allowed on this page
useEffect(() => {
@ -56,27 +56,22 @@ export default function CompanyIdUploadPage() {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 w-full px-5 lg:px-10 py-10">
{/* Background (same as personal) */}
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
<svg aria-hidden="true" className="absolute inset-0 -z-10 h-full w-full stroke-white/10">
<defs>
<pattern id="company-id-pattern" x="50%" y={-1} width={200} height={200} patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect fill="url(#company-id-pattern)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div aria-hidden="true" className="absolute top-0 right-0 left-1/2 -ml-24 blur-3xl transform-gpu overflow-hidden lg:ml-24 xl:ml-48">
<div
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
style={{ clipPath: 'polygon(63.1% 29.5%,100% 17.1%,76.6% 3%,48.4% 0%,44.6% 4.7%,54.5% 25.3%,59.8% 49%,55.2% 57.8%,44.4% 57.2%,27.8% 47.9%,35.1% 81.5%,0% 97.7%,39.2% 100%,35.2% 81.4%,97.2% 52.8%,63.1% 29.5%)' }}
/>
</div>
<div className="relative min-h-screen overflow-hidden bg-slate-50">
{/* Animated background (same as dashboard) */}
<div className="pointer-events-none absolute inset-0 z-0">
{/* Soft gradient blobs */}
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
{/* Subtle radial highlight */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
</div>
<form onSubmit={submit} className="relative max-w-7xl w-full mx-auto bg-white rounded-2xl shadow-xl ring-1 ring-black/10 overflow-hidden">
<main className="relative z-10 flex flex-col flex-1 w-full px-5 lg:px-10 py-10">
<form
onSubmit={submit}
className="relative max-w-7xl w-full mx-auto bg-white rounded-2xl shadow-xl ring-1 ring-black/10 overflow-hidden"
>
<div className="px-6 py-8 sm:px-12 lg:px-16">
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">
Company Contact Person Identity Verification
@ -289,6 +284,7 @@ export default function CompanyIdUploadPage() {
</div>
</div>
</form>
</main>
</div>
</PageLayout>
)

View File

@ -3,11 +3,13 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import useAuthStore from '../../../../store/authStore'
import { useUserStatus } from '../../../../hooks/useUserStatus'
import { useToast } from '../../../../components/toast/toastComponent'
export function usePersonalUploadId() {
// Auth and status
const token = useAuthStore(s => s.accessToken)
const { refreshStatus } = useUserStatus()
const { showToast } = useToast()
// Form state
const [idNumber, setIdNumber] = useState('')
@ -37,7 +39,13 @@ export function usePersonalUploadId() {
// File handlers
const handleFile = (f: File, side: 'front' | 'back') => {
if (f.size > 10 * 1024 * 1024) {
setError('File size exceeds 10 MB.')
const msg = 'File size exceeds 10 MB.'
setError(msg)
showToast({
variant: 'error',
title: 'File too large',
message: msg,
})
return
}
setError('')
@ -81,15 +89,33 @@ export function usePersonalUploadId() {
// Validation
const validate = () => {
if (!idNumber.trim() || !idType || !expiry) {
setError('Please fill out all required fields.')
const msg = 'Please fill out all required fields.'
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
message: msg,
})
return false
}
if (!frontFile) {
setError('Please upload the front side.')
const msg = 'Please upload the front side.'
setError(msg)
showToast({
variant: 'error',
title: 'Front side missing',
message: msg,
})
return false
}
if (hasBack && !backFile) {
setError('Please upload the back side or disable the switch.')
const msg = 'Please upload the back side or disable the switch.'
setError(msg)
showToast({
variant: 'error',
title: 'Back side missing',
message: msg,
})
return false
}
setError('')
@ -101,7 +127,13 @@ export function usePersonalUploadId() {
e.preventDefault()
if (!validate()) return
if (!token) {
setError('Not authenticated. Please log in again.')
const msg = 'Not authenticated. Please log in again.'
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
message: msg,
})
return
}
@ -126,6 +158,11 @@ export function usePersonalUploadId() {
if (response.ok && data.success) {
setSuccess(true)
showToast({
variant: 'success',
title: 'Documents uploaded',
message: 'Your ID documents have been uploaded successfully.',
})
await refreshStatus()
setTimeout(() => {
// Check if we came from tutorial
@ -139,11 +176,23 @@ export function usePersonalUploadId() {
}
}, 2000)
} else {
setError(data.message || 'Upload failed. Please try again.')
const msg = data.message || 'Upload failed. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Upload failed',
message: msg,
})
}
} catch (err) {
console.error('Upload error:', err)
setError('Network error. Please try again.')
const msg = 'Network error. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Network error',
message: msg,
})
} finally {
setSubmitting(false)
}

View File

@ -60,27 +60,22 @@ export default function PersonalIdUploadPage() {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 w-full px-5 lg:px-10 py-10">
{/* Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
<svg aria-hidden="true" className="absolute inset-0 -z-10 h-full w-full stroke-white/10">
<defs>
<pattern id="personal-id-pattern" x="50%" y={-1} width={200} height={200} patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect fill="url(#personal-id-pattern)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div aria-hidden="true" className="absolute top-0 right-0 left-1/2 -ml-24 blur-3xl transform-gpu overflow-hidden lg:ml-24 xl:ml-48">
<div
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
style={{ clipPath: 'polygon(63.1% 29.5%,100% 17.1%,76.6% 3%,48.4% 0%,44.6% 4.7%,54.5% 25.3%,59.8% 49%,55.2% 57.8%,44.4% 57.2%,27.8% 47.9%,35.1% 81.5%,0% 97.7%,39.2% 100%,35.2% 81.4%,97.2% 52.8%,63.1% 29.5%)' }}
/>
</div>
<div className="relative min-h-screen overflow-hidden bg-slate-50">
{/* Animated background (same as dashboard) */}
<div className="pointer-events-none absolute inset-0 z-0">
{/* Soft gradient blobs */}
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
{/* Subtle radial highlight */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
</div>
<form onSubmit={submit} className="relative max-w-7xl w-full mx-auto bg-white rounded-2xl shadow-xl ring-1 ring-black/10 overflow-hidden">
<main className="relative z-10 flex flex-col flex-1 w-full px-5 lg:px-10 py-10">
<form
onSubmit={submit}
className="relative max-w-7xl w-full mx-auto bg-white rounded-2xl shadow-xl ring-1 ring-black/10 overflow-hidden"
>
<div className="px-6 py-8 sm:px-12 lg:px-16">
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">
Personal Identity Verification
@ -300,6 +295,7 @@ export default function PersonalIdUploadPage() {
</div>
</div>
</form>
</main>
</div>
</PageLayout>
)

View File

@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
import { useRegister } from '../hooks/useRegister'
import { useToast } from '../../components/toast/toastComponent'
interface RegisterFormProps {
mode: 'personal' | 'company'
@ -72,6 +73,7 @@ export default function RegisterForm({
// Hook for backend calls
const { registerPersonalReferral, registerCompanyReferral, error: regError } = useRegister()
const { showToast } = useToast()
// Animate form when mode changes
useEffect(() => {
@ -113,24 +115,24 @@ export default function RegisterForm({
if (!personalForm.firstName.trim() || !personalForm.lastName.trim() ||
!personalForm.email.trim() || !personalForm.confirmEmail.trim() ||
!personalForm.password.trim() || !personalForm.confirmPassword.trim() ||
!personalForm.phoneNumber.trim() // now required by backend
!personalForm.phoneNumber.trim()
) {
setError('Alle Felder sind erforderlich')
setError('All fields are required')
return false
}
if (personalForm.email !== personalForm.confirmEmail) {
setError('E-Mail-Adressen stimmen nicht überein')
setError('Email addresses do not match')
return false
}
if (personalForm.password !== personalForm.confirmPassword) {
setError('Passwörter stimmen nicht überein')
setError('Passwords do not match')
return false
}
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(personalForm.password)) {
setError('Passwort muss mindestens 8 Zeichen lang sein und Groß-, Kleinbuchstaben, Ziffern und Sonderzeichen enthalten')
setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters')
return false
}
@ -142,24 +144,24 @@ export default function RegisterForm({
if (!companyForm.companyName.trim() || !companyForm.companyEmail.trim() ||
!companyForm.confirmCompanyEmail.trim() || !companyForm.contactPersonName.trim() ||
!companyForm.password.trim() || !companyForm.confirmPassword.trim() ||
!companyForm.companyPhone.trim() || !companyForm.contactPersonPhone.trim() // now required
!companyForm.companyPhone.trim() || !companyForm.contactPersonPhone.trim()
) {
setError('Alle Felder sind erforderlich')
setError('All fields are required')
return false
}
if (companyForm.companyEmail !== companyForm.confirmCompanyEmail) {
setError('E-Mail-Adressen stimmen nicht überein')
setError('Email addresses do not match')
return false
}
if (companyForm.password !== companyForm.confirmPassword) {
setError('Passwörter stimmen nicht überein')
setError('Passwords do not match')
return false
}
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(companyForm.password)) {
setError('Passwort muss mindestens 8 Zeichen lang sein und Groß-, Kleinbuchstaben, Ziffern und Sonderzeichen enthalten')
setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters')
return false
}
@ -187,12 +189,29 @@ export default function RegisterForm({
phone: personalForm.phoneNumber,
})
if (res.ok) {
showToast({
variant: 'success',
title: 'Registration successful',
message: 'You can now log in with your new account.'
})
onRegistered()
} else {
setError(res.message || 'Registrierung fehlgeschlagen. Bitte versuche es erneut.')
const msg = res.message || 'Registration failed. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Registration failed',
message: msg
})
}
} catch (error) {
setError('Registrierung fehlgeschlagen. Bitte versuche es erneut.')
const msg = 'Registration failed. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Registration failed',
message: msg
})
} finally {
setLoading(false)
}
@ -218,12 +237,29 @@ export default function RegisterForm({
contactPersonPhone: companyForm.contactPersonPhone,
})
if (res.ok) {
showToast({
variant: 'success',
title: 'Registration successful',
message: 'You can now log in with your new company account.'
})
onRegistered()
} else {
setError(res.message || 'Registrierung fehlgeschlagen. Bitte versuche es erneut.')
const msg = res.message || 'Registration failed. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Registration failed',
message: msg
})
}
} catch (error) {
setError('Registrierung fehlgeschlagen. Bitte versuche es erneut.')
const msg = 'Registration failed. Please try again.'
setError(msg)
showToast({
variant: 'error',
title: 'Registration failed',
message: msg
})
} finally {
setLoading(false)
}
@ -231,7 +267,14 @@ export default function RegisterForm({
// Surface hook error if present and no local error
useEffect(() => {
if (regError && !error) setError(regError)
if (regError && !error) {
setError(regError)
showToast({
variant: 'error',
title: 'Registration failed',
message: regError
})
}
}, [regError]) // eslint-disable-line react-hooks/exhaustive-deps
// Input change handlers
@ -259,16 +302,16 @@ export default function RegisterForm({
const renderPasswordStrength = (password: string) => {
const strength = getPasswordStrength(password)
const rules = [
{ test: password.length >= 8, text: 'Mindestens 8 Zeichen' },
{ test: /[a-z]/.test(password), text: 'Kleinbuchstaben (a-z)' },
{ test: /[A-Z]/.test(password), text: 'Großbuchstaben (A-Z)' },
{ test: /\d/.test(password), text: 'Ziffern (0-9)' },
{ test: /[\W_]/.test(password), text: 'Sonderzeichen (!@#$...)' }
{ test: password.length >= 8, text: 'At least 8 characters' },
{ test: /[a-z]/.test(password), text: 'Lowercase letters (a-z)' },
{ test: /[A-Z]/.test(password), text: 'Uppercase letters (A-Z)' },
{ test: /\d/.test(password), text: 'Digits (0-9)' },
{ test: /[\W_]/.test(password), text: 'Special characters (!@#$...)' }
]
return (
<div className="mt-2">
<div className="text-sm text-slate-700 mb-2">Passwort-Anforderungen:</div>
<div className="text-sm text-slate-700 mb-2">Password requirements:</div>
<ul className="text-sm space-y-1">
{rules.map((rule, index) => (
<li key={index} className={`flex items-center gap-2 ${rule.test ? 'text-green-600' : 'text-slate-600'}`}>
@ -286,12 +329,12 @@ export default function RegisterForm({
{/* Header */}
<div className="mb-6 text-center">
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2">
Registrierung r Profit Planet
Registration for Profit Planet
</h2>
{/* Replace generic invite with referrer email inside the form */}
{referrerEmail && (
<p className="text-base sm:text-sm text-[#8D6B1D] font-medium">
Du wurdest von <span className="font-semibold">{referrerEmail}</span> eingeladen!
You were invited by <span className="font-semibold">{referrerEmail}</span>!
</p>
)}
</div>
@ -308,7 +351,7 @@ export default function RegisterForm({
onClick={() => setMode('personal')}
type="button"
>
Privatperson
Individual
</button>
<button
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
@ -319,7 +362,7 @@ export default function RegisterForm({
onClick={() => setMode('company')}
type="button"
>
Unternehmen
Company
</button>
</div>
</div>
@ -338,7 +381,7 @@ export default function RegisterForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-[#0F172A] mb-2">
Vorname *
First name *
</label>
<input
type="text"
@ -353,7 +396,7 @@ export default function RegisterForm({
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-[#0F172A] mb-2">
Nachname *
Last name *
</label>
<input
type="text"
@ -370,7 +413,7 @@ export default function RegisterForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-[#0F172A] mb-2">
E-Mail-Adresse *
Email address *
</label>
<input
type="email"
@ -385,7 +428,7 @@ export default function RegisterForm({
<div>
<label htmlFor="confirmEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
E-Mail bestätigen *
Confirm email *
</label>
<input
type="email"
@ -401,7 +444,7 @@ export default function RegisterForm({
<div>
<label htmlFor="phoneNumber" className="block text-sm font-medium text-[#0F172A] mb-2">
Telefonnummer *
Phone number *
</label>
<input
type="tel"
@ -418,7 +461,7 @@ export default function RegisterForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="password" className="block text-sm font-medium text-[#0F172A] mb-2">
Passwort *
Password *
</label>
<div className="relative">
<input
@ -447,7 +490,7 @@ export default function RegisterForm({
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
Passwort bestätigen *
Confirm password *
</label>
<input
type={showPersonalPassword ? 'text' : 'password'}
@ -473,10 +516,10 @@ export default function RegisterForm({
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Registrierung läuft...
Registration in progress...
</>
) : (
'Jetzt registrieren'
'Register now'
)}
</button>
</form>
@ -485,7 +528,7 @@ export default function RegisterForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="companyName" className="block text-sm font-medium text-[#0F172A] mb-2">
Firmenname *
Company name *
</label>
<input
type="text"
@ -500,7 +543,7 @@ export default function RegisterForm({
<div>
<label htmlFor="contactPersonName" className="block text-sm font-medium text-[#0F172A] mb-2">
Ansprechpartner *
Contact person *
</label>
<input
type="text"
@ -517,7 +560,7 @@ export default function RegisterForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="companyEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
Firmen-E-Mail *
Company email *
</label>
<input
type="email"
@ -532,7 +575,7 @@ export default function RegisterForm({
<div>
<label htmlFor="confirmCompanyEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
E-Mail bestätigen *
Confirm email *
</label>
<input
type="email"
@ -549,7 +592,7 @@ export default function RegisterForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="companyPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
Firmen-Telefon *
Company phone *
</label>
<input
type="tel"
@ -565,7 +608,7 @@ export default function RegisterForm({
<div>
<label htmlFor="contactPersonPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
Ansprechpartner-Telefon *
Contact person phone *
</label>
<input
type="tel"
@ -583,7 +626,7 @@ export default function RegisterForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="password" className="block text-sm font-medium text-[#0F172A] mb-2">
Passwort *
Password *
</label>
<div className="relative">
<input
@ -612,7 +655,7 @@ export default function RegisterForm({
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
Passwort bestätigen *
Confirm password *
</label>
<input
type={showCompanyPassword ? 'text' : 'password'}
@ -638,10 +681,10 @@ export default function RegisterForm({
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Registrierung läuft...
Registration in progress...
</>
) : (
'Firma registrieren'
'Register company'
)}
</button>
</form>
@ -651,12 +694,12 @@ export default function RegisterForm({
{/* Login Link */}
<div className="mt-8 text-center">
<p className="text-slate-700">
Bereits registriert?{' '}
Already registered?{' '}
<a
href="/login"
className="text-[#8D6B1D] hover:text-[#7A5E1A] font-medium transition-colors"
>
Hier anmelden
Login here
</a>
</p>
</div>

View File

@ -19,7 +19,8 @@ export default function SessionDetectedModal({
}: SessionDetectedModalProps) {
if (inline) {
return (
<div className="w-full flex justify-center items-center flex-1 min-h-[50vh]">
// removed flex-1 and min-h to avoid extra white gap
<div className="w-full flex justify-center items-center py-8">
<div className="bg-white px-6 py-6 rounded-xl shadow-xl max-w-lg w-full border border-gray-200">
<div className="flex gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100">
@ -27,11 +28,10 @@ export default function SessionDetectedModal({
</div>
<div>
<h3 className="text-base font-semibold leading-6 text-[#0F172A]">
Aktive Sitzung erkannt
Active session detected
</h3>
<p className="mt-2 text-sm text-[#4A4A4A]">
Du bist bereits angemeldet. Um dich zu registrieren, musst du dich zuerst abmelden
oder du kannst zum Dashboard gehen.
You are already logged in. To register, you must first log out or you can go to the dashboard.
</p>
<div className="mt-5 flex flex-col sm:flex-row gap-3 sm:justify-end">
<button
@ -39,14 +39,14 @@ export default function SessionDetectedModal({
onClick={onCancel}
className="inline-flex justify-center rounded-md bg-white px-4 py-2 text-sm font-medium text-[#4A4A4A] shadow-sm ring-1 ring-gray-300 hover:bg-gray-50 transition-colors"
>
Zum Dashboard
Go to dashboard
</button>
<button
type="button"
onClick={onLogout}
className="inline-flex justify-center rounded-md bg-[#8D6B1D] px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
>
Abmelden und registrieren
Log out and register
</button>
</div>
</div>
@ -95,12 +95,11 @@ export default function SessionDetectedModal({
as="h3"
className="text-base font-semibold leading-6 text-[#0F172A]"
>
Aktive Sitzung erkannt
Active session detected
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-[#4A4A4A]">
Du bist bereits angemeldet. Um dich zu registrieren, musst du dich zuerst abmelden
oder du kannst zum Dashboard gehen.
You are already logged in. To register, you must first log out or you can go to the dashboard.
</p>
</div>
</div>
@ -111,14 +110,14 @@ export default function SessionDetectedModal({
className="inline-flex w-full justify-center rounded-md bg-[#8D6B1D] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] sm:ml-3 sm:w-auto transition-colors"
onClick={onLogout}
>
Abmelden und registrieren
Log out and register
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-[#4A4A4A] shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto transition-colors"
onClick={onCancel}
>
Zum Dashboard
Go to dashboard
</button>
</div>
</Dialog.Panel>

View File

@ -7,6 +7,7 @@ import RegisterForm from './components/RegisterForm'
import PageLayout from '../components/PageLayout'
import SessionDetectedModal from './components/SessionDetectedModal'
import InvalidRefLinkModal from './components/invalidRefLinkModal'
import { ToastProvider } from '../components/toast/toastComponent'
export default function RegisterPage() {
const searchParams = useSearchParams()
@ -36,7 +37,7 @@ export default function RegisterPage() {
// Redirect to login after simulated registration
useEffect(() => {
if (registered) {
const t = setTimeout(() => router.push('/login'), 1200)
const t = setTimeout(() => router.push('/login'), 4000) // was 1200
return () => clearTimeout(t)
}
}, [registered, router])
@ -118,23 +119,27 @@ export default function RegisterPage() {
// NEW: Gate rendering until referral check is done
if (!isRefChecked) {
return (
<ToastProvider>
<PageLayout>
<main className="w-full flex flex-col flex-1 items-center justify-center py-24">
<main className="w-full flex flex-col flex-1 items-center justify-center py-24 min-h-screen">
<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-slate-700">Überprüfe Einladungslink</p>
<p className="text-slate-700">Checking invitation link</p>
</div>
</main>
</PageLayout>
</ToastProvider>
)
}
// NEW: Invalid referral link state — show modal instead of form with same background as register form
if (invalidRef) {
return (
<ToastProvider>
<PageLayout>
<main className="w-full flex flex-col flex-1 gap-10">
<div className="relative overflow-hidden pt-16 sm:pt-20 pb-20 sm:pb-24">
<main className="w-full flex flex-col flex-1 gap-10 min-h-screen">
{/* make wrapper flex-1 so background reaches the footer */}
<div className="relative flex-1 overflow-hidden pt-16 sm:pt-20 pb-20 sm:pb-24">
{/* Pattern */}
<svg
aria-hidden="true"
@ -196,14 +201,17 @@ export default function RegisterPage() {
</div>
</main>
</PageLayout>
</ToastProvider>
)
}
return (
<ToastProvider>
<PageLayout>
<main className="w-full flex flex-col flex-1 gap-10">
<main className="w-full flex flex-col flex-1 gap-10 min-h-screen">
{/* Background section wrapper */}
<div className="relative overflow-hidden pt-16 sm:pt-20 pb-20 sm:pb-24">
{/* make wrapper flex-1 so background reaches the footer */}
<div className="relative flex-1 overflow-hidden pt-16 sm:pt-20 pb-20 sm:pb-24">
{/* Pattern */}
<svg
aria-hidden="true"
@ -255,11 +263,10 @@ export default function RegisterPage() {
{/* Heading (optional adjusted to registration context) */}
<div className="mx-auto max-w-2xl text-center mb-10">
<h1 className="text-4xl font-semibold tracking-tight text-white sm:text-5xl">
Registriere dich jetzt
Register now
</h1>
<p className="mt-2 text-lg/8 text-gray-200">
Erstelle dein persönliches oder Unternehmens-Konto bei Profit
Planet.
Create your personal or company account with Profit Planet.
</p>
</div>
@ -288,7 +295,7 @@ export default function RegisterPage() {
)}
{registered && (
<div className="mt-6 mx-auto text-center text-sm text-gray-200">
Registrierung erfolgreich Weiterleitung...
Registration successful redirecting...
</div>
)}
</>
@ -298,5 +305,6 @@ export default function RegisterPage() {
</div>
</main>
</PageLayout>
</ToastProvider>
)
}