Merge pull request 'bigTypeShii' (#16) from bigTypeShii into dev

Reviewed-on: #16
This commit is contained in:
Seazn 2026-03-16 19:53:06 +00:00
commit 2e416521f8
32 changed files with 4025 additions and 818 deletions

View File

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

800
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,12 +22,15 @@
"@tailwindplus/elements": "^1.0.22", "@tailwindplus/elements": "^1.0.22",
"@tailwindui/react": "^0.1.1", "@tailwindui/react": "^0.1.1",
"axios": "^1.13.5", "axios": "^1.13.5",
"canvg": "^4.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"country-flag-icons": "^1.6.13", "country-flag-icons": "^1.6.13",
"country-select-js": "^2.1.0", "country-select-js": "^2.1.0",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"html2canvas": "^1.4.1",
"intl-tel-input": "^26.4.1", "intl-tel-input": "^26.4.1",
"jspdf": "^4.2.0",
"lucide-react": "^0.574.0", "lucide-react": "^0.574.0",
"motion": "^12.34.1", "motion": "^12.34.1",
"next": "^16.1.6", "next": "^16.1.6",
@ -65,4 +68,4 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@ -0,0 +1,694 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ABO Vertrag Profit Planet GmbH</title>
<style>
/* Print setup */
@page { size: A4; margin: 14mm; }
:root {
--ink: #111827;
--muted: #6b7280;
--line: #d1d5db;
--soft: #f3f4f6;
--soft2: #f9fafb;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
color: var(--ink);
font-family: Arial, Helvetica, sans-serif;
font-size: 11.5pt;
line-height: 1.35;
background: #ffffff;
}
.doc {
width: 100%;
max-width: 760px;
margin: 0 auto;
padding: 0 16px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 10px;
border-bottom: 2px solid var(--ink);
margin-bottom: 12px;
}
.brand {
min-width: 260px;
}
.brand .title {
font-weight: 800;
letter-spacing: 0.2px;
font-size: 16pt;
margin: 0 0 4px;
}
.brand .subtitle {
margin: 0;
color: var(--muted);
font-size: 10pt;
}
.company {
text-align: right;
font-size: 9.5pt;
color: var(--ink);
max-width: 340px;
}
.company .muted { color: var(--muted); }
h1 {
margin: 10px 0 0;
font-size: 15pt;
letter-spacing: 0.2px;
}
h2 {
margin: 18px 0 8px;
font-size: 12.5pt;
border-bottom: 1px solid var(--line);
padding-bottom: 4px;
}
h3 {
margin: 12px 0 6px;
font-size: 11.5pt;
}
.grid2 {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.box {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
background: var(--soft2);
}
.box .boxTitle {
font-weight: 700;
margin-bottom: 6px;
font-size: 10.5pt;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.row {
display: grid;
grid-template-columns: 1fr;
gap: 2px;
padding: 4px 0;
border-bottom: 1px dashed #e5e7eb;
}
.row:last-child { border-bottom: 0; }
.label {
color: var(--muted);
font-size: 10pt;
}
.value {
font-weight: 600;
}
.fill {
display: inline-block;
min-width: 160px;
border-bottom: 1px solid #9ca3af;
padding: 0 4px 1px;
font-weight: 600;
}
.fill.wide { min-width: 280px; }
.fill.full { display: block; width: 100%; min-width: 0; }
.metaGrid {
display: grid;
grid-template-columns: auto auto;
justify-content: end;
align-items: end;
column-gap: 8px;
row-gap: 4px;
text-align: right;
font-size: 10pt;
}
.metaGrid .metaLabel {
white-space: nowrap;
}
.metaGrid .metaValue {
text-align: right;
}
.metaGrid .fill {
min-width: 160px;
}
.hint {
color: var(--muted);
font-size: 9.5pt;
margin-top: 6px;
}
.checkline {
display: flex;
gap: 18px;
flex-wrap: wrap;
align-items: center;
padding: 6px 0;
}
.check {
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 10.5pt;
}
.checkbox {
width: 14px;
height: 14px;
border: 1px solid var(--ink);
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
border-radius: 2px;
position: relative;
top: 2px;
flex: 0 0 auto;
}
.checkbox.checked::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 10px;
border-right: 2px solid var(--ink);
border-bottom: 2px solid var(--ink);
transform: translate(-50%, -60%) rotate(40deg);
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: #fff;
margin: 8px 0 0;
}
thead th {
text-align: left;
font-size: 10pt;
padding: 8px 10px;
background: var(--soft);
border-bottom: 1px solid var(--line);
}
tbody td {
padding: 8px 10px;
border-bottom: 1px solid #e5e7eb;
vertical-align: top;
font-size: 10.5pt;
}
tbody tr:last-child td { border-bottom: 0; }
.right { text-align: right; }
.muted { color: var(--muted); }
.pageBreak {
page-break-before: always;
break-before: page;
margin-top: 18px;
}
.para { margin: 0 0 10px; }
.para.tight { margin-bottom: 6px; }
.legal h3 {
margin-top: 14px;
margin-bottom: 6px;
font-size: 11.2pt;
}
.legal p {
margin: 0 0 10px;
text-align: justify;
}
.sigGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 18px;
}
.sig {
border-top: 1px solid #111827;
padding-top: 8px;
font-size: 10pt;
color: var(--muted);
}
.sig strong { color: var(--ink); }
.footerNote {
margin-top: 16px;
font-size: 9.5pt;
color: var(--muted);
}
@media print {
body { background: #fff; }
.box { background: #fff; }
thead th { background: #f3f4f6; }
/* Keep important blocks from being split across pages */
.header,
.grid2,
.box,
table,
thead,
tbody,
tr,
.sigGrid,
.signature-grid,
.signature-cell,
.signature-section {
break-inside: avoid;
page-break-inside: avoid;
-webkit-column-break-inside: avoid;
}
/* Keep labels/rows together */
.row {
break-inside: avoid;
page-break-inside: avoid;
}
/* Keep headings with the first lines of the following content */
h1, h2, h3 {
break-after: avoid-page;
page-break-after: avoid;
}
/* Improve paragraph pagination */
p {
orphans: 3;
widows: 3;
}
/* Repeat table header on new pages (Chromium/Puppeteer) */
thead { display: table-header-group; }
/* Explicit page breaks */
.pageBreak {
break-before: page;
page-break-before: always;
margin-top: 0;
}
}
</style>
</head>
<body>
<div class="doc">
<div class="header">
<div class="brand">
<p class="title">ABO Vertrag</p>
<p class="subtitle">Angebot auf Abschluss eines Kauf- Mietvertrages Kaffee-Service- Kapsel</p>
</div>
<div class="company">
<div><strong>PROFIT PLANET GMBH</strong></div>
<div>Liebenauer Hauptstraße 82c</div>
<div>A-8041 Graz</div>
<div class="muted">FN-649474 i</div>
<div class="muted" style="margin-top:6px;">IBAN: AT16 2081 5000 4639 9507</div>
<div class="muted">Swift/BIC Code: STSPAT2GXXX</div>
<div class="muted">ATU82089605</div>
</div>
</div>
<div style="display:flex; justify-content: space-between; gap: 12px; align-items: flex-end;">
<div>
<h1>Vertrag über automatische Wiederbestellungen (ABO)</h1>
<p class="para muted" style="margin: 4px 0 0;">Bitte alle Felder vollständig ausfüllen und Zutreffendes ankreuzen.</p>
</div>
<div class="metaGrid muted">
<div class="metaLabel">Vertragsnummer:</div>
<div class="metaValue"><span class="fill">{{contractNumber}}</span></div>
<div class="metaLabel">Datum:</div>
<div class="metaValue"><span class="fill">{{currentDate}}</span></div>
</div>
</div>
<h2>An die</h2>
<div class="box">
<div class="row">
<div class="label">Empfänger</div>
<div class="value"><span class="fill wide">{{recipientName}}</span></div>
</div>
<div class="row">
<div class="label">Adresse</div>
<div class="value"><span class="fill full">{{recipientAddress}}</span></div>
</div>
</div>
<h2>Lieferadresse</h2>
<div class="box">
<div class="grid2">
<div>
<div class="checkline" style="padding-top: 0;">
<span class="check"><span class="checkbox {{shippingCustomerClass}}"></span> KUNDE</span>
<span class="check"><span class="checkbox {{shippingCompanyClass}}"></span> FIRMA</span>
</div>
<div class="row"><div class="label">Vor- und Nachname</div><div class="value"><span class="fill wide">{{shippingFullName}}</span></div></div>
<div class="row"><div class="label">Adresse</div><div class="value"><span class="fill full">{{shippingStreet}}</span></div></div>
<div class="row"><div class="label">PLZ / Ort</div><div class="value"><span class="fill">{{shippingPostalCode}}</span> &nbsp;&nbsp; <span class="fill wide">{{shippingCity}}</span></div></div>
</div>
<div>
<div class="row"><div class="label">Telefonnummer</div><div class="value"><span class="fill">{{shippingPhone}}</span></div></div>
<div class="row"><div class="label">Mobil</div><div class="value"><span class="fill">{{shippingMobile}}</span></div></div>
<div class="row"><div class="label">E-Mail-Adresse</div><div class="value"><span class="fill wide">{{shippingEmail}}</span></div></div>
<div class="row">
</div>
</div>
</div>
<div class="hint">Rechnungsadresse: <strong>{{invoiceSameAsShippingMark}} wie Lieferadresse</strong></div>
</div>
<div class="pageBreak"></div>
<h2>Rechnungsadresse (falls abweichend)</h2>
<div class="box">
<div class="grid2">
<div>
<div class="checkline" style="padding-top: 0;">
<span class="check"><span class="checkbox {{invoiceCompanyClass}}"></span> FIRMA</span>
<span class="check"><span class="checkbox {{invoiceCustomerClass}}"></span> KUNDE</span>
</div>
<div class="row"><div class="label">Vor- und Nachname</div><div class="value"><span class="fill wide">{{invoiceFullName}}</span></div></div>
<div class="row"><div class="label">Adresse</div><div class="value"><span class="fill full">{{invoiceStreet}}</span></div></div>
<div class="row"><div class="label">PLZ / Ort</div><div class="value"><span class="fill">{{invoicePostalCode}}</span> &nbsp;&nbsp; <span class="fill wide">{{invoiceCity}}</span></div></div>
</div>
<div>
<div class="row"><div class="label">Telefonnummer</div><div class="value"><span class="fill">{{invoicePhone}}</span></div></div>
<div class="row"><div class="label">Mobil</div><div class="value"><span class="fill">{{invoiceMobile}}</span></div></div>
<div class="row"><div class="label">E-Mail-Adresse</div><div class="value"><span class="fill wide">{{invoiceEmail}}</span></div></div>
</div>
</div>
<div class="checkline" style="margin-top: 6px;">
<span class="check"><span class="checkbox {{fnCheckedClass}}"></span> FN: <span class="fill">{{fnNumber}}</span></span>
<span class="check"><span class="checkbox {{atuCheckedClass}}"></span> ATU: <span class="fill">{{atuNumber}}</span></span>
</div>
</div>
<h2>Zutreffendes bitte ankreuzen</h2>
<div class="box">
<div class="para tight">
<span class="check"><span class="checkbox {{entrepreneurClass}}"></span></span>
Der Kunde/Käufer tätigt das gegenständliche Rechtsgeschäft als Unternehmer im Sinne des § 1 Abs 1 Z 1 KSchG, das heißt, das Geschäft gehört zum Betrieb seines Unternehmens.
</div>
<div class="para tight">
<span class="check"><span class="checkbox {{consumerClass}}"></span></span>
Der Kunde/Käufer tätigt das gegenständliche Rechtsgeschäft als Konsument im Sinne des § 1 Abs 1 Z 2 KSchG.
</div>
</div>
<h2>Angebote</h2>
<div class="box">
<p class="para">
Mindestbestellmenge für BIO Kaffee und BIO Tee und BIO Kakao beträgt pro Bestellung jeweils <strong>120 Kapseln</strong>.
Preis pro Kapsel <strong>€ 2,97</strong> inkl. 20% MwSt. Preise und Konditionen gemäß gültigem PROFIT PLANET GMBH Tarif.
</p>
<table>
<thead>
<tr>
<th>Tarif</th>
<th class="right">Preis pro Kapsel</th>
</tr>
</thead>
<tbody>
<tr>
<td>Customer without abo</td>
<td class="right"><strong>2.97€</strong></td>
</tr>
<tr>
<td>Customer with abo</td>
<td class="right"><strong>1.77€</strong></td>
</tr>
</tbody>
</table>
</div>
<h2>Produktauswahl</h2>
<div class="box">
<p class="para muted" style="margin-bottom: 6px;">Superfood Coffee 60 Kapseln (bitte gewünschte Sorten ankreuzen / ergänzen)</p>
{{selectedProductsHtml}}
<p class="para" style="margin-top: 10px;">
Bei Angabe einer automatischen Wiederbestellung, gemäß den Regelungen in nachstehendem Punkt 3, erhält der Kunde in regelmäßigen Abständen,
<strong>BEGINNEND AM (Unterzeichnung des Vertrages)</strong> vorstehend eingetragene BIO Kaffee-Teemenge für die Dauer des Vertrages oder bis zum Widerruf der automatischen Wiederbestellung.
Der BIO Kaffee-Tee wird automatisch im monatlichen Abstand fakturiert und innerhalb von drei bis fünf Werktagen an den Kunden geliefert.</p>
</div>
<div class="pageBreak"></div>
<h2>Zahlungsart</h2>
<div class="box">
<div class="checkline">
<span class="check"><span class="checkbox {{paymentSepaClass}}"></span> Sepa</span>
<span class="check"><span class="checkbox {{paymentCardClass}}"></span> Kreditkarte</span>
<span class="check"><span class="checkbox {{paymentSofortClass}}"></span> Sofortbanking</span>
</div>
<div class="checkline" style="margin-top: 2px;">
<span class="check"><span class="checkbox {{invoiceByEmailClass}}"></span> Bitte senden Sie mir meine Rechnung per E-Mail zu!</span>
</div>
</div>
<div class="legal">
<h2>§ 1 Vertragsgegenstand/Geltung der Allgemeinen Geschäftsbedingungen</h2>
<p>(1) Die Allgemeinen Geschäftsbedingungen (AGB, siehe unten) der Profit Planet GmbH sind verbindlicher Bestandteil dieses Vertrages. Abweichende, entgegenstehende oder ergänzende Allgemeine Geschäftsbedingungen des Kunden werden nur dann und insoweit Vertragsbestandteil, als Profit Planet GmbH ihrer Geltung ausdrücklich zugestimmt hat. Dieses Zustimmungserfordernis gilt in jedem Fall, beispielsweise auch dann, wenn Profit Planet GmbH in Kenntnis der AGB des Kunden die Lieferung an ihn vorbehaltlos ausführt.</p>
<h2>§ 2 Laufzeit des Vertrages/Zahlung per Lastschrift/Abrechnung</h2>
<p>(1) Der Vertrag hat eine Laufzeit von 36 Monaten.</p>
<p>(2) Ist der Kunde Unternehmer, verlängert sich der Vertrag nach Ablauf der 36 Monate jeweils um 3 Monate, sofern er nicht von einer der Parteien mit einer Frist von 4 Wochen vor Vertragsende gekündigt wird.</p>
<p>(3) Ist der Kunde Verbraucher, wird die Profit Planet GmbH den Verbraucher spätestens drei Monate und frühestens fünf Monate vor Ablauf der Vertragsdauer in Textform auf das bevorstehende Vertragsende und die automatische Verlängerung hinweisen. Erfolgt kein solcher Hinweis, endet der Vertrag mit Ablauf der ursprünglichen Vertragsdauer. Nach rechtzeitigem Hinweis verlängert sich der Vertrag auch mit Verbrauchern um jeweils 3 Monate, wenn er nicht bis spätestens 4 Wochen vor Ablauf der jeweiligen Vertragslaufzeit von einer der Parteien gekündigt wird.</p>
<p>(4) Die Zahlung per Kreditkarte, Lastschrift/Bankeinzug, Rechnung und Nachnahme ist Voraussetzung für den Vertrag (SEPA Lastschrift-Mandat).</p>
<h2>§ 3 Automatische Wiederbestellungen</h2>
<p>(1) Bei Angabe einer automatischen Wiederbestellung durch den Kunden wird diesem im gewählten Bestellintervall BIO Kaffee, Tee gemäß der auf Seite 2,3,4 und 5 stehenden Tabelle gewählte Menge an die aktuelle Lieferadresse geschickt. Die Zusammenstellung der Kaffee Teevarietäten kann bei schriftlichem Einlangen des Änderungswunsches bis zwei Werktage von dem Kunden gewählten Versanddatum geändert werden.</p>
<h2>§ 4 Verpflichtung zur Verwendung von Produkten der Profit Planet GmbH Vertragsstrafe/Liefervereinbarung</h2>
<p>(1) Der Kunde verpflichtet sich, während der Laufzeit des Vertrags auf den von Profit Planet GmbH zur Verfügung gestellten Kaffeemaschinen ausschließlich Produkte der Profit Planet GmbH „VITAPRESSO“ zu verwenden und einzusetzen, maximal jedoch für einen Zeitraum von drei (3) und/oder fünf (5) Jahren ab Vertragsschluss.</p>
<p>(2) Der Kaffee wird wiederkehrend zugestellt laut Bestellung.</p>
<p>(3) Verstößt der Kunde gegen seine Verpflichtung aus Abs. 1, so ist die Profit Planet GmbH zur außerordentlichen fristlosen Kündigung aus wichtigem Grund berechtigt. Darüber hinaus vereinbaren die Parteien die Zahlung einer verschuldensunabhängigen Vertragsstrafe durch den Kunden an die Profit Planet GmbH in angemessener Höhe, wobei die Profit Planet GmbH die Höhe nach billigem Ermessen bestimmen wird und die Angemessenheit der Vertragsstrafe im Streitfall von dem zuständigen Gericht überprüft werden kann. Die Geltendmachung weiteren Schadensersatzes bleibt vorbehalten.</p>
</div>
<div class="pageBreak"></div>
<div class="legal">
<h2>§ 5 Wartung und Reparatur</h2>
<p>(1) Wartung und Reparaturen der Kaffeemaschinen sind in der Gestellung wie folgt enthalten:</p>
<p>(a) Alle Wartungsarbeiten und Reparaturen werden werktags, von Montag bis Freitag zu den üblichen Arbeitszeiten (09:00 - 17:00 Uhr) telefonisch: 0043 676 3440274 oder schriftlich an office@profit-planet.com durchgeführt. Dienstleistungen an Wochenenden und Feiertagen sind ausgeschlossen und können nur gegen einen Aufpreis vom Kunden selbst bei dem von uns benannten, autorisierten Servicepartner beauftragt werden.</p>
<p>(b) Nicht eingeschlossen sind Reparaturen, die auf mangelhafte Pflege oder eine unsachgemäße Bedienung zurückzuführen sind. Insbesondere die Nichtbeachtung der Bedienungs- und Reinigungsanleitung, die unsachgemäße oder mangelnde Entkalkung sowie Bedienungsfehler gehen zu Lasten des Kunden.</p>
<p>(c) Profit Planet GmbH behält sich vor, dem Kunden das Ersatzgerät zum UVP zu fakturieren, sofern das Ersatzgerät nicht innerhalb von 4 Wochen nach Erhalt der reparierten Maschine an den Kundendienst zurückgeschickt wurde.</p>
<p>(d) Ausdrücklich nicht von dem vertraglichen Wartungs- und Reparaturservice umfasst sind: Stellplatzwechsel, Produktumstellung, Verkostung, Umbauten, die Behebung von Störungen, die als Folge von Reparaturen oder Änderungen durch den Kunden oder durch Dritte auftreten, die Behebung von Störungen, die durch unsachgemäße Bedienung oder mangelhafte Reinigung bzw. Pflege verursacht wurden, die Behebung von Störungen, deren Ursache außerhalb der Kaffeemaschine liegen, wie Defekte in der Wasser- und Stromzufuhr, Elementarschäden, Missbrauch, andere außergewöhnliche Einwirkungen und Fremdkörper.</p>
<h2>§ 6 Außerordentliche Kündigung</h2>
<p>(1) Beide Vertragsparteien haben das Recht, diesen Vertrag außerordentlich fristlos zu kündigen, wenn die jeweils andere Vertragspartei gegen wesentliche Bestimmungen dieses Vertrages trotz Mahnung und angemessener Frist verstößt.</p>
<p>(2) Profit Planet GmbH ist insbesondere berechtigt, den Vertrag außerordentlich fristlos zu kündigen, wenn der Kunde</p>
<p>(a) mit einer Zahlung ganz oder teilweise im Verzug ist und Profit Planet GmbH dem Kunden erfolglos eine angemessene Frist zur Zahlung des rückständigen Betrages gesetzt hat, oder</p>
<p>(b) eine in diesem Vertrag vereinbarte Mindestabnahmemenge in einem Zeitraum von 6 Monaten um mehr als durchschnittlich 20% unterschritten wurde, oder</p>
<p>(c) fremde Produkte auf den gestellten Kaffeemaschinen zubereitet.</p>
<p>(3) Im Falle einer außerordentlichen fristlosen Kündigung durch Profit Planet GmbH, behält sich diese vor, dem Kunden eine Deckungsausgleichzahlung für die Restlaufzeit in Höhe von 25% der vereinbarten Mindestabnahmemenge, sowie der vertraglich vereinbarten Mietzinsen in Rechnung zu stellen. Die Geltendmachung weiteren Schadensersatzes bleibt vorbehalten. Dem Kunden bleibt der Nachweis offen, dass kein oder ein wesentlich geringerer Schaden entstanden ist.</p>
<h2>§ 7 Eigentumsverhältnisse</h2>
<p>Die gelieferten Maschinen bleiben Eigentum von Profit Planet GmbH.</p>
</div>
<div class="pageBreak"></div>
<div class="legal">
<h2>§ 8 Interne Bestellsysteme und Bestellungen, Datenschutz</h2>
<p>Weiteres stimme ich dem Erhalt von exklusiven Angeboten und Informationen wie folgt zu:</p>
<p><span class="check"><span class="checkbox"></span></span> Ich stimme zu, dass die angegebenen Daten von der Profit Planet GmbH verarbeitet und zur Information über exklusive Angebote und sonstige Informationen über E-Mail-Newsletters verwendet werden. Die Zustimmung kann jederzeit per E-Mail an office@profit-planet.com widerrufen werden. Ich akzeptiere hiermit die Datenschutzbestimmungen.</p>
<p><span class="check"><span class="checkbox"></span></span> Ich stimme zu, dass die angegebenen Daten von der Profit Planet GmbH verarbeitet und zur telefonischen Information über exklusive Angebote und sonstige Informationen verwendet werden. Die Zustimmung kann jederzeit per E-Mail an office@profit-planet.com widerrufen werden. Ich akzeptiere hiermit die Datenschutzbestimmungen.</p>
<p>Sie haben uns schon früher Ihre Zustimmung gegeben und erhalten schon Informationen und Angebote zu unseren Produkten, wollen diese Einwilligung aber jetzt widerrufen: Diese Zustimmung kann jederzeit per E-Mail an office@profit-planet.com widerrufen werden.</p>
<p>Mit dieser Unterschrift wird (werden) das (die) auf Seite 1 genannte(n) Gerät(e) zu genannten Konditionen übernommen.</p>
<p>Es gelten die Allgemeinen Geschäftsbedingungen der Profit Planet GmbH als vereinbart. Der Kunde erklärt hiermit ausdrücklich, dass er die Allgemeinen Geschäftsbedingungen und die Datenschutzbestimmungen gelesen hat und diesen zustimmt. Der Vertrag kommt mittels Annahme durch Profit Planet GmbH zustande und ist unter der Voraussetzung einer positiven Bonitätsprüfung gültig.</p>
<div class="box" style="margin-top: 14px;">
<div class="row"><div class="label">Ort</div><div class="value"><span class="fill wide">{{shippingCity}}</span></div></div>
<div class="row"><div class="label">Datum</div><div class="value"><span class="fill wide">{{currentDate}}</span></div></div>
</div>
<div class="signature-section" style="margin-top: 30px; border-top: 1px solid #ccc; padding-top: 10px;">
<div class="signature-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start;">
<div class="signature-cell" style="text-align: center;">
<span class="signature-label" style="font-weight: bold;">Unterschrift Profit Planet</span><br>
{{profitplanetSignature}}
</div>
<div class="signature-cell" style="text-align: center;">
<span class="signature-label" style="font-weight: bold;">Stempel / Unterschrift Kunde</span><br>
{{signatureImage}}
</div>
</div>
<div class="signature-fullname" style="margin-top: 10px; font-size: 0.95em;">
fullname: {{fullName}}
</div>
<div class="signature-date" style="margin-top: 4px; font-size: 0.95em;">
date: {{currentDate}}
</div>
</div>
<p class="footerNote">Informationen über Inhaltsstoffe, Nährwertangaben etc. finden Sie auf der Lieferanten-Homepage www.lanaturalifestyle.com oder unter der Telefonnummer: 0043 552 322 960.</p>
</div>
<div class="pageBreak"></div>
<div class="legal">
<h2>Allgemeine Geschäftsbedingungen Kaffee-Service</h2>
<h3>§ 1 Geltungsbereich, Form</h3>
<p>(1) Die vorliegenden Allgemeinen Geschäftsbedingungen (AGB) gelten für alle unsere Geschäftsbeziehungen zwischen unseren Kunden und uns, der Profit Planet GmbH, Liebenauer Hauptstraße 82c, 8041 Graz, Österreich. Die AGB gelten gegenüber Verbrauchern und Unternehmern; zwingende Verbraucherschutzbestimmungen (insbesondere nach KSchG und FAGG) gehen im Zweifel diesen AGB vor.</p>
<p>(2) Die AGB gelten insbesondere für Verträge über den Verkauf und/oder die Lieferung beweglicher Sachen („Ware“), ohne Rücksicht darauf, ob wir die Ware selbst herstellen oder bei Zulieferern einkaufen. Sofern nichts anderes vereinbart, gelten die AGB in der zum Zeitpunkt der Bestellung des Käufers gültigen bzw. jedenfalls in der ihm zuletzt in Textform mitgeteilten Fassung als Rahmenvereinbarung auch für gleichartige künftige Verträge, ohne dass wir in jedem Einzelfall wieder auf sie hinweisen müssten.</p>
<p>(3) Unsere AGB gelten ausschließlich. Abweichende, entgegenstehende oder ergänzende Allgemeine Geschäftsbedingungen des Käufers werden nur dann und insoweit Vertragsbestandteil, als wir ihrer Geltung ausdrücklich zugestimmt haben. Dieses Zustimmungserfordernis gilt in jedem Fall, beispielsweise auch dann, wenn wir in Kenntnis der AGB des Käufers die Lieferung an ihn vorbehaltlos ausführen.</p>
<p>(4) Im Einzelfall getroffene, individuelle Vereinbarungen mit dem Käufer (einschließlich Nebenabreden, Ergänzungen und Änderungen) haben in jedem Fall Vorrang vor diesen AGB. Für den Inhalt derartiger Vereinbarungen ist, vorbehaltlich des Gegenbeweises, ein schriftlicher Vertrag bzw. unsere schriftliche Bestätigung maßgebend.</p>
<p>(5) Rechtserhebliche Erklärungen und Anzeigen des Käufers in Bezug auf den Vertrag (z.B. Fristsetzung, Mängelanzeige, Rücktritt oder Minderung), sind schriftlich, d.h. in Schrift- oder Textform (z.B. Brief, E-Mail, Telefax) abzugeben. Gesetzliche Formvorschriften und weitere Nachweise insbesondere bei Zweifeln über die Legitimation des Erklärenden bleiben unberührt.</p>
<p>(6) Hinweise auf die Geltung gesetzlicher Vorschriften haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen AGB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden.</p>
<h3>§ 2 Vertragsschluss</h3>
<p>(1) Unsere Angebote sind freibleibend und unverbindlich. Dies gilt auch, wenn wir dem Käufer Kataloge, technische Dokumentationen (z.B. Zeichnungen, Pläne, Berechnungen, Kalkulationen, Verweisungen auf DIN-Normen), sonstige Produktbeschreibungen oder Unterlagen auch in elektronischer Form überlassen haben, an denen wir uns Eigentums- und Urheberrechte vorbehalten.</p>
<p>(2) Die Bestellung der Ware durch den Käufer gilt als verbindliches Vertragsangebot. Sofern sich aus der Bestellung nichts anderes ergibt, sind wir berechtigt, dieses Vertragsangebot innerhalb von 30 Tagen nach seinem Zugang bei uns anzunehmen.</p>
<p>(3) Die Annahme kann entweder schriftlich (z.B. durch Auftragsbestätigung) oder durch Auslieferung der Ware an den Käufer erklärt werden.</p>
</div>
<div class="pageBreak"></div>
<div class="legal">
<h3>§ 3 Lieferfrist und Lieferverzug</h3>
<p>(1) Die Lieferfrist wird individuell vereinbart bzw. von uns bei Annahme der Bestellung angegeben. Sofern dies nicht der Fall ist, beträgt die Lieferfrist ca. 14 21 Tage ab Vertragsschluss.</p>
<p>(2) Sofern wir verbindliche Lieferfristen aus Gründen, die wir nicht zu vertreten haben, nicht einhalten können (Nichtverfügbarkeit der Leistung), werden wir den Kunden hierüber unverzüglich informieren und gleichzeitig die voraussichtliche, neue Lieferfrist mitteilen. Ist die Leistung auch innerhalb der neuen Lieferfrist nicht verfügbar, sind wir berechtigt, ganz oder teilweise vom Vertrag zurückzutreten; eine bereits erbrachte Gegenleistung des Kunden werden wir unverzüglich erstatten. Als Fall der Nichtverfügbarkeit der Leistung in diesem Sinne gilt insbesondere die nicht rechtzeitige Selbstbelieferung durch unseren Zulieferer, wenn wir ein kongruentes Deckungsgeschäft abgeschlossen haben, weder uns noch unseren Zulieferer ein Verschulden trifft oder wir im Einzelfall zur Beschaffung nicht verpflichtet sind.</p>
<p>(3) Der Eintritt unseres Lieferverzugs bestimmt sich nach den gesetzlichen Vorschriften. In jedem Fall ist aber eine Mahnung durch den Kunden erforderlich.</p>
<p>(4) Die Rechte des Kunden und unsere gesetzlichen Rechte, insbesondere bei einem Ausschluss der Leistungspflicht (z.B. aufgrund Unmöglichkeit oder Unzumutbarkeit der Leistung und/oder Nacherfüllung), bleiben unberührt.</p>
<h3>§ 4 Lieferung, Gefahrübergang, Abnahme, Annahmeverzug</h3>
<p>(1) Die Lieferung erfolgt ab Lager, wo auch der Erfüllungsort für die Lieferung und eine etwaige Nacherfüllung ist. Auf Verlangen und Kosten des Käufers wird die Ware an einen anderen Bestimmungsort versandt (Versendungskauf). Soweit nicht etwas anderes vereinbart ist, sind wir berechtigt, die Art der Versendung (insbesondere Transportunternehmen, Versandweg, Verpackung) selbst zu bestimmen.</p>
<p>(2) Die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der Ware geht spätestens mit der Übergabe auf den Käufer über. Beim Versendungskauf geht die Gefahr des zufälligen Untergangs und einer zufälligen Verschlechterung der Ware während des Transports für Unternehmer bereits mit Übergabe der Ware an den Spediteur/Frachtführer über; für Verbraucher geht die Gefahr erst über, wenn die Ware dem Verbraucher oder einem von diesem benannten Dritten (der nicht Frachtführer ist) übergeben wurde. Hat der Verbraucher den Beförderungsvertrag selbst ohne unsere Auswahlmöglichkeit beauftragt, so geht die Gefahr bereits mit Übergabe der Ware an den Beförderer über.</p>
<p>(3) Kommt der Käufer in Annahmeverzug, unterlässt er eine Mitwirkungshandlung oder verzögert sich unsere Lieferung aus anderen, vom Käufer zu vertretenden Gründen, so sind wir berechtigt, Ersatz des hieraus entstehenden Schadens einschließlich Mehraufwendungen (z.B. Lagerkosten) zu verlangen. Der Nachweis eines höheren Schadens und unsere gesetzlichen Ansprüche (insbesondere Ersatz von Mehraufwendungen, angemessene Entschädigung, Kündigung) bleiben unberührt.</p>
</div>
<div class="pageBreak"></div>
<div class="legal">
<h3>§ 5 Preise und Zahlungsbedingungen</h3>
<p>(1) Sofern im Einzelfall nichts anderes vereinbart ist, gelten unsere jeweils zum Zeitpunkt des Vertragsschlusses aktuellen Preise, und zwar ab Lager, zzgl. gesetzlicher Umsatzsteuer.</p>
<p>(2) Beim Versendungskauf trägt der Käufer die Transportkosten ab Lager und die Kosten einer ggf. vom Käufer gewünschten Transportversicherung. Sofern wir nicht die im Einzelfall tatsächlich entstandenen Transportkosten in Rechnung stellen, gilt eine Transportkostenpauschale (ausschließlich Transportversicherung) iHv 200 EUR als vereinbart. Etwaige Zölle, Gebühren, Steuern und sonstige öffentliche Abgaben trägt der Käufer.</p>
<p>(3) Der Kaufpreis ist fällig und zu zahlen innerhalb von 14 Tagen ab Rechnungsstellung und Lieferung bzw. Abnahme der Ware. Wir sind jedoch, auch im Rahmen einer laufenden Geschäftsbeziehung, jederzeit berechtigt, eine Lieferung ganz oder teilweise nur gegen Vorkasse durchzuführen. Einen entsprechenden Vorbehalt erklären wir spätestens mit der Auftragsbestätigung.</p>
<p>(4) Mit Ablauf vorstehender Zahlungsfrist kommt der Käufer in Verzug. Der Kaufpreis ist während des Verzugs zum jeweils geltenden gesetzlichen Verzugszinssatz zu verzinsen. Wir behalten uns die Geltendmachung eines weitergehenden Verzugsschadens vor. Gegenüber Kaufleuten bleibt unser Anspruch auf den kaufmännischen Fälligkeitszins (§ 352 UGB) unberührt.</p>
<p>(5) Dem Käufer stehen Aufrechnungs- oder Zurückbehaltungsrechte nur insoweit zu, als sein Anspruch rechtskräftig festgestellt oder unbestritten ist. Bei Mängeln der Lieferung bleiben die Gegenrechte des Käufers insbesondere gem. § 7 dieser AGB unberührt. Gegenüber Verbrauchern gilt diese Einschränkung nicht für Ansprüche, die in rechtlichem Zusammenhang mit ihrer Verbindlichkeit stehen.</p>
<p>(6) Wird nach Vertragsabschluss erkennbar, dass unser Anspruch auf Zahlung des Kaufpreises durch mangelnde Zahlungsfähigkeit oder drohende Zahlungsunfähigkeit des Käufers gefährdet ist etwa durch Antrag auf Eröffnung eines Insolvenzverfahrens oder vergleichbare Umstände , sind wir berechtigt, unsere Leistung zu verweigern und dem Käufer eine angemessene Frist zur Erbringung der Gegenleistung oder zur Sicherheitsleistung zu setzen. Nach fruchtlosem Ablauf dieser Frist sind wir berechtigt, vom Vertrag zurückzutreten.</p>
<h3>§ 6 Eigentumsvorbehalt</h3>
<p>(1) Bis zur vollständigen Bezahlung aller unserer gegenwärtigen und künftigen Forderungen aus dem Kaufvertrag und einer laufenden Geschäftsbeziehung (gesicherte Forderungen) behalten wir uns das Eigentum an den verkauften Waren vor.</p>
<p>(2) Die unter Eigentumsvorbehalt stehenden Waren dürfen vor vollständiger Bezahlung der gesicherten Forderungen weder an Dritte verpfändet, noch zur Sicherheit übereignet werden. Der Käufer hat uns unverzüglich schriftlich zu benachrichtigen, wenn ein Antrag auf Eröffnung eines Insolvenzverfahrens gestellt oder soweit Zugriffe Dritter (zB Pfändungen) auf die uns gehörenden Waren erfolgen.</p>
<p>(3) Bei vertragswidrigem Verhalten des Käufers, insbesondere bei Nichtzahlung des fälligen Kaufpreises, sind wir berechtigt, nach den gesetzlichen Vorschriften vom Vertrag zurückzutreten oder/und die Ware auf Grund des Eigentumsvorbehalts heraus zu verlangen. Das Herausgabeverlangen beinhaltet nicht zugleich die Erklärung des Rücktritts; wir sind vielmehr berechtigt, lediglich die Ware heraus zu verlangen und uns den Rücktritt vorzubehalten. Zahlt der Käufer den fälligen Kaufpreis nicht, dürfen wir diese Rechte nur geltend machen, wenn wir dem Käufer zuvor erfolglos eine angemessene Frist zur Zahlung gesetzt haben oder eine derartige Fristsetzung nach den gesetzlichen Vorschriften entbehrlich ist.</p>
</div>
<div class="pageBreak"></div>
<div class="legal"></div>
<h3>§ 7 Sachmängel</h3>
<p>(1) In dringenden Fällen, z.B. bei Gefährdung der Betriebssicherheit oder zur Abwehr unverhältnismäßiger Schäden, hat der Käufer das Recht, den Mangel selbst zu beseitigen und von uns Ersatz der hierzu objektiv erforderlichen (angemessenen) Aufwendungen zu verlangen. Von einer derartigen Selbstvornahme sind wir unverzüglich nach Möglichkeit vorher zu benachrichtigen. Das Selbstvornahmerecht besteht nicht, wenn wir berechtigt wären, eine entsprechende Nacherfüllung nach den gesetzlichen Vorschriften zu verweigern.</p>
<p>(2) Wenn die Nacherfüllung fehlgeschlagen ist oder eine für die Nacherfüllung vom Käufer zu setzende angemessene Frist erfolglos abgelaufen oder nach den gesetzlichen Vorschriften entbehrlich ist, kann der Käufer vom Kaufvertrag zurücktreten oder den Kaufpreis mindern. Bei einem unerheblichen Mangel besteht jedoch kein Rücktrittsrecht. Bei Verbrauchern gelten unbeschadet vorstehender Regelungen die gesetzlichen Gewährleistungsrechte uneingeschränkt. Insbesondere beträgt die Gewährleistungsfrist für Verbraucher zwei Jahre ab Übergabe der Ware.</p>
<p>(3) Ansprüche des Käufers auf Schadensersatz bzw. Ersatz vergeblicher Aufwendungen bestehen auch bei Mängeln nur nach Maßgabe von § 8 und sind im Übrigen ausgeschlossen.</p>
<h3>§ 8 Sonstige Haftung</h3>
<p>(1) Soweit sich aus diesen AGB einschließlich der nachfolgenden Bestimmungen nichts anderes ergibt, haften wir bei der Verletzung vertraglicher und außervertraglicher Pflichten nach den gesetzlichen Vorschriften.</p>
<p>(2) Wir haften gleich aus welchem Rechtsgrund im Rahmen der Verschuldenshaftung bei Vorsatz und grober Fahrlässigkeit. Bei leichter Fahrlässigkeit haften wir nur a) für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, b) für Schäden aus der Verletzung wesentlicher Vertragspflichten (d.h. solcher Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Vertragspartner regelmäßig vertrauen darf). In diesem Fall ist unsere Haftung jedoch auf den Ersatz des typischen, vorhersehbaren Schadens begrenzt.</p>
<p>(3) Die vorstehenden Haftungsbeschränkungen gelten auch zugunsten Dritter sowie für Pflichtverletzungen durch Personen, deren Verschulden uns nach den gesetzlichen Vorschriften zuzurechnen ist. Sie gelten nicht, soweit wir einen Mangel arglistig verschwiegen oder eine Garantie für die Beschaffenheit der Ware übernommen haben sowie bei Ansprüchen nach dem Produkthaftungsgesetz.</p>
<p>(4) Soweit gesetzlich zulässig, haften wir nicht für mittelbare Schäden, Folgeschäden oder entgangenen Gewinn. Gegenüber Verbrauchern gilt dieser Haftungsausschluss nicht, soweit ein kausaler Zusammenhang mit der Verletzung wesentlicher Vertragspflichten besteht.</p>
<p>(5) Ein Rücktritt oder eine Kündigung wegen Pflichtverletzung, die nicht auf einem Mangel der Ware beruht, ist nur zulässig, wenn wir diese zu vertreten haben. Ein darüber hinausgehendes freies Rücktritts- oder Kündigungsrecht des Käufers wird soweit rechtlich zulässig ausgeschlossen.</p>
<p>(6) Die zwingenden Bestimmungen des Produkthaftungsgesetzes sowie die Haftung für vorsätzliches oder grob fahrlässiges Verhalten bleiben von den vorstehenden Regelungen unberührt.</p>
<h3>§ 9 Verjährung</h3>
<p>(1) Bei Verträgen mit Unternehmern im Sinne des § 1 KSchG wird die gesetzliche Gewährleistungsfrist für bewegliche Sachen gemäß § 933 ABGB auf ein Jahr ab Übergabe verkürzt. Dies gilt nicht bei Arglist oder bei Übernahme einer Garantie für die Beschaffenheit der Ware.</p>
<p>(2) Die in Abs. 1 genannte Fristverkürzung gilt nicht für Ansprüche des Käufers wegen Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, bei grob fahrlässigem oder vorsätzlichem Verhalten oder bei Ansprüchen nach dem Produkthaftungsgesetz.</p>
<p>(3) Schadenersatzansprüche wegen Mängeln (§ 933a ABGB) verjähren unabhängig von einer verkürzten Gewährleistungsfrist innerhalb der gesetzlichen Frist von drei Jahren ab Kenntnis von Schaden und Schädiger.</p>
<p>(4) Gegenüber Verbrauchern gelten uneingeschränkt die gesetzlichen Gewährleistungs- und Verjährungsfristen (§§ 922 ff ABGB, § 9 KSchG).</p>
<h3>§ 10 Rechtswahl und Gerichtsstand</h3>
<p>(1) Für sämtliche Rechtsverhältnisse zwischen uns und dem Käufer gilt ausschließlich das materielle Recht der Republik Österreich unter Ausschluss des UN-Kaufrechts (CISG) und sonstiger internationaler Kollisionsnormen, soweit zwingende Verbraucherschutzvorschriften nicht entgegenstehen.</p>
<p>(2) Ist der Käufer Unternehmer im Sinne des § 1 KSchG, so wird für alle Streitigkeiten aus oder im Zusammenhang mit diesem Vertrag einschließlich seiner Gültigkeit und Durchführung das sachlich zuständige Gericht in Graz vereinbart. Wir sind jedoch berechtigt, auch am allgemeinen Gerichtsstand des Käufers oder an einem sonst gesetzlich zulässigen Gerichtsstand Klage zu erheben.</p>
<p>(3) Gegenüber Verbrauchern gelten die gesetzlichen Gerichtsstandregelungen. Eine abweichende Gerichtsstandsvereinbarung mit Verbrauchern wird nicht getroffen.</p>
<h3>§ 11 Schlussbestimmungen</h3>
<p>Sollten einzelne Bestimmungen dieses Vertrages unwirksam oder nichtig sein oder werden, so berührt dies die Gültigkeit der übrigen Bestimmungen dieses Vertrages nicht. Die Parteien verpflichten sich, unwirksame oder nichtige Bestimmungen durch neue Bestimmungen zu ersetzen, die dem in den unwirksamen oder nichtigen Bestimmungen enthaltenen wirtschaftlichen Regelungsgehalt in rechtlich zulässiger Weise gerecht werden. Entsprechendes gilt, wenn sich in dem Vertrag eine Lücke herausstellen sollte. Zur Ausfüllung der Lücke verpflichten sich die Parteien auf die Etablierung angemessener Regelungen in diesem Vertrag hinzuwirken, die dem am nächsten kommen, was die Vertragsschließenden nach dem Sinn und Zweck dieses Vertrages bestimmt hätten, wenn der Punkt von ihnen bedacht worden wäre.</p>
<p class="muted"><strong>Stand der Allgemeinen Geschäftsbedingungen Profit Planet Kaffee-Service: 01.08.2025</strong></p>
</div>
<div class="pageBreak"></div>
<div class="legal">
<h2>Informationen für Verbraucher über das Rücktrittsrecht (Widerrufsrecht)</h2>
<p><strong>Widerrufsrecht:</strong> Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem Sie (oder ein von Ihnen benannter Dritter, der nicht der Beförderer ist) die erste Ware im Rahmen dieses Vertrages in Besitz genommen haben. Bei einem Vertrag über Dienstleistungen (z.B. Miete einer Kaffeemaschine) beginnt die Widerrufsfrist mit dem Tag des Vertragsabschlusses.</p>
<p><strong>Ausübung:</strong> Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (Profit Planet GmbH, Liebenauer Hauptstraße 82c, 8041 Graz, E-Mail: office@profit-planet.com) mittels einer eindeutigen Erklärung (z.B. ein mit der Post versandter Brief oder E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das unten angefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.</p>
<p><strong>Folgen:</strong> Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben einschließlich etwaiger Lieferkosten (mit Ausnahme jener zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene günstigste Standardlieferung gewählt haben) unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart. Ihnen werden wegen dieser Rückzahlung keine Entgelte berechnet.</p>
<p>Handelt es sich bei dem widerrufenen Vertrag um einen Kaufvertrag über Waren, können wir die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder Sie den Nachweis erbracht haben, dass Sie die Waren abgesandt haben je nachdem, welcher Zeitpunkt früher eintritt. Sie haben die Waren in diesem Fall unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem Sie uns über den Widerruf dieses Vertrags unterrichten, an uns zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn Sie die Waren vor Ablauf der Frist von vierzehn Tagen absenden. Sie tragen die unmittelbaren Kosten der Rücksendung der Waren. Sie müssen für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.</p>
<p>Haben Sie verlangt, dass eine Dienstleistung (oder die regelmäßige Lieferung von Waren) während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt der Widerrufsausübung bereits erbrachten Leistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Leistungen entspricht.</p>
<h2>Muster-Widerrufsformular</h2>
<div class="box">
<p class="para">(Wenn Sie den Vertrag widerrufen wollen, können Sie dieses Formular ausfüllen und an uns zurücksenden.)</p>
<p class="para"> An Profit Planet GmbH, Liebenauer Hauptstraße 82c, 8041 Graz, E-Mail: office@profit-planet.com:</p>
<p class="para"> Hiermit widerrufe(n) ich/wir ( <span class="fill">&nbsp;</span> )</p>
<p class="para">den von mir/uns ( <span class="fill">&nbsp;</span> ) abgeschlossenen Vertrag über den Kauf der folgenden Ware(n)/die Erbringung der folgenden Dienstleistung</p>
<p class="para"> Bestellt am ( <span class="fill">&nbsp;</span> ) / erhalten am ( <span class="fill">&nbsp;</span> )</p>
<p class="para"> Name des/der Verbraucher(s): <span class="fill wide">&nbsp;</span></p>
<p class="para"> Anschrift des/der Verbraucher(s): <span class="fill full">&nbsp;</span></p>
<p class="para"> Datum: <span class="fill">&nbsp;</span></p>
<p class="para"> Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier): <span class="fill wide">&nbsp;</span></p>
</div>
</div>
</div>
</body>
</html>

View File

@ -3,6 +3,33 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import useContractManagement from '../hooks/useContractManagement' import useContractManagement from '../hooks/useContractManagement'
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(new Error('Failed to read file'))
reader.onload = () => {
const result = reader.result
if (typeof result === 'string') resolve(result)
else reject(new Error('Invalid file result'))
}
reader.readAsDataURL(file)
})
}
function summarizeForLog(payload: Record<string, any>) {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(payload)) {
if (typeof v === 'string' && (k.toLowerCase().includes('base64') || k.toLowerCase().includes('qr_code'))) {
out[k] = { kind: 'base64', len: v.length, head: v.slice(0, 32) }
} else if (typeof v === 'string' && v.length > 200) {
out[k] = { kind: 'string', len: v.length, head: v.slice(0, 32) }
} else {
out[k] = v
}
}
return out
}
export default function CompanySettingsPanel() { export default function CompanySettingsPanel() {
const { getCompanySettings, updateCompanySettings } = useContractManagement() const { getCompanySettings, updateCompanySettings } = useContractManagement()
@ -12,9 +39,14 @@ export default function CompanySettingsPanel() {
company_postal_city: '', company_postal_city: '',
company_country: '', company_country: '',
}) })
const [hasQr60, setHasQr60] = useState(false)
const [hasQr120, setHasQr120] = useState(false)
const [qr60DataUrl, setQr60DataUrl] = useState<string>('')
const [qr120DataUrl, setQr120DataUrl] = useState<string>('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [saveError, setSaveError] = useState<string>('')
useEffect(() => { useEffect(() => {
getCompanySettings() getCompanySettings()
@ -25,6 +57,11 @@ export default function CompanySettingsPanel() {
company_postal_city: data.company_postal_city || '', company_postal_city: data.company_postal_city || '',
company_country: data.company_country || '', company_country: data.company_country || '',
}) })
const qr60 = (data as any)?.qr_code_60_base64 ?? (data as any)?.qrCode60Base64
const qr120 = (data as any)?.qr_code_120_base64 ?? (data as any)?.qrCode120Base64
setHasQr60(!!qr60)
setHasQr120(!!qr120)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)) .finally(() => setLoading(false))
@ -40,17 +77,81 @@ export default function CompanySettingsPanel() {
e.preventDefault() e.preventDefault()
setSaving(true) setSaving(true)
setSaved(false) setSaved(false)
setSaveError('')
try { try {
await updateCompanySettings(form) // IMPORTANT: send `payload` (full strings), not the redacted log view.
const payload: any = { ...form }
if (qr60DataUrl) payload.qr_code_60_base64 = qr60DataUrl
if (qr120DataUrl) payload.qr_code_120_base64 = qr120DataUrl
// For logging only (redacted); never send this object.
const logPayload: any = summarizeForLog(payload)
try {
const qr60 = payload.qr_code_60_base64
const qr120 = payload.qr_code_120_base64
console.info('[CompanySettingsPanel] updateCompanySettings payload', {
logPayload,
keys: Object.keys(payload),
jsonLength: JSON.stringify(payload).length,
qrFieldTypes: {
qr_code_60_base64: qr60 ? typeof qr60 : null,
qr_code_120_base64: qr120 ? typeof qr120 : null,
},
qrFieldLengths: {
qr_code_60_base64: typeof qr60 === 'string' ? qr60.length : null,
qr_code_120_base64: typeof qr120 === 'string' ? qr120.length : null,
},
})
if (qr60 && typeof qr60 !== 'string') console.warn('[CompanySettingsPanel] qr_code_60_base64 is not a string!', qr60)
if (qr120 && typeof qr120 !== 'string') console.warn('[CompanySettingsPanel] qr_code_120_base64 is not a string!', qr120)
} catch {}
await updateCompanySettings(payload)
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 3000) setTimeout(() => setSaved(false), 3000)
} catch { } catch {
// silent setSaveError('Could not save settings.')
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
const handleQrUpload = async (which: '60' | '120', file: File | null) => {
setSaved(false)
setSaveError('')
if (!file) return
// Backend accepts 10MB JSON, but base64 expands the payload.
// Keep a conservative limit to avoid 413 Payload Too Large.
const MAX_FILE_BYTES = 7_000_000
if (file.size > MAX_FILE_BYTES) {
setSaveError('QR image is too large. Please upload a smaller PNG.')
return
}
if (file.type && file.type !== 'image/png') {
setSaveError('Please upload a PNG file for the QR code.')
return
}
try {
const dataUrl = await fileToDataUrl(file)
// Normalize to raw base64, to match other endpoints (e.g. company stamp upload)
const m = dataUrl.match(/^data:(.+?);base64,(.*)$/)
const base64 = m ? m[2] : dataUrl
if (which === '60') {
setQr60DataUrl(base64)
setHasQr60(true)
} else {
setQr120DataUrl(base64)
setHasQr120(true)
}
} catch {
setSaveError('Could not read QR image file.')
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center gap-2 text-sm text-gray-500 py-4"> <div className="flex items-center gap-2 text-sm text-gray-500 py-4">
@ -121,6 +222,38 @@ export default function CompanySettingsPanel() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Invoice QR Code (60 pcs)</label>
<input
type="file"
accept="image/png"
onChange={e => handleQrUpload('60', e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100"
/>
<div className="mt-1 text-xs text-gray-500">
{qr60DataUrl ? 'Selected (will be saved on Save)' : hasQr60 ? 'Already uploaded' : 'Not uploaded'}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Invoice QR Code (120 pcs)</label>
<input
type="file"
accept="image/png"
onChange={e => handleQrUpload('120', e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100"
/>
<div className="mt-1 text-xs text-gray-500">
{qr120DataUrl ? 'Selected (will be saved on Save)' : hasQr120 ? 'Already uploaded' : 'Not uploaded'}
</div>
</div>
</div>
{saveError && (
<div className="text-sm text-red-600 font-medium">{saveError}</div>
)}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="submit" type="submit"

View File

@ -19,7 +19,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
const [lang, setLang] = useState<'en' | 'de'>('en'); const [lang, setLang] = useState<'en' | 'de'>('en');
const [type, setType] = useState<'contract' | 'invoice' | 'other'>('contract'); const [type, setType] = useState<'contract' | 'invoice' | 'other'>('contract');
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract'); const [contractType, setContractType] = useState<'contract' | 'gdpr' | 'abo'>('contract');
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal'); const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
const [description, setDescription] = useState<string>(''); const [description, setDescription] = useState<string>('');
@ -55,7 +55,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description` setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
setLang((tpl.lang as any) || 'en'); setLang((tpl.lang as any) || 'en');
setType(((tpl.type as any) || 'contract') as 'contract' | 'invoice' | 'other'); setType(((tpl.type as any) || 'contract') as 'contract' | 'invoice' | 'other');
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr'); setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr' | 'abo');
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both'); setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
setEditingMeta({ setEditingMeta({
id: editingTemplateId, id: editingTemplateId,
@ -163,6 +163,20 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
return; return;
} }
try {
console.info('[ContractEditor] doSave()', {
editingTemplateId: editingTemplateId ?? null,
publish,
name: name.trim(),
type,
contract_type: type === 'contract' ? contractType : null,
lang,
user_type: type === 'invoice' ? 'both' : userType,
descriptionLength: description ? description.length : 0,
htmlLength: html.length,
});
} catch {}
setSaving(true); setSaving(true);
setStatusMsg(null); setStatusMsg(null);
@ -216,7 +230,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
const save = async (publish: boolean) => { const save = async (publish: boolean) => {
if (publish) { if (publish) {
let kind = type === 'contract' let kind = type === 'contract'
? (contractType === 'gdpr' ? 'GDPR' : 'Contract') ? (contractType === 'gdpr' ? 'GDPR' : contractType === 'abo' ? 'ABO' : 'Contract')
: type === 'invoice' : type === 'invoice'
? 'Invoice' ? 'Invoice'
: 'Other'; : 'Other';
@ -302,12 +316,13 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
{type === 'contract' && ( {type === 'contract' && (
<select <select
value={contractType} value={contractType}
onChange={(e) => setContractType(e.target.value as 'contract' | 'gdpr')} onChange={(e) => setContractType(e.target.value as 'contract' | 'gdpr' | 'abo')}
required required
className="w-full sm:w-40 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow" className="w-full sm:w-40 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
> >
<option value="contract">Contract</option> <option value="contract">Contract</option>
<option value="gdpr">GDPR</option> <option value="gdpr">GDPR</option>
<option value="abo">ABO</option>
</select> </select>
)} )}
{type !== 'invoice' && ( {type !== 'invoice' && (

View File

@ -100,7 +100,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
const tpl = items.find((i) => i.id === id); const tpl = items.find((i) => i.id === id);
if (tpl) { if (tpl) {
const kind = tpl.type === 'contract' const kind = tpl.type === 'contract'
? (tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract') ? (tpl.contract_type === 'gdpr' ? 'GDPR' : tpl.contract_type === 'abo' ? 'ABO' : 'Contract')
: tpl.type === 'invoice' : tpl.type === 'invoice'
? 'Invoice' ? 'Invoice'
: 'Other'; : 'Other';
@ -172,7 +172,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
)} )}
{c.type === 'contract' && ( {c.type === 'contract' && (
<Pill className="bg-indigo-50 text-indigo-800 border-indigo-200"> <Pill className="bg-indigo-50 text-indigo-800 border-indigo-200">
{c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'} {c.contract_type === 'gdpr' ? 'GDPR' : c.contract_type === 'abo' ? 'ABO' : 'Contract'}
</Pill> </Pill>
)} )}
{c.user_type && c.type !== 'invoice' && ( {c.user_type && c.type !== 'invoice' && (

View File

@ -5,7 +5,7 @@ export type DocumentTemplate = {
id: string; id: string;
name: string; name: string;
type?: string; type?: string;
contract_type?: 'contract' | 'gdpr' | null | string; contract_type?: 'contract' | 'gdpr' | 'abo' | null | string;
lang?: 'en' | 'de' | string; lang?: 'en' | 'de' | string;
user_type?: 'personal' | 'company' | 'both' | string; user_type?: 'personal' | 'company' | 'both' | string;
state?: 'active' | 'inactive' | string; state?: 'active' | 'inactive' | string;
@ -28,10 +28,62 @@ export type CompanyStamp = {
type Json = Record<string, any>; type Json = Record<string, any>;
function redactLongOrBase64ish(value: any) {
if (typeof value !== 'string') return value;
const len = value.length;
const head = value.slice(0, 32);
// If it's long, treat it as potentially sensitive / base64 and only log metadata.
if (len > 200) {
return { kind: 'string', len, head };
}
return value;
}
function redactJsonForLogs(input: any): any {
if (!input || typeof input !== 'object') return redactLongOrBase64ish(input);
if (Array.isArray(input)) return input.map(redactJsonForLogs);
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(input)) {
if (typeof v === 'string' && (k.toLowerCase().includes('base64') || k.toLowerCase().includes('qr_code'))) {
out[k] = { kind: 'base64', len: v.length, head: v.slice(0, 32) };
} else {
out[k] = redactJsonForLogs(v);
}
}
return out;
}
function isFormData(body: any): body is FormData { function isFormData(body: any): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData; return typeof FormData !== 'undefined' && body instanceof FormData;
} }
function safeDescribeBody(body: any) {
if (!body) return null;
if (isFormData(body)) {
const entries: Record<string, any> = {};
try {
for (const [k, v] of body.entries()) {
if (typeof File !== 'undefined' && v instanceof File) {
entries[k] = { kind: 'File', name: v.name, type: v.type, size: v.size };
} else {
// Strings only for our current usage.
entries[k] = v;
}
}
} catch (e: any) {
return { kind: 'FormData', error: e?.message || String(e) };
}
return { kind: 'FormData', entries };
}
if (typeof body === 'string') {
return { kind: 'string', preview: body.slice(0, 500), length: body.length };
}
// Avoid dumping arbitrary objects (could be huge / sensitive)
return { kind: typeof body };
}
export default function useContractManagement() { export default function useContractManagement() {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''; const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
const getState = useAuthStore.getState; const getState = useAuthStore.getState;
@ -57,16 +109,32 @@ export default function useContractManagement() {
headers['Content-Type'] = headers['Content-Type'] || 'application/json'; headers['Content-Type'] = headers['Content-Type'] || 'application/json';
} }
const url = `${base}${path}`;
const method = init.method || 'GET';
// Debug (safe) // Debug (safe)
try { try {
console.debug('[CM] fetch ->', { console.debug('[CM] fetch ->', {
url: `${base}${path}`, url,
method: init.method || 'GET', method,
hasAuth: !!token, hasAuth: !!token,
tokenPrefix: token ? `${token.substring(0, 12)}...` : null,
}); });
} catch {} } catch {}
// EXTRA debug for document-template calls: show what we send (safe metadata only)
if (path.startsWith('/api/document-templates')) {
try {
const safeHeaders = { ...headers } as Record<string, any>;
if (safeHeaders.Authorization) safeHeaders.Authorization = '[redacted]';
console.info('[CM][document-templates] request', {
url,
method,
headers: safeHeaders,
body: safeDescribeBody(init.body),
});
} catch {}
}
// Include cookies + Authorization on all requests // Include cookies + Authorization on all requests
const res = await fetch(`${base}${path}`, { const res = await fetch(`${base}${path}`, {
credentials: 'include', credentials: 'include',
@ -113,7 +181,7 @@ export default function useContractManagement() {
return {} as T; return {} as T;
} }
}, },
[base] [base, getState]
); );
// Document templates // Document templates
@ -154,7 +222,7 @@ export default function useContractManagement() {
file: File | Blob; file: File | Blob;
name: string; name: string;
type: string; type: string;
contract_type?: 'contract' | 'gdpr'; contract_type?: 'contract' | 'gdpr' | 'abo';
lang: 'en' | 'de' | string; lang: 'en' | 'de' | string;
description?: string; description?: string;
user_type?: 'personal' | 'company' | 'both'; user_type?: 'personal' | 'company' | 'both';
@ -171,6 +239,19 @@ export default function useContractManagement() {
if (payload.description) fd.append('description', payload.description); if (payload.description) fd.append('description', payload.description);
fd.append('user_type', (payload.user_type ?? 'both')); fd.append('user_type', (payload.user_type ?? 'both'));
try {
console.info('[CM][document-templates] uploadTemplate()', {
name: payload.name,
type: payload.type,
contract_type: payload.contract_type,
willSendContractType: payload.type === 'contract' && Boolean(payload.contract_type),
lang: payload.lang,
user_type: payload.user_type ?? 'both',
descriptionLength: payload.description ? payload.description.length : 0,
file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null,
});
} catch {}
return authorizedFetch<DocumentTemplate>('/api/document-templates', { method: 'POST', body: fd }); return authorizedFetch<DocumentTemplate>('/api/document-templates', { method: 'POST', body: fd });
}, [authorizedFetch]); }, [authorizedFetch]);
@ -178,7 +259,7 @@ export default function useContractManagement() {
file?: File | Blob; file?: File | Blob;
name?: string; name?: string;
type?: string; type?: string;
contract_type?: 'contract' | 'gdpr'; contract_type?: 'contract' | 'gdpr' | 'abo';
lang?: 'en' | 'de' | string; lang?: 'en' | 'de' | string;
description?: string; description?: string;
user_type?: 'personal' | 'company' | 'both'; user_type?: 'personal' | 'company' | 'both';
@ -205,7 +286,7 @@ export default function useContractManagement() {
file: File | Blob; file: File | Blob;
name?: string; name?: string;
type?: string; type?: string;
contract_type?: 'contract' | 'gdpr'; contract_type?: 'contract' | 'gdpr' | 'abo';
lang?: 'en' | 'de' | string; lang?: 'en' | 'de' | string;
description?: string; description?: string;
user_type?: 'personal' | 'company' | 'both'; user_type?: 'personal' | 'company' | 'both';
@ -224,6 +305,20 @@ export default function useContractManagement() {
if (payload.user_type !== undefined) fd.append('user_type', payload.user_type); if (payload.user_type !== undefined) fd.append('user_type', payload.user_type);
if (payload.state !== undefined) fd.append('state', payload.state); if (payload.state !== undefined) fd.append('state', payload.state);
try {
console.info('[CM][document-templates] reviseTemplate()', {
id,
name: payload.name,
type: payload.type,
contract_type: payload.contract_type,
lang: payload.lang,
user_type: payload.user_type,
state: payload.state,
descriptionLength: payload.description ? payload.description.length : 0,
file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null,
});
} catch {}
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/revise`, { method: 'POST', body: fd }); return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/revise`, { method: 'POST', body: fd });
}, [authorizedFetch]); }, [authorizedFetch]);
@ -445,18 +540,57 @@ export default function useContractManagement() {
}, [authorizedFetch]); }, [authorizedFetch]);
// Company settings (invoice address info) // Company settings (invoice address info)
type CompanySettings = {
company_name?: string
company_street?: string
company_postal_city?: string
company_country?: string
// NEW: QR codes for invoices (base64 or data URL)
qr_code_60_base64?: string | null
qr_code_120_base64?: string | null
// NEW: allow camelCase too (backend supports both)
qrCode60Base64?: string | null
qrCode120Base64?: string | null
}
const getCompanySettings = useCallback(async () => { const getCompanySettings = useCallback(async () => {
return authorizedFetch<{ company_name: string; company_street: string; company_postal_city: string; company_country: string }>( return authorizedFetch<CompanySettings>('/api/admin/company-settings', { method: 'GET' });
'/api/admin/company-settings', { method: 'GET' }
);
}, [authorizedFetch]); }, [authorizedFetch]);
const updateCompanySettings = useCallback(async (data: { const updateCompanySettings = useCallback(async (data: Partial<CompanySettings>) => {
company_name: string; company_street: string; company_postal_city: string; company_country: string; // Debug request body in browser console (redacts base64 values)
}) => { try {
return authorizedFetch<{ company_name: string; company_street: string; company_postal_city: string; company_country: string }>( // IMPORTANT: `data` is the real payload object; `redacted` is for logs only.
'/api/admin/company-settings', { method: 'PUT', body: JSON.stringify(data) } const json = JSON.stringify(data);
); const redacted = redactJsonForLogs(data);
const qr60 = (data as any)?.qr_code_60_base64 ?? (data as any)?.qrCode60Base64;
const qr120 = (data as any)?.qr_code_120_base64 ?? (data as any)?.qrCode120Base64;
console.info('[CM][company-settings] PUT body', {
redacted,
jsonLength: json.length,
keys: Object.keys(data || {}),
qrFieldTypes: {
qr_code_60_base64: qr60 ? typeof qr60 : null,
qr_code_120_base64: qr120 ? typeof qr120 : null,
},
qrFieldLengths: {
qr_code_60_base64: typeof qr60 === 'string' ? qr60.length : null,
qr_code_120_base64: typeof qr120 === 'string' ? qr120.length : null,
},
});
if (qr60 && typeof qr60 !== 'string') {
console.warn('[CM][company-settings] qr_code_60_base64 is not a string!', qr60);
}
if (qr120 && typeof qr120 !== 'string') {
console.warn('[CM][company-settings] qr_code_120_base64 is not a string!', qr120);
}
} catch {}
return authorizedFetch<CompanySettings>('/api/admin/company-settings', {
method: 'PUT',
body: JSON.stringify(data),
});
}, [authorizedFetch]); }, [authorizedFetch]);
return { return {

View File

@ -0,0 +1,272 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { authFetch } from '../../../utils/authFetch'
import {
DEFAULT_DASHBOARD_PLATFORMS,
type DashboardPlatform,
type DashboardPlatformColorClass,
type DashboardPlatformIconName
} from '../../../utils/dashboardPlatforms'
type BackendPlatform = {
id: string | number
title: string
href: string
description?: string | null
icon?: DashboardPlatformIconName | null
color?: DashboardPlatformColorClass | null
state?: boolean
disabled?: boolean
disabledText?: string | null
sortOrder?: number | null
}
export type PlatformRow = DashboardPlatform & {
_isNew?: boolean
}
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const FIXED_ICON: DashboardPlatformIconName = 'LinkIcon'
function createId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return (crypto as any).randomUUID()
}
return `platform_${Date.now()}_${Math.random().toString(16).slice(2)}`
}
function isValidHref(href: string): boolean {
const v = href.trim()
if (!v) return false
return v.startsWith('/') || v.startsWith('http://') || v.startsWith('https://')
}
function toRow(p: BackendPlatform): PlatformRow {
return {
id: String(p.id),
title: typeof p.title === 'string' ? p.title : '',
description: typeof p.description === 'string' ? p.description : '',
href: typeof p.href === 'string' ? p.href : '',
icon: FIXED_ICON,
color: (p.color as DashboardPlatformColorClass) || ('bg-blue-500' as DashboardPlatformColorClass),
isActive: typeof p.state === 'boolean' ? p.state : true,
disabled: typeof p.disabled === 'boolean' ? p.disabled : false,
disabledText: typeof p.disabledText === 'string' ? p.disabledText : undefined
}
}
function toPayload(p: PlatformRow) {
return {
title: p.title,
href: p.href,
description: p.description ?? '',
icon: FIXED_ICON,
color: p.color,
state: Boolean(p.isActive),
disabled: Boolean(p.disabled),
disabledText: p.disabledText ?? ''
}
}
function normalizeForCompare(p: PlatformRow) {
return {
title: (p.title || '').trim(),
href: (p.href || '').trim(),
description: (p.description || '').trim(),
icon: FIXED_ICON,
color: p.color,
isActive: Boolean(p.isActive),
disabled: Boolean(p.disabled),
disabledText: (p.disabledText || '').trim()
}
}
function isChanged(p: PlatformRow, baselineById: Record<string, PlatformRow>): boolean {
if (p._isNew) return true
const baseline = baselineById[p.id]
if (!baseline) return true
const a = normalizeForCompare(p)
const b = normalizeForCompare(baseline)
return (
a.title !== b.title ||
a.href !== b.href ||
a.description !== b.description ||
a.color !== b.color ||
a.isActive !== b.isActive ||
a.disabled !== b.disabled ||
a.disabledText !== b.disabledText
)
}
function forceLinkIcon(rows: DashboardPlatform[]): PlatformRow[] {
return rows.map(r => ({ ...r, icon: FIXED_ICON })) as PlatformRow[]
}
export function useAdminDashboardPlatforms() {
const [platforms, setPlatforms] = useState<PlatformRow[]>(forceLinkIcon(DEFAULT_DASHBOARD_PLATFORMS))
const [baselineById, setBaselineById] = useState<Record<string, PlatformRow>>({})
const [savedAt, setSavedAt] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const hasValidationErrors = useMemo(() => {
return platforms.some(p => !p.title.trim() || !p.href.trim() || !isValidHref(p.href))
}, [platforms])
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms`, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include'
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const json = (await res.json().catch(() => null)) as unknown
const list = Array.isArray(json) ? (json as BackendPlatform[]) : []
const rows = list.map(toRow)
setPlatforms(rows)
setBaselineById(Object.fromEntries(rows.map(r => [r.id, r])))
} catch (e: any) {
setError(e?.message || 'Failed to load platforms')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const addPlatform = useCallback((): string => {
const id = createId()
setPlatforms(prev => [
...prev,
{
id,
title: 'New Platform',
description: '',
href: '/dashboard',
icon: FIXED_ICON,
color: 'bg-blue-500' as DashboardPlatformColorClass,
isActive: true,
disabled: false,
_isNew: true
}
])
return id
}, [])
const updatePlatform = useCallback((id: string, patch: Partial<DashboardPlatform>) => {
setPlatforms(prev => prev.map(p => (p.id === id ? { ...p, ...patch, icon: FIXED_ICON } : p)))
}, [])
const removeNewPlatform = useCallback((id: string) => {
setPlatforms(prev => prev.filter(p => p.id !== id))
}, [])
const setPlatformState = useCallback(async (platform: PlatformRow, state: boolean) => {
if (platform._isNew) {
updatePlatform(platform.id, { isActive: state })
return
}
const prev = platform.isActive
updatePlatform(platform.id, { isActive: state })
try {
const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms/${platform.id}/state`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
credentials: 'include',
body: JSON.stringify({ state })
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
setBaselineById(prevMap => {
const prevBaseline = prevMap[platform.id]
if (!prevBaseline) return prevMap
return { ...prevMap, [platform.id]: { ...prevBaseline, isActive: state, icon: FIXED_ICON } }
})
} catch (e: any) {
setError(e?.message || 'Failed to update platform state')
updatePlatform(platform.id, { isActive: prev })
}
}, [updatePlatform])
const save = useCallback(async () => {
setSaving(true)
setError(null)
try {
// 1) Create new platforms
for (const platform of platforms.filter(p => p._isNew)) {
const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
credentials: 'include',
body: JSON.stringify(toPayload(platform))
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
}
// 2) Update changed existing platforms
for (const platform of platforms.filter(p => !p._isNew && isChanged(p, baselineById))) {
const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms/${platform.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
credentials: 'include',
body: JSON.stringify(toPayload(platform))
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
}
// 3) Re-fetch list for canonical state/sort
await reload()
setSavedAt(Date.now())
} catch (e: any) {
setError(e?.message || 'Save failed')
} finally {
setSaving(false)
}
}, [baselineById, platforms, reload])
return {
platforms,
loading,
saving,
error,
savedAt,
hasValidationErrors,
addPlatform,
updatePlatform,
removeNewPlatform,
setPlatformState,
save,
isValidHref,
}
}

View File

@ -0,0 +1,259 @@
'use client'
import { useState } from 'react'
import PageLayout from '../../components/PageLayout'
import {
DASHBOARD_PLATFORMS_COLOR_OPTIONS,
type DashboardPlatform,
type DashboardPlatformColorClass
} from '../../utils/dashboardPlatforms'
import { PlusIcon, TrashIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'
import { useAdminDashboardPlatforms, type PlatformRow } from './hooks/useAdminDashboardPlatforms'
export default function AdminDashboardManagementPage() {
const {
platforms,
loading,
saving,
error,
savedAt,
hasValidationErrors,
addPlatform,
updatePlatform,
removeNewPlatform,
setPlatformState,
save,
isValidHref,
} = useAdminDashboardPlatforms()
const [openById, setOpenById] = useState<Record<string, boolean>>({})
const toggleOpen = (id: string) => {
setOpenById(prev => ({ ...prev, [id]: !prev[id] }))
}
const addAndOpen = () => {
const id = addPlatform()
setOpenById(prev => ({ ...prev, [id]: true }))
}
const isOpen = (p: PlatformRow) => Boolean(openById[p.id] ?? p._isNew)
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 md:py-10">
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
<header className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-3xl sm:text-4xl font-extrabold text-blue-900 tracking-tight">Dashboard Management</h1>
<p className="text-sm sm:text-base text-blue-700 mt-2">
Manage the Platforms cards shown on the user dashboard.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<button
type="button"
onClick={addAndOpen}
disabled={loading || saving}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 text-white px-4 py-2 text-sm font-semibold hover:bg-blue-800"
>
<PlusIcon className="h-5 w-5" />
Add Platform
</button>
<button
type="button"
onClick={save}
disabled={hasValidationErrors || loading || saving}
className={
hasValidationErrors || loading || saving
? 'inline-flex items-center justify-center gap-2 rounded-lg bg-gray-300 text-gray-600 px-4 py-2 text-sm font-semibold cursor-not-allowed'
: 'inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 text-white px-4 py-2 text-sm font-semibold hover:bg-emerald-500'
}
>
<CheckIcon className="h-5 w-5" />
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</header>
{error && (
<div className="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</div>
)}
{savedAt && (
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
Saved at {new Date(savedAt).toLocaleTimeString('de-DE')}
</div>
)}
{hasValidationErrors && (
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Please ensure every platform has a title and a valid link (must start with / or http(s)://”).
</div>
)}
<div className="grid grid-cols-1 gap-4">
{loading && (
<div className="rounded-2xl border border-gray-200 bg-white p-6 text-sm text-gray-600">
Loading
</div>
)}
{!loading && platforms.map(platform => (
<div key={platform.id} className="rounded-2xl bg-white border border-gray-100 shadow p-4 sm:p-5">
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-base font-semibold text-gray-900 truncate">{platform.title}</div>
<div className="text-xs text-gray-500 truncate">{platform.href}</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => toggleOpen(platform.id)}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white text-gray-800 px-3 py-2 text-xs font-semibold hover:bg-gray-50"
>
{isOpen(platform) ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
{isOpen(platform) ? 'Close' : 'Edit'}
</button>
<button
type="button"
onClick={async () => {
if (platform._isNew) {
removeNewPlatform(platform.id)
return
}
await setPlatformState(platform, false)
}}
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-xs font-semibold hover:bg-red-100"
>
<TrashIcon className="h-4 w-4" />
{platform._isNew ? 'Remove' : 'Deactivate'}
</button>
</div>
</div>
{isOpen(platform) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="block">
<div className="text-xs font-semibold text-gray-700">Title</div>
<input
value={platform.title}
onChange={e => updatePlatform(platform.id, { title: e.target.value })}
disabled={saving}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block">
<div className="text-xs font-semibold text-gray-700">Description</div>
<input
value={platform.description}
onChange={e => updatePlatform(platform.id, { description: e.target.value })}
disabled={saving}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block md:col-span-2">
<div className="text-xs font-semibold text-gray-700">Link</div>
<input
value={platform.href}
onChange={e => updatePlatform(platform.id, { href: e.target.value })}
disabled={saving}
placeholder="Example: /shop or https://example.com"
className={
'mt-1 w-full rounded-lg border px-3 py-2 text-sm ' +
(isValidHref(platform.href) ? 'border-gray-200' : 'border-red-300')
}
/>
{!isValidHref(platform.href) && (
<div className="mt-1 text-xs text-red-600">Must start with / or http(s)://”.</div>
)}
<div className="mt-1 text-xs text-gray-500">
Use a relative path (starts with /) for internal pages, or a full URL for external pages.
</div>
</label>
<label className="block">
<div className="text-xs font-semibold text-gray-700">Icon</div>
<input
value={'Link'}
disabled
className="mt-1 w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700"
/>
</label>
<label className="block">
<div className="text-xs font-semibold text-gray-700">Color</div>
<select
value={platform.color}
onChange={e => updatePlatform(platform.id, { color: e.target.value as DashboardPlatformColorClass })}
disabled={saving}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
>
{DASHBOARD_PLATFORMS_COLOR_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-4 md:col-span-2">
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={platform.isActive}
onChange={e => { void setPlatformState(platform, e.target.checked) }}
disabled={saving}
/>
Active (visible on dashboard)
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={Boolean(platform.disabled)}
onChange={e => updatePlatform(platform.id, { disabled: e.target.checked })}
disabled={saving}
/>
Disabled
</label>
</div>
{platform.disabled && (
<label className="block md:col-span-2">
<div className="text-xs font-semibold text-gray-700">Disabled message</div>
<input
value={platform.disabledText || ''}
onChange={e => updatePlatform(platform.id, { disabledText: e.target.value })}
disabled={saving}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="Optional"
/>
</label>
)}
</div>
)}
</div>
</div>
))}
{!loading && platforms.length === 0 && (
<div className="rounded-2xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-600">
No platforms configured.
</div>
)}
</div>
</div>
</div>
</div>
</PageLayout>
)
}

View File

@ -278,6 +278,7 @@ export default function FinanceManagementPage() {
<th className="px-3 py-2 font-semibold">Invoice</th> <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">Customer</th>
<th className="px-3 py-2 font-semibold">Issued</th> <th className="px-3 py-2 font-semibold">Issued</th>
<th className="px-3 py-2 font-semibold">Due Date</th>
<th className="px-3 py-2 font-semibold">Amount</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">Status</th>
<th className="px-3 py-2 font-semibold">Actions</th> <th className="px-3 py-2 font-semibold">Actions</th>
@ -286,12 +287,12 @@ export default function FinanceManagementPage() {
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{invLoading ? ( {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={7} 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> <tr><td colSpan={7} className="px-3 py-3"><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
</> </>
) : filteredBills.length === 0 ? ( ) : filteredBills.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="px-3 py-4 text-center text-gray-500"> <td colSpan={7} className="px-3 py-4 text-center text-gray-500">
Keine Rechnungen gefunden. Keine Rechnungen gefunden.
</td> </td>
</tr> </tr>
@ -301,6 +302,24 @@ export default function FinanceManagementPage() {
<td className="px-3 py-2">{inv.invoice_number ?? inv.id}</td> <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.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">{inv.issued_at ? new Date(inv.issued_at).toLocaleDateString() : '—'}</td>
<td className="px-3 py-2">
{(() => {
if (!inv.due_at) return <span className="text-gray-400"></span>
const due = new Date(inv.due_at)
const now = new Date()
const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
let cls = 'bg-green-100 text-green-700' // plenty of time
if (inv.status === 'paid') cls = 'bg-green-100 text-green-700'
else if (diffDays < 0) cls = 'bg-red-100 text-red-700'
else if (diffDays <= 3) cls = 'bg-red-100 text-red-700'
else if (diffDays <= 7) cls = 'bg-amber-100 text-amber-700'
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cls}`}>
{due.toLocaleDateString()}
</span>
)
})()}
</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{Number(inv.total_gross ?? 0).toFixed(2)}{' '} {Number(inv.total_gross ?? 0).toFixed(2)}{' '}
<span className="text-xs text-gray-500">{inv.currency ?? 'EUR'}</span> <span className="text-xs text-gray-500">{inv.currency ?? 'EUR'}</span>

View File

@ -279,6 +279,24 @@ export default function AdminDashboardPage() {
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" /> <ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
</button> </button>
{/* Dashboard Management */}
<button
type="button"
onClick={() => router.push('/admin/dashboard-management')}
className="group w-full flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
>
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-blue-100 border border-blue-200 group-hover:animate-pulse">
<Squares2X2Icon className="h-6 w-6 text-blue-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-blue-900">Dashboard Management</div>
<div className="text-xs text-blue-700">Configure dashboard platforms</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
</button>
{/* User Management (unchanged) */} {/* User Management (unchanged) */}
<button <button
type="button" type="button"

View File

@ -0,0 +1,120 @@
import { useCallback } from 'react';
import useAuthStore from '../../../store/authStore';
export type CoffeeShippingFeePieceCount = 60 | 120;
export type CoffeeShippingFee = {
pieceCount: CoffeeShippingFeePieceCount;
price: number;
};
function isFormData(body: any): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData;
}
export default function useCoffeeShippingFees() {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
const getState = useAuthStore.getState;
const authorizedFetch = useCallback(
async <T = any>(
path: string,
init: RequestInit = {},
responseType: 'json' | 'text' | 'blob' = 'json'
): Promise<T> => {
let token = getState().accessToken;
if (!token) {
const ok = await getState().refreshAuthToken();
if (ok) token = getState().accessToken;
}
const headers: Record<string, string> = {
...((init.headers as Record<string, string>) || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
if (!isFormData(init.body) && init.method && init.method !== 'GET') {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
const res = await fetch(`${base}${path}`, {
credentials: 'include',
...init,
headers,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
if (responseType === 'blob') return (await res.blob()) as unknown as T;
if (responseType === 'text') return (await res.text()) as unknown as T;
const text = await res.text();
try {
return JSON.parse(text) as T;
} catch {
return {} as T;
}
},
[base, getState]
);
const listShippingFees = useCallback(async (): Promise<CoffeeShippingFee[]> => {
const res = await fetch(`${base}/api/shipping-fees`, {
method: 'GET',
credentials: 'include',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
const raw = (await res.json().catch(() => [])) as any;
if (!Array.isArray(raw)) return [];
const normalizePieceCount = (v: any): CoffeeShippingFeePieceCount | null => {
const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : NaN);
if (n === 60 || n === 120) return n;
return null;
};
return raw
.map((r: any) => {
const pieceCount = normalizePieceCount(r?.pieceCount);
if (!pieceCount) return null;
const price = r?.price != null && r?.price !== '' ? Number(r.price) : 0;
return { pieceCount, price } satisfies CoffeeShippingFee;
})
.filter(Boolean) as CoffeeShippingFee[];
}, [base]);
const updateShippingFee = useCallback(
async (pieceCount: CoffeeShippingFeePieceCount, price: number): Promise<CoffeeShippingFee> => {
if (!(pieceCount === 60 || pieceCount === 120)) {
throw new Error('pieceCount must be 60 or 120');
}
if (!Number.isFinite(price) || price < 0) {
throw new Error('price must be a number >= 0');
}
const row = await authorizedFetch<any>(`/api/admin/shipping-fees/${pieceCount}`, {
method: 'PUT',
body: JSON.stringify({ price }),
});
const normalized: CoffeeShippingFee = {
pieceCount,
price: row?.price != null && row?.price !== '' ? Number(row.price) : price,
};
return normalized;
},
[authorizedFetch]
);
return {
listShippingFees,
updateShippingFee,
};
}

View File

@ -4,9 +4,48 @@ import { PhotoIcon } from '@heroicons/react/24/solid';
import Link from 'next/link'; import Link from 'next/link';
import PageLayout from '../../components/PageLayout'; import PageLayout from '../../components/PageLayout';
import useCoffeeManagement, { CoffeeItem } from './hooks/useCoffeeManagement'; import useCoffeeManagement, { CoffeeItem } from './hooks/useCoffeeManagement';
import useCoffeeShippingFees, {
CoffeeShippingFee,
CoffeeShippingFeePieceCount,
} from './hooks/useCoffeeShippingFees';
export default function AdminSubscriptionsPage() { export default function AdminSubscriptionsPage() {
const { listProducts, setProductState, deleteProduct } = useCoffeeManagement(); const { listProducts, setProductState, deleteProduct } = useCoffeeManagement();
const { listShippingFees, updateShippingFee } = useCoffeeShippingFees();
const formatPriceDraft = (price: number) => {
if (!Number.isFinite(price)) return '';
return price.toFixed(2).replace('.', ',');
};
const parsePriceDraft = (raw: string) => {
const normalized = (raw ?? '')
.trim()
.replace(/\s+/g, '')
.replace(/,/g, '.');
if (!normalized) return NaN;
return Number(normalized);
};
const [shippingFees, setShippingFees] = useState<CoffeeShippingFee[]>([]);
const [shippingFeesLoading, setShippingFeesLoading] = useState(false);
const [shippingFeesError, setShippingFeesError] = useState<string | null>(null);
const [shippingFeeDraft, setShippingFeeDraft] = useState<Record<CoffeeShippingFeePieceCount, string>>({
60: '',
120: '',
});
const [shippingFeeFieldError, setShippingFeeFieldError] = useState<Record<CoffeeShippingFeePieceCount, string | null>>({
60: null,
120: null,
});
const [shippingFeeSaving, setShippingFeeSaving] = useState<Record<CoffeeShippingFeePieceCount, boolean>>({
60: false,
120: false,
});
const [shippingFeeSavedAt, setShippingFeeSavedAt] = useState<Record<CoffeeShippingFeePieceCount, number | null>>({
60: null,
120: null,
});
const [items, setItems] = useState<CoffeeItem[]>([]); const [items, setItems] = useState<CoffeeItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -27,9 +66,65 @@ export default function AdminSubscriptionsPage() {
useEffect(() => { useEffect(() => {
load(); load();
loadShippingFees();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
async function loadShippingFees() {
setShippingFeesLoading(true);
setShippingFeesError(null);
try {
const list = await listShippingFees();
setShippingFees(Array.isArray(list) ? list : []);
const findPrice = (pieceCount: CoffeeShippingFeePieceCount) => {
const row = (Array.isArray(list) ? list : []).find((r) => r.pieceCount === pieceCount);
return row ? row.price : 0;
};
setShippingFeeDraft({
60: formatPriceDraft(findPrice(60)),
120: formatPriceDraft(findPrice(120)),
});
setShippingFeeFieldError({ 60: null, 120: null });
} catch (e: any) {
setShippingFeesError(e?.message ?? 'Failed to load shipping fees');
} finally {
setShippingFeesLoading(false);
}
}
const saveShippingFee = async (pieceCount: CoffeeShippingFeePieceCount) => {
if (shippingFeeSaving[pieceCount]) return;
const raw = (shippingFeeDraft[pieceCount] ?? '').trim();
const price = parsePriceDraft(raw);
if (!Number.isFinite(price) || price < 0) {
setShippingFeeFieldError((prev) => ({
...prev,
[pieceCount]: 'Enter a valid price (≥ 0).',
}));
return;
}
setShippingFeeFieldError((prev) => ({ ...prev, [pieceCount]: null }));
setShippingFeeSaving((prev) => ({ ...prev, [pieceCount]: true }));
try {
const updated = await updateShippingFee(pieceCount, price);
setShippingFees((prev) => {
const next = prev.filter((r) => r.pieceCount !== pieceCount);
next.push(updated);
next.sort((a, b) => a.pieceCount - b.pieceCount);
return next;
});
setShippingFeeDraft((prev) => ({ ...prev, [pieceCount]: formatPriceDraft(updated.price) }));
setShippingFeeSavedAt((prev) => ({ ...prev, [pieceCount]: Date.now() }));
} catch (e: any) {
setShippingFeesError(e?.message ?? 'Failed to update shipping fee');
} finally {
setShippingFeeSaving((prev) => ({ ...prev, [pieceCount]: false }));
}
};
const availabilityBadge = (avail: boolean) => ( const availabilityBadge = (avail: boolean) => (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${avail ? 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200' : 'bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300'}`}> <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${avail ? 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200' : 'bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300'}`}>
{avail ? 'Available' : 'Unavailable'} {avail ? 'Available' : 'Unavailable'}
@ -63,6 +158,95 @@ export default function AdminSubscriptionsPage() {
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{error}</div> <div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{error}</div>
)} )}
{/* Shipping Fees */}
<section className="mb-8 rounded-2xl border border-gray-100 bg-white shadow-lg p-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-blue-900">Shipping Fees (ABO)</h2>
<p className="mt-1 text-sm text-gray-600">Edit the shipping prices for 60 and 120 pieces.</p>
</div>
<button
className="inline-flex items-center rounded-lg bg-gray-50 px-4 py-2 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-100 shadow transition self-start"
onClick={loadShippingFees}
disabled={shippingFeesLoading}
>
{shippingFeesLoading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{shippingFeesError && (
<div className="mt-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{shippingFeesError}</div>
)}
<div className="mt-5 grid grid-cols-1 gap-4">
{([60, 120] as CoffeeShippingFeePieceCount[]).map((pieceCount) => {
const saving = shippingFeeSaving[pieceCount];
const savedAt = shippingFeeSavedAt[pieceCount];
const fieldError = shippingFeeFieldError[pieceCount];
const current = shippingFees.find((r) => r.pieceCount === pieceCount);
const draft = shippingFeeDraft[pieceCount] ?? '';
return (
<div
key={pieceCount}
className="rounded-xl border border-gray-100 bg-white ring-1 ring-inset ring-gray-100 p-4"
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-gray-900">{pieceCount} pieces</div>
{typeof current?.price === 'number' && Number.isFinite(current.price) ? (
<div className="text-xs text-gray-500">Current: {formatPriceDraft(current.price)}</div>
) : null}
{savedAt ? (
<div className="text-xs text-emerald-700 bg-emerald-50 ring-1 ring-inset ring-emerald-200 px-2 py-0.5 rounded-full">
Saved
</div>
) : null}
</div>
{fieldError ? (
<div className="mt-2 text-xs text-red-700">{fieldError}</div>
) : (
<div className="mt-2 text-xs text-gray-500">Enter a price in EUR ( 0).</div>
)}
</div>
<div className="flex items-center gap-3">
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-gray-500"></span>
<input
inputMode="decimal"
className={`w-40 rounded-lg border px-8 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${
fieldError ? 'border-red-300 ring-1 ring-red-200' : 'border-gray-300'
}`}
value={draft}
onChange={(e) => {
const v = e.target.value;
setShippingFeeDraft((prev) => ({ ...prev, [pieceCount]: v }));
setShippingFeeFieldError((prev) => ({ ...prev, [pieceCount]: null }));
}}
placeholder="0.00"
/>
</div>
<button
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-semibold shadow transition ${
saving
? 'bg-gray-200 text-gray-600 cursor-not-allowed'
: 'bg-blue-900 text-blue-50 hover:bg-blue-800'
}`}
disabled={saving}
onClick={() => saveShippingFee(pieceCount)}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
})}
</div>
</section>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{loading && ( {loading && (
<div className="col-span-full text-sm text-gray-700">Loading</div> <div className="col-span-full text-sm text-gray-700">Loading</div>
@ -131,7 +315,7 @@ export default function AdminSubscriptionsPage() {
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200"> <div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
<div className="px-6 pt-6"> <div className="px-6 pt-6">
<h3 className="text-lg font-semibold text-blue-900">Delete coffee?</h3> <h3 className="text-lg font-semibold text-blue-900">Delete coffee?</h3>
<p className="mt-2 text-sm text-gray-700">You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.</p> <p className="mt-2 text-sm text-gray-700">You are about to delete the coffee {deleteTarget.title}. This action cannot be undone.</p>
</div> </div>
<div className="px-6 pb-6 pt-4 flex justify-end gap-3"> <div className="px-6 pb-6 pt-4 flex justify-end gap-3">
<button <button

View File

@ -14,7 +14,7 @@ import useAuthStore from '../../store/authStore'
type UserType = 'personal' | 'company' type UserType = 'personal' | 'company'
type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived' type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived'
type UserRole = 'user' | 'admin' type UserRole = 'user' | 'admin' | 'guest'
interface User { interface User {
id: number id: number
@ -32,7 +32,7 @@ interface User {
const STATUSES: UserStatus[] = ['active','pending','disabled','inactive'] const STATUSES: UserStatus[] = ['active','pending','disabled','inactive']
const TYPES: UserType[] = ['personal','company'] const TYPES: UserType[] = ['personal','company']
const ROLES: UserRole[] = ['user','admin'] const ROLES: UserRole[] = ['user','admin','guest']
export default function AdminUserManagementPage() { export default function AdminUserManagementPage() {
const { isAdmin } = useAdminUsers() const { isAdmin } = useAdminUsers()
@ -122,6 +122,7 @@ export default function AdminUserManagementPage() {
const stats = useMemo(() => ({ const stats = useMemo(() => ({
total: allUsers.length, total: allUsers.length,
admins: allUsers.filter(u => u.role === 'admin').length, admins: allUsers.filter(u => u.role === 'admin').length,
guests: allUsers.filter(u => u.role === 'guest').length,
personal: allUsers.filter(u => u.user_type === 'personal').length, personal: allUsers.filter(u => u.user_type === 'personal').length,
company: allUsers.filter(u => u.user_type === 'company').length, company: allUsers.filter(u => u.user_type === 'company').length,
active: allUsers.filter(u => u.status === 'active').length, active: allUsers.filter(u => u.status === 'active').length,
@ -232,7 +233,7 @@ export default function AdminUserManagementPage() {
t==='personal' ? badge('Personal','blue') : badge('Company','purple') t==='personal' ? badge('Personal','blue') : badge('Company','purple')
const roleBadge = (r: UserRole) => const roleBadge = (r: UserRole) =>
r==='admin' ? badge('Admin','indigo') : badge('User','gray') r==='admin' ? badge('Admin','indigo') : r==='guest' ? badge('Guest','amber') : badge('User','gray')
// Action handler for opening edit modal // Action handler for opening edit modal
const onEdit = (id: string) => { const onEdit = (id: string) => {
@ -256,7 +257,7 @@ export default function AdminUserManagementPage() {
{/* Statistic Section + Verify Button */} {/* Statistic Section + Verify Button */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center gap-6"> <div className="mb-8 flex flex-col sm:flex-row sm:items-center gap-6">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6 flex-1"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-7 gap-6 flex-1">
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow"> <div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Total Users</div> <div className="text-xs text-gray-500">Total Users</div>
<div className="text-xl font-semibold text-blue-900">{stats.total}</div> <div className="text-xl font-semibold text-blue-900">{stats.total}</div>
@ -265,6 +266,10 @@ export default function AdminUserManagementPage() {
<div className="text-xs text-gray-500">Admins</div> <div className="text-xs text-gray-500">Admins</div>
<div className="text-xl font-semibold text-indigo-700">{stats.admins}</div> <div className="text-xl font-semibold text-indigo-700">{stats.admins}</div>
</div> </div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Guests</div>
<div className="text-xl font-semibold text-amber-700">{stats.guests}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow"> <div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Personal</div> <div className="text-xs text-gray-500">Personal</div>
<div className="text-xl font-semibold text-blue-700">{stats.personal}</div> <div className="text-xl font-semibold text-blue-700">{stats.personal}</div>

View File

@ -0,0 +1,87 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
export type CoffeeShippingFeePieceCount = 60 | 120;
export type CoffeeShippingFee = {
pieceCount: CoffeeShippingFeePieceCount;
price: number;
};
type ShippingFeeMap = Record<CoffeeShippingFeePieceCount, number>;
function normalizePieceCount(v: any): CoffeeShippingFeePieceCount | null {
const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : NaN);
if (n === 60 || n === 120) return n;
return null;
}
export function useShippingFees() {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
const [fees, setFees] = useState<CoffeeShippingFee[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const feeByPieceCount: ShippingFeeMap = useMemo(() => {
const map: ShippingFeeMap = { 60: 0, 120: 0 };
for (const row of fees) {
map[row.pieceCount] = row.price;
}
return map;
}, [fees]);
useEffect(() => {
let active = true;
(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${base}/api/shipping-fees`, {
method: 'GET',
credentials: 'include',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
const raw = (await res.json().catch(() => [])) as any;
if (!active) return;
if (!Array.isArray(raw)) {
setFees([]);
return;
}
const next = raw
.map((r: any) => {
const pieceCount = normalizePieceCount(r?.pieceCount);
if (!pieceCount) return null;
const price = r?.price != null && r?.price !== '' ? Number(r.price) : 0;
return {
pieceCount,
price: Number.isFinite(price) && price >= 0 ? price : 0,
} satisfies CoffeeShippingFee;
})
.filter(Boolean) as CoffeeShippingFee[];
next.sort((a, b) => a.pieceCount - b.pieceCount);
setFees(next);
} catch (e: any) {
if (!active) return;
setError(e?.message ?? 'Failed to load shipping fees');
setFees([]);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [base]);
return { fees, feeByPieceCount, loading, error };
}

View File

@ -3,6 +3,7 @@ import React, { useState, useMemo } from 'react';
import PageLayout from '../components/PageLayout'; import PageLayout from '../components/PageLayout';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useActiveCoffees } from './hooks/getActiveCoffees'; import { useActiveCoffees } from './hooks/getActiveCoffees';
import { useShippingFees } from './hooks/useShippingFees';
export default function CoffeeAbonnementPage() { export default function CoffeeAbonnementPage() {
const [selections, setSelections] = useState<Record<string, number>>({}); const [selections, setSelections] = useState<Record<string, number>>({});
@ -13,6 +14,13 @@ export default function CoffeeAbonnementPage() {
// Fetch active coffees from the backend // Fetch active coffees from the backend
const { coffees, loading, error } = useActiveCoffees(); const { coffees, loading, error } = useActiveCoffees();
// Shipping fees (per piece count)
const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees();
const shippingFeeFor60 = feeByPieceCount[60] ?? 0;
const shippingFeeFor120 = feeByPieceCount[120] ?? 0;
const selectedShippingFee = feeByPieceCount[selectedPlanCapsules] ?? 0;
const isFreeShippingSelected = Number(selectedShippingFee) === 0;
const selectedEntries = useMemo( const selectedEntries = useMemo(
() => () =>
Object.entries(selections).map(([id, qty]) => { Object.entries(selections).map(([id, qty]) => {
@ -32,6 +40,11 @@ export default function CoffeeAbonnementPage() {
[selectedEntries] [selectedEntries]
); );
const totalNetWithShipping = useMemo(
() => totalPrice + (Number.isFinite(selectedShippingFee) ? selectedShippingFee : 0),
[totalPrice, selectedShippingFee]
);
// NEW: enforce selected plan size (60 or 120 capsules) // NEW: enforce selected plan size (60 or 120 capsules)
const totalCapsules = useMemo( const totalCapsules = useMemo(
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0), () => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
@ -112,7 +125,22 @@ export default function CoffeeAbonnementPage() {
: 'border-gray-300 hover:bg-gray-50' : 'border-gray-300 hover:bg-gray-50'
}`} }`}
> >
<div className="font-semibold">60 piece abo</div> <div className="flex items-start justify-between gap-3">
<div className="font-semibold">60 piece abo</div>
{shippingLoading ? (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
Shipping
</span>
) : shippingFeeFor60 === 0 ? (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">
FREE SHIPPING
</span>
) : (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
Shipping {shippingFeeFor60.toFixed(2)}
</span>
)}
</div>
<div className="text-xs text-gray-600">6 packs of 10 capsules</div> <div className="text-xs text-gray-600">6 packs of 10 capsules</div>
</button> </button>
<button <button
@ -124,10 +152,31 @@ export default function CoffeeAbonnementPage() {
: 'border-gray-300 hover:bg-gray-50' : 'border-gray-300 hover:bg-gray-50'
}`} }`}
> >
<div className="font-semibold">120 piece abo</div> <div className="flex items-start justify-between gap-3">
<div className="font-semibold">120 piece abo</div>
{shippingLoading ? (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
Shipping
</span>
) : shippingFeeFor120 === 0 ? (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">
FREE SHIPPING
</span>
) : (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
Shipping {shippingFeeFor120.toFixed(2)}
</span>
)}
</div>
<div className="text-xs text-gray-600">12 packs of 10 capsules</div> <div className="text-xs text-gray-600">12 packs of 10 capsules</div>
</button> </button>
</div> </div>
{shippingError && (
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
Shipping fees could not be loaded: {shippingError}
</div>
)}
</div> </div>
<h2 className="text-xl font-semibold mb-4">2. Choose coffees & quantities</h2> <h2 className="text-xl font-semibold mb-4">2. Choose coffees & quantities</h2>
@ -298,10 +347,25 @@ export default function CoffeeAbonnementPage() {
</div> </div>
</div> </div>
))} ))}
{/* Shipping */}
<div className="flex justify-between text-sm border-b pb-2">
<span className="text-sm font-medium">Shipping</span>
<span className="text-sm font-semibold">
{shippingLoading ? (
'Loading…'
) : isFreeShippingSelected ? (
'FREE SHIPPING'
) : (
`${selectedShippingFee.toFixed(2)}`
)}
</span>
</div>
<div className="flex justify-between pt-2 border-t"> <div className="flex justify-between pt-2 border-t">
<span className="text-sm font-semibold">Total (net)</span> <span className="text-sm font-semibold">Total (net)</span>
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]"> <span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">
{totalPrice.toFixed(2)} {totalNetWithShipping.toFixed(2)}
</span> </span>
</div> </div>

View File

@ -0,0 +1,167 @@
'use client'
import React, { useEffect, useRef } from 'react'
type Props = {
value: string
onChange: (dataUrl: string) => void
className?: string
}
export default function SignaturePad({ value, onChange, className }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const isDrawing = useRef(false)
const getPos = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
return { x: clientX - rect.left, y: clientY - rect.top }
}
const setupCanvas = () => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.lineWidth = 2
ctx.lineCap = 'round'
ctx.strokeStyle = '#1C2B4A'
// If we already have a signature value, redraw it after resize.
if (value) {
const img = new Image()
img.onload = () => {
try {
ctx.clearRect(0, 0, rect.width, rect.height)
ctx.drawImage(img, 0, 0, rect.width, rect.height)
} catch {}
}
img.src = value
}
}
useEffect(() => {
setupCanvas()
const onResize = () => setupCanvas()
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// If parent sets a new value (e.g., cleared externally), reflect it.
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const rect = canvas.getBoundingClientRect()
ctx.clearRect(0, 0, rect.width, rect.height)
if (!value) return
const img = new Image()
img.onload = () => {
try {
ctx.drawImage(img, 0, 0, rect.width, rect.height)
} catch {}
}
img.src = value
}, [value])
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault()
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const { x, y } = getPos(e)
ctx.beginPath()
ctx.moveTo(x, y)
isDrawing.current = true
}
const draw = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault()
if (!isDrawing.current) return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const { x, y } = getPos(e)
ctx.lineTo(x, y)
ctx.stroke()
}
const endDrawing = () => {
if (!isDrawing.current) return
isDrawing.current = false
const canvas = canvasRef.current
if (!canvas) return
try {
onChange(canvas.toDataURL('image/png'))
} catch {
onChange('')
}
}
const clear = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const rect = canvas.getBoundingClientRect()
ctx.clearRect(0, 0, rect.width, rect.height)
onChange('')
}
return (
<div className={className}>
<div className="flex items-center justify-between gap-2 mb-2">
<p className="text-sm font-medium text-gray-900">Signature</p>
<button
type="button"
onClick={clear}
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs hover:bg-gray-50"
>
Clear
</button>
</div>
<div className="rounded-lg border border-gray-300 bg-white">
<canvas
ref={canvasRef}
className="block w-full h-36 touch-none"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={endDrawing}
onMouseLeave={endDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={endDrawing}
/>
</div>
<p className="mt-2 text-xs text-gray-500">
{value ? 'Signature captured.' : 'Draw your signature in the box.'}
</p>
</div>
)
}

View File

@ -11,8 +11,7 @@ export type SubscribeAboInput = {
target_user_id?: number target_user_id?: number
recipient_name?: string recipient_name?: string
recipient_email?: string recipient_email?: string
recipient_notes?: string // Customer fields
// NEW: customer fields
firstName?: string firstName?: string
lastName?: string lastName?: string
email?: string email?: string
@ -21,8 +20,22 @@ export type SubscribeAboInput = {
city?: string city?: string
country?: string country?: string
frequency?: string frequency?: string
startDate?: string // New contract / contact fields
// NEW: logged-in user id phone?: string
recipientContractName?: string
recipientAddress?: string
paymentMethod?: string
invoiceByEmail?: boolean
invoiceSameAsShipping?: boolean
invoiceFullName?: string
invoiceStreet?: string
invoicePostalCode?: string
invoiceCity?: string
invoicePhone?: string
invoiceEmail?: string
signingCity?: string
signatureDataUrl?: string
// logged-in user id
referred_by?: number | string referred_by?: number | string
} }
@ -48,7 +61,7 @@ export async function subscribeAbo(input: SubscribeAboInput) {
} }
// NEW: validate customer fields (required in UI) // NEW: validate customer fields (required in UI)
const requiredFields = ['firstName','lastName','email','street','postalCode','city','country','frequency'] as const const requiredFields = ['firstName','lastName','email','street','postalCode','city','country'] as const
const missing = requiredFields.filter(k => { const missing = requiredFields.filter(k => {
const v = (input as any)[k] const v = (input as any)[k]
return typeof v !== 'string' || v.trim() === '' return typeof v !== 'string' || v.trim() === ''
@ -62,7 +75,7 @@ export async function subscribeAbo(input: SubscribeAboInput) {
interval_count: input.interval_count ?? 1, interval_count: input.interval_count ?? 1,
is_auto_renew: input.is_auto_renew ?? true, is_auto_renew: input.is_auto_renew ?? true,
is_for_self: isForSelf, is_for_self: isForSelf,
// NEW: include customer fields // Customer fields
firstName: input.firstName, firstName: input.firstName,
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
@ -71,7 +84,25 @@ export async function subscribeAbo(input: SubscribeAboInput) {
city: input.city, city: input.city,
country: input.country?.toUpperCase?.() ?? input.country, country: input.country?.toUpperCase?.() ?? input.country,
frequency: input.frequency, frequency: input.frequency,
startDate: input.startDate || undefined, // New contract / contact fields
phone: input.phone || undefined,
recipientContractName: input.recipientContractName || undefined,
recipientAddress: input.recipientAddress || undefined,
paymentMethod: input.paymentMethod || undefined,
invoiceByEmail: input.invoiceByEmail ?? false,
invoiceSameAsShipping: input.invoiceSameAsShipping ?? true,
signingCity: input.signingCity || undefined,
signatureDataUrl: input.signatureDataUrl || undefined,
}
// Include invoice address fields when not same as shipping
if (!body.invoiceSameAsShipping) {
body.invoiceFullName = input.invoiceFullName || undefined
body.invoiceStreet = input.invoiceStreet || undefined
body.invoicePostalCode = input.invoicePostalCode || undefined
body.invoiceCity = input.invoiceCity || undefined
body.invoicePhone = input.invoicePhone || undefined
body.invoiceEmail = input.invoiceEmail || undefined
} }
if (hasItems) { if (hasItems) {
body.items = input.items!.map(i => ({ body.items = input.items!.map(i => ({
@ -92,7 +123,6 @@ export async function subscribeAbo(input: SubscribeAboInput) {
if (input.target_user_id != null) body.target_user_id = input.target_user_id if (input.target_user_id != null) body.target_user_id = input.target_user_id
if (!isForSelf && input.recipient_email) body.recipient_email = input.recipient_email if (!isForSelf && input.recipient_email) body.recipient_email = input.recipient_email
if (!isForSelf && input.recipient_name) body.recipient_name = input.recipient_name if (!isForSelf && input.recipient_name) body.recipient_name = input.recipient_name
if (!isForSelf && input.recipient_notes) body.recipient_notes = input.recipient_notes
// NEW: always include referred_by if provided // NEW: always include referred_by if provided
if (input.referred_by != null) body.referred_by = input.referred_by if (input.referred_by != null) body.referred_by = input.referred_by

View File

@ -0,0 +1,105 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch } from '../../../utils/authFetch'
const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
export function useAboActiveContractHtml() {
const [html, setHtml] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
setError(null)
try {
const url = `${apiBase}/api/contracts/abo/active`
const res = await authFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
})
const ct = res.headers.get('content-type') || ''
const isJson = ct.includes('application/json')
try {
console.info('[useAboActiveContractHtml] response meta', {
url,
status: res.status,
ok: res.ok,
contentType: ct,
isJson,
})
} catch {}
if (isJson) {
const payload: any = await res.json().catch(() => null)
try {
console.info('[useAboActiveContractHtml] response json keys', payload && typeof payload === 'object' ? Object.keys(payload) : payload)
} catch {}
if (!res.ok) {
const msg = payload?.message || payload?.error || `Failed to load contract: ${res.status}`
throw new Error(msg)
}
const foundRaw = payload?.found ?? payload?.data?.found
const found = typeof foundRaw === 'boolean' ? foundRaw : undefined
const htmlRaw = payload?.html ?? payload?.data?.html
const htmlValue = typeof htmlRaw === 'string' ? htmlRaw : ''
const isFound = (typeof found === 'boolean') ? found : Boolean(htmlValue)
try {
console.info('[useAboActiveContractHtml] parsed json', {
success: payload?.success,
found: isFound,
htmlLength: htmlValue ? htmlValue.length : 0,
htmlPreview: htmlValue ? `${htmlValue.slice(0, 200)}${htmlValue.length > 200 ? '…' : ''}` : '',
})
} catch {}
if (active) setHtml(isFound && htmlValue ? htmlValue : null)
return
}
// Fallback: older endpoints returned raw HTML.
const text = await res.text().catch(() => '')
try {
console.info('[useAboActiveContractHtml] response text preview', {
status: res.status,
ok: res.ok,
textLength: text.length,
textPreview: text ? `${text.slice(0, 200)}${text.length > 200 ? '…' : ''}` : '',
})
} catch {}
if (!res.ok) {
throw new Error(text || `Failed to load contract: ${res.status}`)
}
if (active) setHtml(text || null)
} catch (e: any) {
if (active) {
setHtml(null)
setError(e?.message || 'Failed to load contract preview.')
}
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
}
}, [])
return { html, loading, error }
}

View File

@ -0,0 +1,49 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch } from '../../../utils/authFetch'
const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
export function useAboContractTemplateHtml() {
const [html, setHtml] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
setError(null)
try {
const res = await authFetch(`${apiBase}/api/contracts/abo/active`, {
method: 'GET',
headers: { Accept: 'text/html' },
credentials: 'include',
})
const text = await res.text().catch(() => '')
if (!res.ok) {
throw new Error(text || `Failed to load contract template: ${res.status}`)
}
if (active) setHtml(text || null)
} catch (e: any) {
if (active) {
setHtml(null)
setError(e?.message || 'Failed to load contract template preview.')
}
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
}
}, [])
return { html, loading, error }
}

View File

@ -1,19 +1,62 @@
'use client'; 'use client';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import PageLayout from '../../components/PageLayout'; import PageLayout from '../../components/PageLayout';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useActiveCoffees } from '../hooks/getActiveCoffees'; import { useActiveCoffees } from '../hooks/getActiveCoffees';
import { getStandardVatRate, getVatRates } from './hooks/getTaxRate'; import { getStandardVatRate, getVatRates } from './hooks/getTaxRate';
import { subscribeAbo } from './hooks/subscribeAbo'; import { subscribeAbo } from './hooks/subscribeAbo';
import useAuthStore from '../../store/authStore' import useAuthStore from '../../store/authStore'
import { useShippingFees } from '../hooks/useShippingFees';
import { useAboContractTemplateHtml } from './hooks/useAboContractTemplateHtml'
import SignaturePad from './components/SignaturePad'
import { Dialog, DialogActions, DialogBody, DialogTitle } from '../../components/dialog'
import { createReferralLink } from '../../referral-management/hooks/generateReferralLink'
const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette
function extractTemplateVariables(templateHtml: string | null | undefined): string[] {
if (!templateHtml) return []
const vars = new Set<string>()
const re = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g
let match: RegExpExecArray | null
while ((match = re.exec(templateHtml)) !== null) {
if (match[1]) vars.add(match[1])
}
return Array.from(vars).sort((a, b) => a.localeCompare(b))
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function hashString(value: string): number {
// djb2
let hash = 5381
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(i)
}
return hash >>> 0
}
export default function SummaryPage() { export default function SummaryPage() {
const router = useRouter(); const router = useRouter();
const { coffees, loading, error } = useActiveCoffees(); const { coffees, loading, error } = useActiveCoffees();
const user = useAuthStore(state => state.user) const user = useAuthStore(state => state.user)
const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees();
const { html: contractHtml, loading: contractLoading, error: contractError } = useAboContractTemplateHtml()
const [isContractPreviewOpen, setIsContractPreviewOpen] = useState(false)
const [contractPdfUrl, setContractPdfUrl] = useState<string>('')
const [contractPdfKey, setContractPdfKey] = useState<string>('')
const [contractPdfLoading, setContractPdfLoading] = useState(false)
const [contractPdfError, setContractPdfError] = useState<string | null>(null)
const [selections, setSelections] = useState<Record<string, number>>({}); const [selections, setSelections] = useState<Record<string, number>>({});
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120); const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
const [isForSelf, setIsForSelf] = useState(true); const [signatureDataUrl, setSignatureDataUrl] = useState('')
const [form, setForm] = useState({ const [form, setForm] = useState({
firstName: '', firstName: '',
lastName: '', lastName: '',
@ -22,19 +65,284 @@ export default function SummaryPage() {
postalCode: '', postalCode: '',
city: '', city: '',
country: 'DE', country: 'DE',
frequency: 'monatlich', phone: '',
startDate: '', paymentMethod: 'sepa' as 'sepa' | 'card' | 'sofort',
recipientEmail: '', invoiceByEmail: true,
recipientName: '', invoiceSameAsShipping: true,
recipientNotes: '', invoiceFullName: '',
invoiceStreet: '',
invoicePostalCode: '',
invoiceCity: '',
invoicePhone: '',
invoiceEmail: '',
signingCity: '',
}); });
const [showThanks, setShowThanks] = useState(false); const [showThanks, setShowThanks] = useState(false);
const [guestMailtoHref, setGuestMailtoHref] = useState<string>('')
const [guestInviteLink, setGuestInviteLink] = useState<string>('')
const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]); const [confetti, setConfetti] = useState<{ left: number; delay: number; color: string }[]>([]);
const [taxRate, setTaxRate] = useState(0.07); // minimal fallback only const [taxRate, setTaxRate] = useState(0.07); // minimal fallback only
const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]); const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [submitLoading, setSubmitLoading] = useState(false); const [submitLoading, setSubmitLoading] = useState(false);
const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette const initialCountryRef = useRef(form.country)
const templateVariableNames = useMemo(() => extractTemplateVariables(contractHtml), [contractHtml])
const templateVariableNamesKey = useMemo(() => templateVariableNames.join('|'), [templateVariableNames])
const [contractVariables, setContractVariables] = useState<Record<string, string>>({})
// Auto-compute contract variables from form state for preview
useEffect(() => {
if (!templateVariableNamesKey) return
const fullName = `${form.firstName} ${form.lastName}`.trim()
const isCompany = user?.userType === 'company' || user?.user_type === 'company'
const invoiceSame = form.invoiceSameAsShipping
const computed: Record<string, string> = {
contractNumber: '(wird generiert)',
currentDate: new Date().toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }),
recipientName: fullName,
recipientAddress: `${form.street}, ${form.postalCode} ${form.city}`.trim(),
shippingCustomerClass: isCompany ? '' : 'checked',
shippingCompanyClass: isCompany ? 'checked' : '',
shippingFullName: fullName,
shippingStreet: form.street,
shippingPostalCode: form.postalCode,
shippingCity: form.city,
shippingPhone: form.phone,
shippingEmail: form.email,
invoiceSameAsShippingMark: invoiceSame ? '✓' : '',
invoiceCompanyClass: isCompany ? 'checked' : '',
invoiceCustomerClass: isCompany ? '' : 'checked',
invoiceFullName: invoiceSame ? fullName : form.invoiceFullName,
invoiceStreet: invoiceSame ? form.street : form.invoiceStreet,
invoicePostalCode: invoiceSame ? form.postalCode : form.invoicePostalCode,
invoiceCity: invoiceSame ? form.city : form.invoiceCity,
invoicePhone: invoiceSame ? form.phone : form.invoicePhone,
invoiceEmail: invoiceSame ? form.email : form.invoiceEmail,
fnCheckedClass: '',
fnNumber: '',
atuCheckedClass: '',
atuNumber: '',
entrepreneurClass: isCompany ? 'checked' : '',
consumerClass: isCompany ? '' : 'checked',
paymentSepaClass: form.paymentMethod === 'sepa' ? 'checked' : '',
paymentCardClass: form.paymentMethod === 'card' ? 'checked' : '',
paymentSofortClass: form.paymentMethod === 'sofort' ? 'checked' : '',
invoiceByEmailClass: form.invoiceByEmail ? 'checked' : '',
signingCity: form.signingCity,
fullName,
}
setContractVariables(computed)
}, [templateVariableNamesKey, form, user, signatureDataUrl])
const populatedContractHtml = useMemo(() => {
if (!contractHtml) return null
// Replace placeholders with escaped user-entered values so placeholders never show.
return contractHtml.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_whole, varName: string) => {
if (varName === 'signatureImage' && !contractVariables[varName] && signatureDataUrl) {
const safeUrl = String(signatureDataUrl)
if (safeUrl.startsWith('data:image/')) {
const src = escapeHtml(safeUrl)
return `<img alt="Signature" src="${src}" style="max-width: 100%; max-height: 120px;" />`
}
}
const value = contractVariables[varName] ?? ''
return escapeHtml(String(value))
})
}, [contractHtml, contractVariables, signatureDataUrl])
const contractPdfCacheKey = useMemo(() => {
if (!populatedContractHtml) return ''
return String(hashString(populatedContractHtml))
}, [populatedContractHtml])
useEffect(() => {
// Cleanup blob URL when it changes or on unmount.
return () => {
if (contractPdfUrl) URL.revokeObjectURL(contractPdfUrl)
}
}, [contractPdfUrl])
const closeContractPreview = () => {
setIsContractPreviewOpen(false)
setContractPdfError(null)
setContractPdfLoading(false)
setContractPdfKey('')
if (contractPdfUrl) {
URL.revokeObjectURL(contractPdfUrl)
setContractPdfUrl('')
}
}
const openContractPreview = async () => {
if (!populatedContractHtml) return
setIsContractPreviewOpen(true)
setContractPdfError(null)
// Reuse only if the populated HTML hasn't changed.
if (contractPdfUrl && contractPdfKey === contractPdfCacheKey) return
if (contractPdfUrl) {
URL.revokeObjectURL(contractPdfUrl)
setContractPdfUrl('')
}
setContractPdfLoading(true)
try {
const [jsPdfMod, html2canvasMod] = await Promise.all([import('jspdf'), import('html2canvas')])
const jsPDF: any = (jsPdfMod as any).jsPDF || (jsPdfMod as any).default
const html2canvas: any = (html2canvasMod as any).default || html2canvasMod
const parser = new DOMParser()
const doc = parser.parseFromString(populatedContractHtml, 'text/html')
const styles = Array.from(doc.querySelectorAll('style'))
.map(s => s.textContent || '')
.join('\n')
const bodyHtml = doc.body?.innerHTML || ''
const wrapper = document.createElement('div')
wrapper.style.position = 'fixed'
wrapper.style.left = '-10000px'
wrapper.style.top = '0'
wrapper.style.width = '794px' // approx A4 width at 96dpi
wrapper.style.background = '#ffffff'
wrapper.innerHTML = `<style>${styles}</style>${bodyHtml}`
document.body.appendChild(wrapper)
try {
const pdf = new jsPDF({ orientation: 'p', unit: 'pt', format: 'a4' })
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginX = 24
const marginTop = 24
const marginBottom = 24
const usableWidth = pageWidth - marginX * 2
const usableHeight = pageHeight - marginTop - marginBottom
const renderCanvasToPdf = (canvas: HTMLCanvasElement, pageIndex: number) => {
const imgWidthPt = usableWidth
const pxPerPt = canvas.width / imgWidthPt
const sliceHeightPx = Math.max(1, Math.floor(usableHeight * pxPerPt))
let yPx = 0
let sliceIndex = 0
while (yPx < canvas.height) {
const remainingPx = canvas.height - yPx
const currentSliceHeightPx = Math.min(sliceHeightPx, remainingPx)
const sliceCanvas = document.createElement('canvas')
sliceCanvas.width = canvas.width
sliceCanvas.height = currentSliceHeightPx
const ctx = sliceCanvas.getContext('2d')
if (!ctx) break
ctx.drawImage(
canvas,
0,
yPx,
canvas.width,
currentSliceHeightPx,
0,
0,
canvas.width,
currentSliceHeightPx
)
const imgData = sliceCanvas.toDataURL('image/png')
const imgHeightPt = currentSliceHeightPx / pxPerPt
const isFirstOverall = pageIndex === 0 && sliceIndex === 0
if (!isFirstOverall) pdf.addPage()
pdf.addImage(imgData, 'PNG', marginX, marginTop, imgWidthPt, imgHeightPt)
yPx += currentSliceHeightPx
sliceIndex++
}
}
// Split at explicit .pageBreak markers (in document order, even when nested)
// to avoid cutting content between pages.
const docRoot = wrapper.querySelector('.doc') as HTMLElement | null
const pageRoot = docRoot ?? wrapper
const breakEls = Array.from(pageRoot.querySelectorAll('.pageBreak')) as HTMLElement[]
if (breakEls.length === 0) {
const canvas: HTMLCanvasElement = await html2canvas(wrapper, {
scale: Math.min(2, window.devicePixelRatio || 1),
backgroundColor: '#ffffff',
useCORS: true,
})
renderCanvasToPdf(canvas, 0)
} else {
const range = document.createRange()
range.setStart(pageRoot, 0)
const fragments: DocumentFragment[] = []
for (const br of breakEls) {
range.setEndBefore(br)
const frag = range.cloneContents()
if (frag.childNodes.length > 0) fragments.push(frag)
range.setStartAfter(br)
}
range.setEnd(pageRoot, pageRoot.childNodes.length)
const lastFrag = range.cloneContents()
if (lastFrag.childNodes.length > 0) fragments.push(lastFrag)
if (fragments.length === 0) {
const canvas: HTMLCanvasElement = await html2canvas(wrapper, {
scale: Math.min(2, window.devicePixelRatio || 1),
backgroundColor: '#ffffff',
useCORS: true,
})
renderCanvasToPdf(canvas, 0)
} else {
for (let pageIndex = 0; pageIndex < fragments.length; pageIndex++) {
const pageWrapper = document.createElement('div')
pageWrapper.style.position = 'fixed'
pageWrapper.style.left = '-10000px'
pageWrapper.style.top = '0'
pageWrapper.style.width = '794px'
pageWrapper.style.background = '#ffffff'
pageWrapper.innerHTML = `<style>${styles}</style>`
const pageDoc = document.createElement('div')
pageDoc.className = docRoot?.className || 'doc'
pageDoc.appendChild(fragments[pageIndex])
pageWrapper.appendChild(pageDoc)
document.body.appendChild(pageWrapper)
try {
const canvas: HTMLCanvasElement = await html2canvas(pageWrapper, {
scale: Math.min(2, window.devicePixelRatio || 1),
backgroundColor: '#ffffff',
useCORS: true,
})
renderCanvasToPdf(canvas, pageIndex)
} finally {
document.body.removeChild(pageWrapper)
}
}
}
}
const blob = pdf.output('blob') as Blob
const url = URL.createObjectURL(blob)
setContractPdfUrl(url)
setContractPdfKey(contractPdfCacheKey)
} finally {
document.body.removeChild(wrapper)
}
} catch (e: any) {
setContractPdfError(e?.message || 'Failed to generate PDF preview.')
} finally {
setContractPdfLoading(false)
}
}
useEffect(() => { useEffect(() => {
try { try {
@ -96,18 +404,19 @@ export default function SummaryPage() {
useEffect(() => { useEffect(() => {
let active = true; let active = true;
(async () => { (async () => {
console.info('[SummaryPage] Loading vat rates (mount). country:', form.country) const mountCountry = initialCountryRef.current
console.info('[SummaryPage] Loading vat rates (mount). country:', mountCountry)
const list = await getVatRates(); const list = await getVatRates();
if (!active) return; if (!active) return;
console.info('[SummaryPage] getVatRates result count:', list.length) console.info('[SummaryPage] getVatRates result count:', list.length)
setVatRates(list); setVatRates(list);
const upper = form.country.toUpperCase(); const upper = mountCountry.toUpperCase();
const match = list.find(r => r.code === upper); const match = list.find(r => r.code === upper);
if (match?.rate != null) { if (match?.rate != null) {
console.info('[SummaryPage] Initial taxRate from list:', match.rate, 'country:', upper) console.info('[SummaryPage] Initial taxRate from list:', match.rate, 'country:', upper)
setTaxRate(match.rate); setTaxRate(match.rate);
} else { } else {
const rate = await getStandardVatRate(form.country); const rate = await getStandardVatRate(mountCountry);
console.info('[SummaryPage] Fallback taxRate via getStandardVatRate:', rate, 'country:', upper) console.info('[SummaryPage] Fallback taxRate via getStandardVatRate:', rate, 'country:', upper)
setTaxRate(rate ?? 0.07); setTaxRate(rate ?? 0.07);
} }
@ -138,17 +447,29 @@ export default function SummaryPage() {
() => selectedEntries.reduce((sum, e) => sum + (e.quantity / 10) * e.coffee.pricePer10, 0), () => selectedEntries.reduce((sum, e) => sum + (e.quantity / 10) * e.coffee.pricePer10, 0),
[selectedEntries] [selectedEntries]
); );
const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxRate, taxAmount]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { const shippingFee = useMemo(() => {
const v = feeByPieceCount[selectedPlanCapsules];
return Number.isFinite(Number(v)) ? Number(v) : 0;
}, [feeByPieceCount, selectedPlanCapsules]);
const netWithShipping = useMemo(
() => totalPrice + shippingFee,
[totalPrice, shippingFee]
);
const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]);
const taxAmountWithShipping = useMemo(() => netWithShipping * taxRate, [netWithShipping, taxRate]);
const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value })); setForm(prev => ({ ...prev, [name]: value }));
}; };
const handleRecipientNotes = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, checked } = e.target;
setForm(prev => ({ ...prev, [name]: value })); setForm(prev => ({ ...prev, [name]: checked }));
}; };
const fillFromLoggedInData = () => { const fillFromLoggedInData = () => {
@ -177,7 +498,7 @@ export default function SummaryPage() {
})); }));
}; };
const requiredSelfFields: Array<keyof typeof form> = [ const requiredSelfFields = [
'firstName', 'firstName',
'lastName', 'lastName',
'email', 'email',
@ -185,17 +506,16 @@ export default function SummaryPage() {
'postalCode', 'postalCode',
'city', 'city',
'country', 'country',
'frequency', ] as const
]
const hasRequiredSelfFields = requiredSelfFields.every(k => form[k].trim() !== '') const hasRequiredSelfFields = requiredSelfFields.every(k => String(form[k]).trim() !== '')
const hasRequiredGiftFields = isForSelf || form.recipientEmail.trim() !== '' const hasRequiredInvoiceFields = form.invoiceSameAsShipping || form.invoiceEmail.trim() !== ''
const canSubmit = const canSubmit =
selectedEntries.length > 0 && selectedEntries.length > 0 &&
totalPacks === requiredPacks && totalPacks === requiredPacks &&
hasRequiredSelfFields && hasRequiredSelfFields &&
hasRequiredGiftFields; hasRequiredInvoiceFields;
const backToSelection = () => router.push('/coffee-abonnements'); const backToSelection = () => router.push('/coffee-abonnements');
@ -206,10 +526,6 @@ export default function SummaryPage() {
setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`) setSubmitError(`Order must contain exactly ${requiredPacks} packs (${selectedPlanCapsules} capsules).`)
return return
} }
if (!isForSelf && !form.recipientEmail.trim()) {
setSubmitError('Recipient email is required when the subscription is for someone else.')
return
}
setSubmitError(null) setSubmitError(null)
setSubmitLoading(true) setSubmitLoading(true)
@ -222,8 +538,7 @@ export default function SummaryPage() {
billing_interval: 'month', billing_interval: 'month',
interval_count: 1, interval_count: 1,
is_auto_renew: true, is_auto_renew: true,
is_for_self: isForSelf, is_for_self: true,
// NEW: pass customer fields
firstName: form.firstName.trim(), firstName: form.firstName.trim(),
lastName: form.lastName.trim(), lastName: form.lastName.trim(),
email: form.email.trim(), email: form.email.trim(),
@ -231,18 +546,32 @@ export default function SummaryPage() {
postalCode: form.postalCode.trim(), postalCode: form.postalCode.trim(),
city: form.city.trim(), city: form.city.trim(),
country: form.country.trim(), country: form.country.trim(),
frequency: form.frequency.trim(), frequency: 'monatlich',
startDate: form.startDate.trim() || undefined, phone: form.phone.trim() || undefined,
recipient_email: isForSelf ? undefined : form.recipientEmail.trim(), recipientContractName: `${form.firstName} ${form.lastName}`.trim() || undefined,
recipient_name: isForSelf ? undefined : (form.recipientName.trim() || undefined), paymentMethod: form.paymentMethod,
recipient_notes: isForSelf ? undefined : (form.recipientNotes.trim() || undefined), invoiceByEmail: form.invoiceByEmail,
// NEW: always include referred_by if available invoiceSameAsShipping: form.invoiceSameAsShipping,
...(!form.invoiceSameAsShipping ? {
invoiceFullName: form.invoiceFullName.trim() || undefined,
invoiceStreet: form.invoiceStreet.trim() || undefined,
invoicePostalCode: form.invoicePostalCode.trim() || undefined,
invoiceCity: form.invoiceCity.trim() || undefined,
invoicePhone: form.invoicePhone.trim() || undefined,
invoiceEmail: form.invoiceEmail.trim() || undefined,
} : {}),
signingCity: form.signingCity.trim() || undefined,
signatureDataUrl: signatureDataUrl || undefined,
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined, referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
} }
console.info('[SummaryPage] subscribeAbo payload:', payload) console.info('[SummaryPage] subscribeAbo payload:', payload)
// NEW: explicit JSON preview to match request body // NEW: explicit JSON preview to match request body
console.info('[SummaryPage] subscribeAbo payload JSON:', JSON.stringify(payload)) console.info('[SummaryPage] subscribeAbo payload JSON:', JSON.stringify(payload))
await subscribeAbo(payload) await subscribeAbo(payload)
setGuestMailtoHref('')
setGuestInviteLink('')
setShowThanks(true); setShowThanks(true);
try { sessionStorage.removeItem('coffeeSelections'); } catch {} try { sessionStorage.removeItem('coffeeSelections'); } catch {}
try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } catch {} try { sessionStorage.removeItem('coffeeAboSizeCapsules'); } catch {}
@ -327,7 +656,6 @@ export default function SummaryPage() {
> >
Fill fields with logged in data Fill fields with logged in data
</button> </button>
{/* "For someone else" is disabled for now — only self-subscriptions */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{/* inputs translated */} {/* inputs translated */}
<div> <div>
@ -363,51 +691,139 @@ export default function SummaryPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Delivery interval</label> <label className="block text-sm font-medium mb-1">Phone (optional)</label>
<select name="frequency" value={form.frequency} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"> <input name="phone" value={form.phone} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
<option value="monatlich">Monthly</option>
<option value="zweimonatlich">Every 2 months</option>
<option value="vierteljährlich">Quarterly</option>
</select>
</div> </div>
<div> </div>
<label className="block text-sm font-medium mb-1">Start date (optional)</label>
<input type="date" name="startDate" value={form.startDate} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" /> {/* Payment method */}
<div className="mt-6 border-t border-gray-200 pt-4">
<h3 className="text-base font-semibold text-gray-900 mb-3">Payment method</h3>
<div className="flex flex-wrap gap-3">
{(['sepa', 'card', 'sofort'] as const).map(method => (
<label key={method} className={`flex items-center gap-2 rounded-md border px-4 py-2 cursor-pointer transition ${form.paymentMethod === method ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 font-medium' : 'border-gray-300 hover:bg-gray-50'}`}>
<input type="radio" name="paymentMethod" value={method} checked={form.paymentMethod === method} onChange={handleInput} className="accent-[#1C2B4A]" />
{method === 'sepa' ? 'SEPA' : method === 'card' ? 'Credit Card' : 'Sofort Banking'}
</label>
))}
</div> </div>
{!isForSelf && ( <label className="mt-3 flex items-center gap-2 text-sm">
<> <input type="checkbox" name="invoiceByEmail" checked={form.invoiceByEmail} onChange={handleCheckbox} className="accent-[#1C2B4A]" />
Send invoice by email
</label>
</div>
{/* Invoice address */}
<div className="mt-6 border-t border-gray-200 pt-4">
<h3 className="text-base font-semibold text-gray-900 mb-3">Invoice address</h3>
<label className="flex items-center gap-2 text-sm mb-3">
<input type="checkbox" name="invoiceSameAsShipping" checked={form.invoiceSameAsShipping} onChange={handleCheckbox} className="accent-[#1C2B4A]" />
Same as shipping address
</label>
{!form.invoiceSameAsShipping && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Recipient email</label> <label className="block text-sm font-medium mb-1">Full name</label>
<input <input name="invoiceFullName" value={form.invoiceFullName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
type="email"
name="recipientEmail"
value={form.recipientEmail}
onChange={handleInput}
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
/>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="block text-sm font-medium mb-1">Recipient name (optional)</label> <label className="block text-sm font-medium mb-1">Street & No.</label>
<input <input name="invoiceStreet" value={form.invoiceStreet} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
name="recipientName"
value={form.recipientName}
onChange={handleInput}
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
/>
</div> </div>
<div className="sm:col-span-2"> <div>
<label className="block text-sm font-medium mb-1">Recipient note (optional)</label> <label className="block text-sm font-medium mb-1">ZIP</label>
<textarea <input name="invoicePostalCode" value={form.invoicePostalCode} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
name="recipientNotes"
value={form.recipientNotes}
onChange={handleRecipientNotes}
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
rows={3}
/>
</div> </div>
</> <div>
<label className="block text-sm font-medium mb-1">City</label>
<input name="invoiceCity" value={form.invoiceCity} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone (optional)</label>
<input name="invoicePhone" value={form.invoicePhone} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input type="email" name="invoiceEmail" value={form.invoiceEmail} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
</div>
</div>
)} )}
</div> </div>
{/* Contract preview + signature */}
<div className="mt-6 border-t border-gray-200 pt-6">
<h3 className="text-base font-semibold text-gray-900 mb-2">Contract preview (ABO)</h3>
<p className="text-xs text-gray-600 mb-3">
Contract variables are auto-populated from your form data.
</p>
{contractLoading ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
Loading contract preview
</div>
) : contractError ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Contract preview could not be loaded: {contractError}
</div>
) : populatedContractHtml ? (
<button
type="button"
onClick={openContractPreview}
className="inline-flex items-center justify-center rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
>
Open preview
</button>
) : (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
Contract template is not available.
</div>
)}
<div className="mt-4 space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Ort (Signing City)</label>
<input type="text" name="signingCity" value={form.signingCity} onChange={handleInput} className="w-full max-w-xs rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" placeholder="z.B. Wien" />
</div>
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} />
</div>
</div>
<Dialog open={isContractPreviewOpen} onClose={closeContractPreview} size="5xl">
<DialogTitle>ABO contract preview (PDF)</DialogTitle>
<DialogBody>
{contractPdfError ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
PDF preview could not be generated: {contractPdfError}
</div>
) : contractPdfLoading ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
Generating PDF preview
</div>
) : contractPdfUrl ? (
<div className="rounded-lg border border-gray-300 bg-white overflow-hidden">
<iframe
title="ABO Contract PDF Preview"
className="w-full h-[75vh]"
src={contractPdfUrl}
/>
</div>
) : (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
No PDF preview available.
</div>
)}
</DialogBody>
<DialogActions>
<button
type="button"
onClick={closeContractPreview}
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold hover:bg-gray-50"
>
Close
</button>
</DialogActions>
</Dialog>
<button <button
onClick={submit} onClick={submit}
disabled={!canSubmit || submitLoading} disabled={!canSubmit || submitLoading}
@ -422,9 +838,7 @@ export default function SummaryPage() {
</button> </button>
{!canSubmit && ( {!canSubmit && (
<p className="text-xs text-gray-500 mt-2"> <p className="text-xs text-gray-500 mt-2">
{isForSelf Please select coffees and fill all required buyer fields.
? 'Please select coffees and fill all required buyer fields.'
: 'Please select coffees and fill all required buyer fields plus recipient email.'}
</p> </p>
)} )}
</div> </div>
@ -445,13 +859,34 @@ export default function SummaryPage() {
<div className="text-right font-semibold">{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</div> <div className="text-right font-semibold">{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}</div>
</div> </div>
))} ))}
{/* Shipping */}
<div className="flex justify-between text-sm border-b pb-2">
<span className="text-sm font-medium">Shipping</span>
<span className="text-sm font-semibold">
{shippingLoading ? (
'Loading…'
) : shippingFee === 0 ? (
'FREE SHIPPING'
) : (
`${shippingFee.toFixed(2)}`
)}
</span>
</div>
{shippingError && (
<div className="mt-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
Shipping fees could not be loaded: {shippingError}
</div>
)}
<div className="flex justify-between pt-2 border-t"> <div className="flex justify-between pt-2 border-t">
<span className="text-sm font-semibold">Total (net)</span> <span className="text-sm font-semibold">Total (net)</span>
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">{totalPrice.toFixed(2)}</span> <span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">{netWithShipping.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm">Tax ({(taxRate * 100).toFixed(1)}%)</span> <span className="text-sm">Tax ({(taxRate * 100).toFixed(1)}%)</span>
<span className="text-sm font-medium">{taxAmount.toFixed(2)}</span> <span className="text-sm font-medium">{taxAmountWithShipping.toFixed(2)}</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-semibold">Total incl. tax</span> <span className="text-sm font-semibold">Total incl. tax</span>
@ -489,7 +924,7 @@ export default function SummaryPage() {
</div> </div>
<h3 className="text-2xl font-bold">Thanks for your subscription!</h3> <h3 className="text-2xl font-bold">Thanks for your subscription!</h3>
<p className="mt-1 text-sm text-gray-600"> <p className="mt-1 text-sm text-gray-600">
{isForSelf ? 'Subscription created.' : 'Subscription created, invitation sent.'} Subscription created.
</p> </p>
<div className="mt-6 grid gap-3 sm:grid-cols-2"> <div className="mt-6 grid gap-3 sm:grid-cols-2">

View File

@ -25,13 +25,13 @@ export function Dialog({
'as' | 'className' 'as' | 'className'
>) { >) {
return ( return (
<Headless.Dialog {...props}> <Headless.Dialog {...props} className="relative z-50">
<Headless.DialogBackdrop <Headless.DialogBackdrop
transition transition
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50" className="fixed inset-0 z-50 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 backdrop-blur-sm px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
/> />
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0"> <div className="fixed inset-0 z-50 w-screen overflow-y-auto pt-6 sm:pt-0">
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4"> <div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<Headless.DialogPanel <Headless.DialogPanel
transition transition

View File

@ -504,18 +504,18 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
Personal Matrix Personal Matrix
</button> </button>
)} )}
{DISPLAY_ABONEMENTS && hasSubscribePerm && (
<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>
)}
</> </>
)} )}
{userPresent && DISPLAY_ABONEMENTS && hasSubscribePerm && (
<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>
)}
{/* Information dropdown already removed here */} {/* Information dropdown already removed here */}
</PopoverGroup> </PopoverGroup>
@ -737,16 +737,16 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
Personal Matrix Personal Matrix
</button> </button>
)} )}
{DISPLAY_ABONEMENTS && hasSubscribePerm && (
<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>
)}
</> </>
)} )}
{DISPLAY_ABONEMENTS && hasSubscribePerm && (
<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>
)}
{/* Admin navigation LAST */} {/* Admin navigation LAST */}
{isAdmin && ( {isAdmin && (
@ -763,6 +763,12 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
> >
Dashboard Dashboard
</button> </button>
<button
onClick={() => { router.push('/admin/dashboard-management'); setMobileMenuOpen(false); }}
className="w-full text-left rounded-lg px-2 py-1.5 text-slate-800 hover:bg-indigo-50 hover:text-slate-900 transition-colors dark:text-indigo-50 dark:hover:bg-white/10 dark:hover:text-white"
>
Dashboard Management
</button>
<button <button
onClick={() => { router.push('/admin/user-verify'); setMobileMenuOpen(false); }} onClick={() => { router.push('/admin/user-verify'); setMobileMenuOpen(false); }}
className="w-full text-left rounded-lg px-2 py-1.5 text-slate-800 hover:bg-indigo-50 hover:text-slate-900 transition-colors dark:text-indigo-50 dark:hover:bg-white/10 dark:hover:text-white" className="w-full text-left rounded-lg px-2 py-1.5 text-slate-800 hover:bg-indigo-50 hover:text-slate-900 transition-colors dark:text-indigo-50 dark:hover:bg-white/10 dark:hover:text-white"

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback, useRef } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import type { ComponentType, SVGProps } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import useAuthStore from '../store/authStore' import useAuthStore from '../store/authStore'
@ -15,12 +16,20 @@ import {
StarIcon, StarIcon,
LinkIcon LinkIcon
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import {
DEFAULT_DASHBOARD_PLATFORMS,
loadDashboardPlatforms,
subscribeDashboardPlatformsUpdated,
type DashboardPlatform,
type DashboardPlatformIconName
} from '../utils/dashboardPlatforms'
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter() const router = useRouter()
const user = useAuthStore(state => state.user) const user = useAuthStore(state => state.user)
const isAuthReady = useAuthStore(state => state.isAuthReady) const isAuthReady = useAuthStore(state => state.isAuthReady)
const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false' const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false'
const [platforms, setPlatforms] = useState<DashboardPlatform[]>(DEFAULT_DASHBOARD_PLATFORMS)
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [latestNews, setLatestNews] = useState<Array<{ id: number; title: string; summary?: string; slug: string; published_at?: string | null }>>([]) const [latestNews, setLatestNews] = useState<Array<{ id: number; title: string; summary?: string; slug: string; published_at?: string | null }>>([])
const [newsLoading, setNewsLoading] = useState(false) const [newsLoading, setNewsLoading] = useState(false)
@ -50,6 +59,11 @@ export default function DashboardPage() {
} }
}, []) }, [])
useEffect(() => {
setPlatforms(loadDashboardPlatforms())
return subscribeDashboardPlatformsUpdated(() => setPlatforms(loadDashboardPlatforms()))
}, [])
useEffect(() => { useEffect(() => {
let active = true let active = true
;(async () => { ;(async () => {
@ -78,16 +92,20 @@ export default function DashboardPage() {
} }
}, [isAuthReady, user, router]) }, [isAuthReady, user, router])
// NEW: block dashboard unless all 4 quickaction steps are completed // NEW: block dashboard unless all quickaction steps are completed
// For guest users: only email verification is required
// For regular users: all 4 steps must be completed
useEffect(() => { useEffect(() => {
if (!isAuthReady || !user) return if (!isAuthReady || !user) return
if (statusLoading || !userStatus) return if (statusLoading || !userStatus) return
const allDone = const isGuest = user?.role === 'guest'
!!userStatus.email_verified && const allDone = isGuest
!!userStatus.documents_uploaded && ? !!userStatus.email_verified
!!userStatus.profile_completed && : !!userStatus.email_verified &&
!!userStatus.contract_signed !!userStatus.documents_uploaded &&
!!userStatus.profile_completed &&
!!userStatus.contract_signed
if (!allDone) smoothReplace('/quickaction-dashboard') if (!allDone) smoothReplace('/quickaction-dashboard')
}, [isAuthReady, user, statusLoading, userStatus, smoothReplace]) }, [isAuthReady, user, statusLoading, userStatus, smoothReplace])
@ -116,13 +134,15 @@ export default function DashboardPage() {
) )
} }
// NEW: final guard (dont render dashboard if not all done) // Final guard (don't render dashboard if not all done)
if (!userStatus) return null if (!userStatus) return null
const allDone = const isGuestUser = user?.role === 'guest'
!!userStatus.email_verified && const allDone = isGuestUser
!!userStatus.documents_uploaded && ? !!userStatus.email_verified
!!userStatus.profile_completed && : !!userStatus.email_verified &&
!!userStatus.contract_signed !!userStatus.documents_uploaded &&
!!userStatus.profile_completed &&
!!userStatus.contract_signed
if (!allDone) return null if (!allDone) return null
// Get user name // Get user name
@ -135,39 +155,12 @@ export default function DashboardPage() {
return 'User' return 'User'
} }
// Quick actions const icons: Record<DashboardPlatformIconName, ComponentType<SVGProps<SVGSVGElement>>> = {
const quickActions = [ ShoppingBagIcon,
{ LinkIcon,
title: 'Browse Shop', UsersIcon,
description: 'Explore sustainable products', UserCircleIcon
icon: ShoppingBagIcon, }
href: '/shop',
color: 'bg-blue-500',
disabled: !isShopEnabled,
disabledText: 'This is currently disabled.'
},
{
title: 'Browse Affiliate Links',
description: 'Discover affiliate offers and links',
icon: LinkIcon,
href: '/affiliate-links',
color: 'bg-teal-500'
},
{
title: 'Referral Management',
description: 'Create and manage referral links',
icon: UsersIcon,
href: '/referral-management',
color: 'bg-amber-600'
},
{
title: 'Edit Profile',
description: 'Update your information',
icon: UserCircleIcon,
href: '/profile',
color: 'bg-purple-500'
}
]
const content = ( const content = (
<div className="relative z-10 flex-1 min-h-0"> <div className="relative z-10 flex-1 min-h-0">
@ -187,55 +180,62 @@ export default function DashboardPage() {
{/* Quick Actions */} {/* Quick Actions */}
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2> <h2 className="text-xl font-semibold text-gray-900 mb-4">Platforms</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{quickActions.map((action, index) => ( {platforms.filter(p => p.isActive).map((platform) => {
const Icon = icons[platform.icon]
const disabledByEnv = platform.href === '/shop' && !isShopEnabled
const isDisabled = Boolean(platform.disabled) || disabledByEnv
const disabledText = disabledByEnv ? 'This is currently disabled.' : platform.disabledText
return (
<button <button
key={index} key={platform.id}
onClick={() => { onClick={() => {
if (!action.disabled) { if (!isDisabled) {
router.push(action.href) router.push(platform.href)
} }
}} }}
disabled={Boolean(action.disabled)} disabled={isDisabled}
className={`bg-white rounded-lg p-6 border border-gray-200 text-left group transition-all duration-200 ${ className={`bg-white rounded-lg p-6 border border-gray-200 text-left group transition-all duration-200 ${
action.disabled isDisabled
? 'opacity-60 cursor-not-allowed' ? 'opacity-60 cursor-not-allowed'
: 'shadow-sm hover:shadow-lg hover:-translate-y-1 hover:-translate-y-1 hover:-translate-y-1 transform hover:-translate-y-1' : 'shadow-sm hover:shadow-lg hover:-translate-y-1 hover:-translate-y-1 hover:-translate-y-1 transform hover:-translate-y-1'
}`} }`}
> >
<div className="flex items-start"> <div className="flex items-start">
<div <div
className={`${action.color} rounded-lg p-3 ${ className={`${platform.color} rounded-lg p-3 ${
action.disabled isDisabled
? 'grayscale' ? 'grayscale'
: 'group-hover:scale-105 transition-transform' : 'group-hover:scale-105 transition-transform'
}`} }`}
> >
<action.icon className="h-6 w-6 text-white" /> <Icon className="h-6 w-6 text-white" />
</div> </div>
<div className="ml-4 flex-1"> <div className="ml-4 flex-1">
<h3 <h3
className={`text-lg font-medium transition-colors ${ className={`text-lg font-medium transition-colors ${
action.disabled isDisabled
? 'text-gray-500' ? 'text-gray-500'
: 'text-gray-900 group-hover:text-[#8D6B1D]' : 'text-gray-900 group-hover:text-[#8D6B1D]'
}`} }`}
> >
{action.title} {platform.title}
</h3> </h3>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
{action.description} {platform.description}
</p> </p>
{action.disabled && action.disabledText && ( {isDisabled && disabledText && (
<p className="mt-3 text-xs font-medium text-amber-700"> <p className="mt-3 text-xs font-medium text-amber-700">
{action.disabledText} {disabledText}
</p> </p>
)} )}
</div> </div>
</div> </div>
</button> </button>
))} )
})}
</div> </div>
</div> </div>

View File

@ -0,0 +1,101 @@
import { useEffect, useMemo, useState } from 'react'
import {
DASHBOARD_PLATFORMS_COLOR_OPTIONS,
DASHBOARD_PLATFORMS_ICON_OPTIONS,
type DashboardPlatform,
type DashboardPlatformColorClass,
type DashboardPlatformIconName
} from '../utils/dashboardPlatforms'
type BackendPlatform = {
id: string | number
title: string
href: string
description?: string | null
icon?: DashboardPlatformIconName | null
color?: DashboardPlatformColorClass | null
state?: boolean
disabled?: boolean
disabledText?: string | null
sortOrder?: number | null
}
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
function toPlatform(p: BackendPlatform): DashboardPlatform | null {
const title = typeof p.title === 'string' ? p.title : ''
const href = typeof p.href === 'string' ? p.href : ''
if (!title.trim() || !href.trim()) return null
const icon: DashboardPlatformIconName =
p.icon && DASHBOARD_PLATFORMS_ICON_OPTIONS.some(o => o.value === p.icon)
? p.icon
: 'LinkIcon'
const color: DashboardPlatformColorClass =
p.color && DASHBOARD_PLATFORMS_COLOR_OPTIONS.some(o => o.value === p.color)
? p.color
: 'bg-blue-500'
return {
id: String(p.id),
title,
href,
description: typeof p.description === 'string' ? p.description : '',
icon,
color,
isActive: typeof p.state === 'boolean' ? p.state : true,
disabled: typeof p.disabled === 'boolean' ? p.disabled : false,
disabledText: typeof p.disabledText === 'string' ? p.disabledText : undefined
}
}
export function usePublicDashboardPlatforms() {
const [platforms, setPlatforms] = useState<DashboardPlatform[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/dashboard-platforms`, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include'
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const json = (await res.json().catch(() => null)) as unknown
const list = Array.isArray(json) ? (json as BackendPlatform[]) : []
const normalized = list
.map(toPlatform)
.filter((x): x is DashboardPlatform => Boolean(x))
if (active) setPlatforms(normalized)
} catch (e: any) {
if (active) setError(e?.message || 'Failed to load platforms')
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
}
}, [])
const activePlatforms = useMemo(() => platforms.filter(p => p.isActive), [platforms])
return {
platforms: activePlatforms,
loading,
error
}
}

View File

@ -2,26 +2,28 @@
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { gsap } from 'gsap';
import PageLayout from './components/PageLayout'; import PageLayout from './components/PageLayout';
import Crosshair from './components/Crosshair'; import Crosshair from './components/Crosshair';
import Waves from './components/background/waves'; import Waves from './components/background/waves';
import SplitText from './components/SplitText'; import { usePublicDashboardPlatforms } from './hooks/usePublicDashboardPlatforms';
import {
LinkIcon,
ShoppingBagIcon,
UserCircleIcon,
UsersIcon,
} from '@heroicons/react/24/outline';
import type { ComponentType, SVGProps } from 'react';
import type { DashboardPlatformIconName } from './utils/dashboardPlatforms';
export default function HomePage() { export default function HomePage() {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [isHover, setIsHover] = useState(false);
const [isMobile, setIsMobile] = useState(() => { const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
return window.matchMedia('(max-width: 768px)').matches; return window.matchMedia('(max-width: 768px)').matches;
}); });
const router = useRouter(); const router = useRouter();
const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false';
// Mobile: instantly redirect to login const { platforms, loading, error } = usePublicDashboardPlatforms();
useEffect(() => {
if (!isMobile) return;
router.replace('/login');
}, [isMobile, router]);
// Keep breakpoint updated (resize/orientation) // Keep breakpoint updated (resize/orientation)
useEffect(() => { useEffect(() => {
@ -35,41 +37,28 @@ export default function HomePage() {
}; };
}, []); }, []);
const handleLoginClick = () => { const icons: Record<DashboardPlatformIconName, ComponentType<SVGProps<SVGSVGElement>>> = {
// Mobile: no page fade animation ShoppingBagIcon,
if (isMobile || !containerRef.current) { LinkIcon,
router.push('/login'); UsersIcon,
return; UserCircleIcon,
}
gsap.to(containerRef.current, {
opacity: 0,
duration: 0.6,
ease: 'power2.out',
onComplete: () => router.push('/login'),
});
}; };
// Ensure LOGIN never stays stuck after scrolling / wheel (desktop only) const goTo = (href: string) => {
useEffect(() => { const trimmed = (href || '').trim();
if (isMobile) return; if (!trimmed) return;
const resetHover = () => setIsHover(false); if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
window.addEventListener('wheel', resetHover, { passive: true }); window.location.href = trimmed;
window.addEventListener('scroll', resetHover, { passive: true }); return;
return () => { }
window.removeEventListener('wheel', resetHover); router.push(trimmed);
window.removeEventListener('scroll', resetHover); };
};
}, [isMobile]);
// Prevent any home UI flash on mobile
if (isMobile) return null;
return ( return (
<PageLayout> <PageLayout>
<div <div
ref={containerRef} ref={containerRef}
className="min-h-screen flex items-center justify-center relative overflow-hidden bg-black text-white" className="min-h-screen relative overflow-hidden bg-transparent text-gray-900"
> >
{/* Waves background */} {/* Waves background */}
<Waves <Waves
@ -89,38 +78,124 @@ export default function HomePage() {
interactive={!isMobile} interactive={!isMobile}
/> />
<h1 className="z-10"> <div className="relative z-10 min-h-screen flex items-center px-4 py-10 sm:py-14">
<a <div className="w-full max-w-7xl mx-auto">
onClick={handleLoginClick} <div className="flex flex-col gap-6">
onMouseEnter={isMobile ? undefined : () => setIsHover(true)} <div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
onMouseLeave={isMobile ? undefined : () => setIsHover(false)} <div className="flex flex-col sm:flex-row sm:items-center gap-5 sm:gap-6">
className="cursor-pointer" <img
> src="/images/logos/PP_Logo_BW_round.png"
{isMobile ? ( alt="Profit Planet"
<span className="block text-5xl sm:text-6xl font-bold text-gray-500 text-center px-4"> className="h-16 w-16 sm:h-20 sm:w-20 object-contain"
PROFIT PLANET />
</span> <div className="min-w-0 flex-1">
) : ( <div className="inline-flex items-center rounded-full border border-gray-200 bg-white/60 px-3 py-1 text-xs font-semibold text-gray-700">
<SplitText Welcome
key={isHover ? 'login' : 'profit-planet'} </div>
text={isHover ? 'LOGIN' : 'PROFIT PLANET'} <h1 className="mt-3 text-5xl sm:text-6xl md:text-7xl font-black tracking-tight leading-none text-transparent bg-clip-text bg-gradient-to-r from-gray-900 via-gray-700 to-amber-700">
tag="span" Profit Planet
className={`text-7xl sm:text-8xl md:text-9xl font-bold transition-colors duration-300 ${ </h1>
isHover ? 'text-black' : 'text-gray-500' <p className="mt-3 text-sm sm:text-base text-gray-700">
}`} Pick a platform to continue.
delay={100} </p>
duration={0.6} </div>
ease="power3.out" </div>
splitType="chars" </div>
from={{ opacity: 0, y: 40 }}
to={{ opacity: 1, y: 0 }} <div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
threshold={0.1} <div className="flex items-start justify-between gap-4">
rootMargin="-100px" <div>
textAlign="center" <h2 className="text-xl sm:text-2xl font-bold text-gray-900">Platforms</h2>
/> <p className="mt-1 text-sm text-gray-700">Navigation shortcuts</p>
)} </div>
</a> </div>
</h1>
<div className="mt-6">
{loading && (
<div className="rounded-2xl border border-gray-200 bg-white p-6 text-center text-sm text-gray-600">
Loading
</div>
)}
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 p-6 text-center text-sm text-red-800">
{error}
</div>
)}
{!loading && !error && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{platforms.map((platform) => {
const Icon = icons[platform.icon] || LinkIcon;
const disabledByEnv = platform.href === '/shop' && !isShopEnabled;
const isDisabled = Boolean(platform.disabled) || disabledByEnv;
const disabledText = disabledByEnv ? 'This is currently disabled.' : platform.disabledText;
return (
<button
key={platform.id}
onClick={() => {
if (!isDisabled) {
goTo(platform.href);
}
}}
disabled={isDisabled}
className={`rounded-2xl border text-left p-5 transition-all duration-200 ${
isDisabled
? 'border-gray-200 bg-white opacity-60 cursor-not-allowed'
: 'group border-gray-200 bg-white shadow-sm hover:shadow-md hover:-translate-y-0.5'
}`}
>
<div className="flex items-start gap-4">
<div
className={`${platform.color} rounded-xl p-3 ${
isDisabled
? 'grayscale'
: 'transition-transform group-hover:scale-105'
}`}
>
<Icon className="h-6 w-6 text-white" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<h3
className={`text-base font-semibold transition-colors ${
isDisabled
? 'text-gray-500'
: 'text-gray-900 hover:text-amber-700'
}`}
>
{platform.title}
</h3>
</div>
<p className="mt-1 text-sm text-gray-600">
{platform.description}
</p>
{isDisabled && disabledText && (
<p className="mt-3 text-xs font-medium text-amber-700">
{disabledText}
</p>
)}
</div>
</div>
</button>
);
})}
{platforms.length === 0 && (
<div className="sm:col-span-2 lg:col-span-3 rounded-2xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-600">
No platforms available.
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* No parallax/crosshair on mobile */} {/* No parallax/crosshair on mobile */}
{!isMobile && <Crosshair containerRef={containerRef} color="#0f172a" />} {!isMobile && <Crosshair containerRef={containerRef} color="#0f172a" />}

View File

@ -23,8 +23,14 @@ export default function MediaSection({ documents }: { documents: any[] }) {
<td className="px-4 py-2 text-gray-600">{doc.type}</td> <td className="px-4 py-2 text-gray-600">{doc.type}</td>
<td className="px-4 py-2 text-gray-600">{doc.uploaded}</td> <td className="px-4 py-2 text-gray-600">{doc.uploaded}</td>
<td className="px-4 py-2 flex gap-2"> <td className="px-4 py-2 flex gap-2">
<button className="px-3 py-1 text-xs bg-[#8D6B1D] text-white rounded hover:bg-[#7A5E1A] transition">Download</button> {doc.signedUrl ? (
<button className="px-3 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition">Preview</button> <>
<a href={doc.signedUrl} download className="px-3 py-1 text-xs bg-[#8D6B1D] text-white rounded hover:bg-[#7A5E1A] transition">Download</a>
<a href={doc.signedUrl} target="_blank" rel="noopener noreferrer" className="px-3 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition">Preview</a>
</>
) : (
<span className="text-xs text-gray-400 italic">No file</span>
)}
</td> </td>
</tr> </tr>
))} ))}

View File

@ -184,7 +184,28 @@ export default function ProfilePage() {
} }
}, [profileDataApi, user, progressPercent]) }, [profileDataApi, user, progressPercent])
const documents = Array.isArray(mediaData?.documents) ? mediaData.documents : [] const documents = React.useMemo(() => {
const contracts = Array.isArray(mediaData?.contracts) ? mediaData.contracts : []
const idDocs = Array.isArray(mediaData?.idDocuments) ? mediaData.idDocuments : []
const contractItems = contracts.map((doc: any) => ({
id: `contract-${doc.id}`,
name: doc.original_filename || 'Contract',
type: 'Contract',
uploaded: doc.created_at ? new Date(doc.created_at).toLocaleDateString() : '-',
signedUrl: doc.signedUrl,
}))
const idDocItems = idDocs.filter((d: any) => d.object_storage_id).map((doc: any) => ({
id: `id-${doc.user_id_document_id}-${doc.side}`,
name: doc.original_filename || `ID Document (${doc.side})`,
type: `${doc.id_type || 'ID'} ${doc.side}`,
uploaded: doc.expiry_date ? new Date(doc.expiry_date).toLocaleDateString() : '-',
signedUrl: doc.signedUrl,
}))
return [...contractItems, ...idDocItems]
}, [mediaData])
useEffect(() => { useEffect(() => {
if (showRefreshing && !profileLoading && !mediaLoading && !completionLoading) { if (showRefreshing && !profileLoading && !mediaLoading && !completionLoading) {

View File

@ -90,8 +90,13 @@ export default function QuickActionDashboardPage() {
const additionalInfo = userStatus?.profile_completed || false const additionalInfo = userStatus?.profile_completed || false
const contractSigned = userStatus?.contract_signed || false const contractSigned = userStatus?.contract_signed || false
// NEW: if everything is done, quickaction-dashboard is no longer accessible // Detect guest user — guests only need email verification
const allDone = emailVerified && idUploaded && additionalInfo && contractSigned const isGuest = isClient && user?.role === 'guest'
// For guests: only email verification matters. For regular users: all 4 steps.
const allDone = isGuest
? emailVerified
: emailVerified && idUploaded && additionalInfo && contractSigned
// NEW: smooth redirect (prevents snappy double navigation) // NEW: smooth redirect (prevents snappy double navigation)
const [redirectTo, setRedirectTo] = useState<string | null>(null) const [redirectTo, setRedirectTo] = useState<string | null>(null)
const redirectOnceRef = useRef(false) const redirectOnceRef = useRef(false)
@ -167,36 +172,47 @@ export default function QuickActionDashboardPage() {
sessionStorage.setItem(sessionKey, '1') sessionStorage.setItem(sessionKey, '1')
}, [isClient, loading, userStatus, isTutorialOpen, user, accessToken, getNextTutorialStep]) }, [isClient, loading, userStatus, isTutorialOpen, user, accessToken, getNextTutorialStep])
const statusItems: StatusItem[] = [ // For guests: only show email verification step. For regular users: all 4 steps.
{ const statusItems: StatusItem[] = isGuest
key: 'email', ? [
label: 'Email Verification', {
description: emailVerified ? 'Verified' : 'Missing', key: 'email',
complete: emailVerified, label: 'Email Verification',
icon: EnvelopeOpenIcon description: emailVerified ? 'Verified' : 'Missing',
}, complete: emailVerified,
{ icon: EnvelopeOpenIcon
key: 'id', }
label: 'ID Document', ]
description: idUploaded ? 'Uploaded' : 'Missing', : [
complete: idUploaded, {
icon: IdentificationIcon key: 'email',
}, label: 'Email Verification',
{ description: emailVerified ? 'Verified' : 'Missing',
key: 'info', complete: emailVerified,
label: 'Additional Info', icon: EnvelopeOpenIcon
description: additionalInfo ? 'Completed' : 'Missing', },
complete: additionalInfo, {
icon: InformationCircleIcon key: 'id',
}, label: 'ID Document',
{ description: idUploaded ? 'Uploaded' : 'Missing',
key: 'contract', complete: idUploaded,
label: 'Contract', icon: IdentificationIcon
description: contractSigned ? 'Signed' : 'Missing', },
complete: contractSigned, {
icon: DocumentCheckIcon key: 'info',
} label: 'Additional Info',
] description: additionalInfo ? 'Completed' : 'Missing',
complete: additionalInfo,
icon: InformationCircleIcon
},
{
key: 'contract',
label: 'Contract',
description: contractSigned ? 'Signed' : 'Missing',
complete: contractSigned,
icon: DocumentCheckIcon
}
]
// Action handlers - navigate to proper QuickAction pages with tutorial callback // Action handlers - navigate to proper QuickAction pages with tutorial callback
const handleVerifyEmail = useCallback(() => { const handleVerifyEmail = useCallback(() => {
@ -313,7 +329,11 @@ export default function QuickActionDashboardPage() {
Welcome{isClient && user?.firstName ? `, ${user.firstName}` : ''}! Welcome{isClient && user?.firstName ? `, ${user.firstName}` : ''}!
</h1> </h1>
<p className="text-sm sm:text-base text-gray-600 mt-2"> <p className="text-sm sm:text-base text-gray-600 mt-2">
{isClient && user?.userType === 'company' ? 'Company Account' : 'Personal Account'} {isGuest
? 'Guest Account'
: isClient && user?.userType === 'company'
? 'Company Account'
: 'Personal Account'}
</p> </p>
{loading && <p className="text-xs text-gray-500 mt-1">Loading status...</p>} {loading && <p className="text-xs text-gray-500 mt-1">Loading status...</p>}
{error && ( {error && (
@ -350,11 +370,14 @@ export default function QuickActionDashboardPage() {
{/* Status Overview */} {/* Status Overview */}
<div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 p-5 sm:p-8"> <div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 p-5 sm:p-8">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-5"> <h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-5">
Status Overview {isGuest ? 'Email Verification Status' : 'Status Overview'}
</h2> </h2>
{/* CHANGED: mobile 2x2 grid */} {/* Guest: single centered card. Regular: 2x2 / 4-col grid */}
<div className="grid grid-cols-2 gap-3 sm:gap-6 md:grid-cols-2 xl:grid-cols-4"> <div className={isGuest
? 'flex justify-center'
: 'grid grid-cols-2 gap-3 sm:gap-6 md:grid-cols-2 xl:grid-cols-4'
}>
{statusItems.map(item => { {statusItems.map(item => {
const CompleteIcon = item.complete ? CheckCircleIcon : XCircleIcon const CompleteIcon = item.complete ? CheckCircleIcon : XCircleIcon
return ( return (
@ -362,7 +385,7 @@ export default function QuickActionDashboardPage() {
key={item.key} key={item.key}
className={`rounded-lg px-3 py-4 sm:px-4 sm:py-6 flex flex-col items-center text-center border transition-colors ${ className={`rounded-lg px-3 py-4 sm:px-4 sm:py-6 flex flex-col items-center text-center border transition-colors ${
item.complete ? 'bg-emerald-50 border-emerald-100' : 'bg-rose-50 border-rose-100' item.complete ? 'bg-emerald-50 border-emerald-100' : 'bg-rose-50 border-rose-100'
}`} } ${isGuest ? 'w-full max-w-xs' : ''}`}
> >
<CompleteIcon <CompleteIcon
className={`h-5 w-5 sm:h-6 sm:w-6 mb-3 sm:mb-4 ${ className={`h-5 w-5 sm:h-6 sm:w-6 mb-3 sm:mb-4 ${
@ -397,98 +420,130 @@ export default function QuickActionDashboardPage() {
i i
</span> </span>
<h2 className="text-sm sm:text-base font-semibold text-gray-900"> <h2 className="text-sm sm:text-base font-semibold text-gray-900">
Quick Actions {isGuest ? 'Action Required' : 'Quick Actions'}
</h2> </h2>
</div> </div>
<button {/* Tutorial button — only for regular users */}
onClick={startTutorial} {!isGuest && (
className="relative inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors" <button
> onClick={startTutorial}
<AcademicCapIcon className="h-4 w-4" /> className="relative inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors"
Tutorial >
{!hasSeenTutorial && ( <AcademicCapIcon className="h-4 w-4" />
<span className="absolute -top-1 -right-1 h-3 w-3 bg-red-500 rounded-full animate-pulse" /> Tutorial
)} {!hasSeenTutorial && (
</button> <span className="absolute -top-1 -right-1 h-3 w-3 bg-red-500 rounded-full animate-pulse" />
)}
</button>
)}
</div> </div>
{/* CHANGED: mobile 2x2 grid (order already matches desired layout) */} {isGuest ? (
<div className="grid grid-cols-2 gap-3 sm:gap-6 md:grid-cols-2 xl:grid-cols-4"> /* ── Guest view: single email verification action ── */
{/* Email Verification */} <div className="flex flex-col items-center text-center">
<div className="flex flex-col"> <div className="max-w-sm w-full">
<p className="text-sm text-gray-600 mb-6">
Please verify your email address to activate your guest account and access your subscriptions.
</p>
<button
onClick={handleVerifyEmail}
disabled={emailVerified}
className={`w-full relative flex flex-col items-center justify-center rounded-lg px-4 py-8 text-center border font-medium text-sm transition-all md:transition-transform md:duration-200 ${
emailVerified
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
: 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
}`}
>
<EnvelopeOpenIcon className="h-6 w-6 sm:h-8 sm:w-8 mb-3" />
{emailVerified ? 'Email Verified ✓' : 'Verify Email'}
</button>
{!emailVerified && (
<p className="mt-3 text-xs text-[#112c55]">
{resendRemainingSec > 0
? `Resend available in ${formatMmSs(resendRemainingSec)}`
: 'You can request a new code now'}
</p>
)}
</div>
</div>
) : (
/* ── Regular user view: all 4 quick action buttons ── */
<div className="grid grid-cols-2 gap-3 sm:gap-6 md:grid-cols-2 xl:grid-cols-4">
{/* Email Verification */}
<div className="flex flex-col">
<button
onClick={handleVerifyEmail}
disabled={emailVerified}
className={`relative flex flex-col items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-8 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
emailVerified
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
: 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
}`}
>
<EnvelopeOpenIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{emailVerified ? 'Email Verified' : 'Verify Email'}
</button>
{!emailVerified && (
<p className="mt-2 text-[11px] text-[#112c55] text-center">
{resendRemainingSec > 0
? `Resend available in ${formatMmSs(resendRemainingSec)}`
: 'You can request a new code now'}
</p>
)}
</div>
{/* ID Upload */}
<button <button
onClick={handleVerifyEmail} onClick={handleUploadId}
disabled={emailVerified} disabled={idUploaded}
className={`relative flex flex-col items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-8 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${ className={`relative flex flex-col items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-5 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
emailVerified idUploaded
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default' ? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
: 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0' : 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
}`} }`}
> >
<EnvelopeOpenIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" /> <ArrowUpOnSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{emailVerified ? 'Email Verified' : 'Verify Email'} {idUploaded ? 'ID Uploaded' : 'Upload ID Document'}
</button> </button>
{/* NEW: resend feedback (only when not verified) */}
{!emailVerified && (
<p className="mt-2 text-[11px] text-[#112c55] text-center">
{resendRemainingSec > 0
? `Resend available in ${formatMmSs(resendRemainingSec)}`
: 'You can request a new code now'}
</p>
)}
</div>
{/* ID Upload */} {/* Additional Info */}
<button
onClick={handleUploadId}
disabled={idUploaded}
className={`relative flex flex-col items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-5 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
idUploaded
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
: 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
}`}
>
<ArrowUpOnSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{idUploaded ? 'ID Uploaded' : 'Upload ID Document'}
</button>
{/* Additional Info */}
<button
onClick={handleCompleteInfo}
disabled={additionalInfo}
className={`relative flex flex-col items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-5 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
additionalInfo
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
: 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
}`}
>
<PencilSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{additionalInfo ? 'Profile Completed' : 'Complete Profile'}
</button>
{/* Sign Contract */}
<div className="flex flex-col">
<button <button
onClick={handleSignContract} onClick={handleCompleteInfo}
disabled={!canSignContract || contractSigned} disabled={additionalInfo}
className={`flex flex-col flex-1 items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-5 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${ className={`relative flex flex-col items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-5 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
contractSigned additionalInfo
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default' ? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
: canSignContract : 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
? 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
: 'bg-gray-300 text-gray-600 border-gray-300 cursor-not-allowed'
}`} }`}
> >
<ClipboardDocumentCheckIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" /> <PencilSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{contractSigned ? 'Contract Signed' : 'Sign Contract'} {additionalInfo ? 'Profile Completed' : 'Complete Profile'}
</button> </button>
{!canSignContract && !contractSigned && (
<p className="mt-2 text-[11px] text-red-600 leading-snug text-center"> {/* Sign Contract */}
Complete previous steps (email, ID, profile) before signing the contract. <div className="flex flex-col">
</p> <button
)} onClick={handleSignContract}
disabled={!canSignContract || contractSigned}
className={`flex flex-col flex-1 items-center justify-center rounded-lg px-3 py-5 sm:px-4 sm:py-5 text-center border font-medium text-xs sm:text-sm transition-all md:transition-transform md:duration-200 ${
contractSigned
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
: canSignContract
? 'bg-blue-600 text-white border-blue-600 shadow md:hover:-translate-y-0.5 md:hover:shadow-lg md:hover:bg-blue-500 md:active:translate-y-0'
: 'bg-gray-300 text-gray-600 border-gray-300 cursor-not-allowed'
}`}
>
<ClipboardDocumentCheckIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{contractSigned ? 'Contract Signed' : 'Sign Contract'}
</button>
{!canSignContract && !contractSigned && (
<p className="mt-2 text-[11px] text-red-600 leading-snug text-center">
Complete previous steps (email, ID, profile) before signing the contract.
</p>
)}
</div>
</div> </div>
</div> )}
</div> </div>
{/* Latest News */} {/* Latest News */}

View File

@ -236,7 +236,9 @@ export default function EmailVerifyPage() {
message: 'Your email has been verified successfully.' message: 'Your email has been verified successfully.'
}) })
await refreshStatus() await refreshStatus()
window.location.href = '/quickaction-dashboard?tutorial=true' // Guests go directly to dashboard after email verification
const isGuest = user?.role === 'guest'
window.location.href = isGuest ? '/dashboard' : '/quickaction-dashboard?tutorial=true'
} else { } else {
const msg = data.error || 'Verification failed. Please try again.' const msg = data.error || 'Verification failed. Please try again.'
setError(msg) setError(msg)
@ -345,18 +347,22 @@ export default function EmailVerifyPage() {
useEffect(() => { useEffect(() => {
if (statusLoading || !userStatus) return if (statusLoading || !userStatus) return
const allDone = const isGuest = user?.role === 'guest'
!!userStatus.email_verified && const allDone = isGuest
!!userStatus.documents_uploaded && ? !!userStatus.email_verified
!!userStatus.profile_completed && : !!userStatus.email_verified &&
!!userStatus.contract_signed !!userStatus.documents_uploaded &&
!!userStatus.profile_completed &&
!!userStatus.contract_signed
if (allDone) { if (allDone) {
smoothReplace('/dashboard') // CHANGED smoothReplace('/dashboard')
} else if (userStatus.email_verified) { } else if (userStatus.email_verified) {
smoothReplace('/quickaction-dashboard') // CHANGED // Regular users go back to quickaction dashboard for remaining steps
// Guests should never reach here since allDone covers them
smoothReplace('/quickaction-dashboard')
} }
}, [statusLoading, userStatus, smoothReplace]) }, [statusLoading, userStatus, user, smoothReplace])
// NEW: must be logged in // NEW: must be logged in
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,159 @@
export type DashboardPlatformIconName =
| 'ShoppingBagIcon'
| 'LinkIcon'
| 'UsersIcon'
| 'UserCircleIcon'
export type DashboardPlatformColorClass =
| 'bg-blue-500'
| 'bg-teal-500'
| 'bg-amber-600'
| 'bg-purple-500'
export type DashboardPlatform = {
id: string
title: string
description: string
href: string
icon: DashboardPlatformIconName
color: DashboardPlatformColorClass
isActive: boolean
disabled?: boolean
disabledText?: string
}
export const DASHBOARD_PLATFORMS_STORAGE_KEY = 'pp.dashboardPlatforms.v1'
export const DASHBOARD_PLATFORMS_UPDATED_EVENT = 'pp:dashboard-platforms-updated'
export const DASHBOARD_PLATFORMS_ICON_OPTIONS: Array<{ value: DashboardPlatformIconName; label: string }> = [
{ value: 'ShoppingBagIcon', label: 'Shopping Bag' },
{ value: 'LinkIcon', label: 'Link' },
{ value: 'UsersIcon', label: 'Users' },
{ value: 'UserCircleIcon', label: 'User Profile' }
]
export const DASHBOARD_PLATFORMS_COLOR_OPTIONS: Array<{ value: DashboardPlatformColorClass; label: string }> = [
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-teal-500', label: 'Teal' },
{ value: 'bg-amber-600', label: 'Amber' },
{ value: 'bg-purple-500', label: 'Purple' }
]
export const DEFAULT_DASHBOARD_PLATFORMS: DashboardPlatform[] = [
{
id: 'shop',
title: 'Browse Shop',
description: 'Explore sustainable products',
icon: 'ShoppingBagIcon',
href: '/shop',
color: 'bg-blue-500',
isActive: true,
disabledText: 'This is currently disabled.'
},
{
id: 'affiliate-links',
title: 'Browse Affiliate Links',
description: 'Discover affiliate offers and links',
icon: 'LinkIcon',
href: '/affiliate-links',
color: 'bg-teal-500',
isActive: true
},
{
id: 'referral-management',
title: 'Referral Management',
description: 'Create and manage referral links',
icon: 'UsersIcon',
href: '/referral-management',
color: 'bg-amber-600',
isActive: true
},
{
id: 'profile',
title: 'Edit Profile',
description: 'Update your information',
icon: 'UserCircleIcon',
href: '/profile',
color: 'bg-purple-500',
isActive: true
}
]
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function createFallbackId(): string {
return `platform_${Date.now()}_${Math.random().toString(16).slice(2)}`
}
function normalizePlatform(input: unknown): DashboardPlatform | null {
if (!isRecord(input)) return null
const id = typeof input.id === 'string' && input.id.trim() ? input.id.trim() : createFallbackId()
const title = typeof input.title === 'string' ? input.title : ''
const description = typeof input.description === 'string' ? input.description : ''
const href = typeof input.href === 'string' ? input.href : ''
const icon = input.icon as DashboardPlatformIconName
const color = input.color as DashboardPlatformColorClass
const isActive = typeof input.isActive === 'boolean' ? input.isActive : true
const disabled = typeof input.disabled === 'boolean' ? input.disabled : false
const disabledText = typeof input.disabledText === 'string' ? input.disabledText : undefined
const iconOk = DASHBOARD_PLATFORMS_ICON_OPTIONS.some(o => o.value === icon)
const colorOk = DASHBOARD_PLATFORMS_COLOR_OPTIONS.some(o => o.value === color)
if (!title.trim() || !href.trim() || !iconOk || !colorOk) return null
return {
id,
title,
description,
href,
icon,
color,
isActive,
disabled,
disabledText
}
}
export function loadDashboardPlatforms(): DashboardPlatform[] {
if (typeof window === 'undefined') return DEFAULT_DASHBOARD_PLATFORMS
try {
const raw = window.localStorage.getItem(DASHBOARD_PLATFORMS_STORAGE_KEY)
if (!raw) return DEFAULT_DASHBOARD_PLATFORMS
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return DEFAULT_DASHBOARD_PLATFORMS
const normalized = parsed
.map(normalizePlatform)
.filter((x): x is DashboardPlatform => Boolean(x))
return normalized.length ? normalized : DEFAULT_DASHBOARD_PLATFORMS
} catch {
return DEFAULT_DASHBOARD_PLATFORMS
}
}
export function saveDashboardPlatforms(platforms: DashboardPlatform[]): void {
if (typeof window === 'undefined') return
window.localStorage.setItem(DASHBOARD_PLATFORMS_STORAGE_KEY, JSON.stringify(platforms))
window.dispatchEvent(new Event(DASHBOARD_PLATFORMS_UPDATED_EVENT))
}
export function subscribeDashboardPlatformsUpdated(callback: () => void): () => void {
if (typeof window === 'undefined') return () => {}
const handler = () => callback()
window.addEventListener(DASHBOARD_PLATFORMS_UPDATED_EVENT, handler)
window.addEventListener('storage', handler)
return () => {
window.removeEventListener(DASHBOARD_PLATFORMS_UPDATED_EVENT, handler)
window.removeEventListener('storage', handler)
}
}