im getting MY>AADASD

This commit is contained in:
DeathKaioken 2026-03-15 18:34:19 +01:00
parent adfe136d74
commit f47441ff41
20 changed files with 2547 additions and 163 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;

View File

@ -0,0 +1,620 @@
<!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%;
margin: 0 auto;
}
.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 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: 180px 1fr;
gap: 8px;
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: inline-block; min-width: 100%; }
.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-block;
border-radius: 2px;
position: relative;
flex: 0 0 auto;
}
.checkbox.checked::after {
content: "";
position: absolute;
left: 3px;
top: 0px;
width: 6px;
height: 10px;
border-right: 2px solid var(--ink);
border-bottom: 2px solid var(--ink);
transform: 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; }
}
</style>
</head>
<body>
<div class="doc">
<div class="header">
<div class="brand">
<p class="title">ABO Vertrag</p>
<p class="subtitle">Kaffee-/Tee-Service & automatische Wiederbestellungen</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 style="text-align:right; font-size: 10pt;" class="muted">
<div>Vertragsnummer: <span class="fill">{{contractNumber}}</span></div>
<div>Datum: <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>
<div class="grid2" style="margin-top: 10px;">
<div class="box">
<div class="boxTitle">Affiliate</div>
<div class="row">
<div class="label">AFFILIATE NAME</div>
<div class="value"><span class="fill wide">{{affiliateName}}</span></div>
</div>
</div>
<div class="box">
<div class="boxTitle">Client</div>
<div class="row">
<div class="label">CLIENT NAME</div>
<div class="value"><span class="fill wide">{{clientName}}</span></div>
</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 class="label">Bevorzugte Kontaktaufnahme</div>
<div class="value">
<span class="check"><span class="checkbox {{shippingPrefPhoneClass}}"></span> Telefon</span>
<span class="check" style="margin-left: 12px;"><span class="checkbox {{shippingPrefEmailClass}}"></span> E-Mail</span>
</div>
</div>
</div>
</div>
<div class="hint">Rechnungsadresse: <strong>{{invoiceSameAsShippingMark}} wie Lieferadresse</strong></div>
</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 class="row">
<div class="label">Bevorzugte Kontaktaufnahme</div>
<div class="value">
<span class="check"><span class="checkbox {{invoicePrefPhoneClass}}"></span> Telefon</span>
<span class="check" style="margin-left: 12px;"><span class="checkbox {{invoicePrefEmailClass}}"></span> E-Mail</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>
<span class="muted" style="margin-left:auto; font-size: 9.5pt;">(falls zutreffend ausfüllen)</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 Abstand von (zutreffendes bitte ankreuzen)
</p>
<div class="checkline" style="margin-top: 2px;">
<span class="check"><span class="checkbox {{intervalMonthlyClass}}"></span> 1 Monat</span>
<span class="check"><span class="checkbox {{intervalTwoMonthlyClass}}"></span> 2 Monate</span>
<span class="check"><span class="checkbox {{intervalQuarterlyClass}}"></span> 3 Monate</span>
</div>
<p class="para">fakturiert und innerhalb von drei bis fünf Werktagen an den Kunden geliefert.</p>
</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="pageBreak"></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>
<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>
<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 class="pageBreak"></div>
<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>
<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>
<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>
<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 class="pageBreak"></div>
<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

@ -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;
@ -32,6 +32,33 @@ 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 +84,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 +156,7 @@ export default function useContractManagement() {
return {} as T; return {} as T;
} }
}, },
[base] [base, getState]
); );
// Document templates // Document templates
@ -154,7 +197,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 +214,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 +234,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 +261,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 +280,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]);

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

@ -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

@ -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="flex items-start justify-between gap-3">
<div className="font-semibold">60 piece abo</div> <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="flex items-start justify-between gap-3">
<div className="font-semibold">120 piece abo</div> <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

@ -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

@ -1,19 +1,27 @@
'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 { useAboActiveContractHtml } from './hooks/useAboActiveContractHtml'
import SignaturePad from './components/SignaturePad'
const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette
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 } = useAboActiveContractHtml()
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 [isForSelf, setIsForSelf] = useState(true);
const [signatureDataUrl, setSignatureDataUrl] = useState('')
const [form, setForm] = useState({ const [form, setForm] = useState({
firstName: '', firstName: '',
lastName: '', lastName: '',
@ -34,7 +42,7 @@ export default function SummaryPage() {
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)
useEffect(() => { useEffect(() => {
try { try {
@ -96,18 +104,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,8 +147,20 @@ 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 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 taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]);
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxRate, taxAmount]); const taxAmountWithShipping = useMemo(() => netWithShipping * taxRate, [netWithShipping, taxRate]);
const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
@ -432,6 +453,43 @@ export default function SummaryPage() {
</> </>
)} )}
</div> </div>
{/* Contract preview + signature (frontend only for now) */}
<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">
This is the currently active ABO contract template for your account.
</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>
) : contractHtml ? (
<div className="rounded-lg border border-gray-300 bg-white overflow-hidden">
<iframe
title="ABO Contract Preview"
className="w-full h-[520px]"
srcDoc={contractHtml}
sandbox="allow-same-origin"
referrerPolicy="no-referrer"
/>
</div>
) : (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
No active ABO contract is available.
</div>
)}
<div className="mt-4">
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} />
</div>
</div>
<button <button
onClick={submit} onClick={submit}
disabled={!canSubmit || submitLoading} disabled={!canSubmit || submitLoading}
@ -469,13 +527,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>

View File

@ -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 () => {
@ -141,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">
@ -193,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,
UserCircleIcon,
};
const goTo = (href: string) => {
const trimmed = (href || '').trim();
if (!trimmed) return;
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
window.location.href = trimmed;
return; return;
} }
router.push(trimmed);
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)
useEffect(() => {
if (isMobile) return;
const resetHover = () => setIsHover(false);
window.addEventListener('wheel', resetHover, { passive: true });
window.addEventListener('scroll', resetHover, { passive: true });
return () => {
window.removeEventListener('wheel', resetHover);
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>
) : (
<SplitText
key={isHover ? 'login' : 'profit-planet'}
text={isHover ? 'LOGIN' : 'PROFIT PLANET'}
tag="span"
className={`text-7xl sm:text-8xl md:text-9xl font-bold transition-colors duration-300 ${
isHover ? 'text-black' : 'text-gray-500'
}`}
delay={100}
duration={0.6}
ease="power3.out"
splitType="chars"
from={{ opacity: 0, y: 40 }}
to={{ opacity: 1, y: 0 }}
threshold={0.1}
rootMargin="-100px"
textAlign="center"
/> />
)} <div className="min-w-0 flex-1">
</a> <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">
Welcome
</div>
<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">
Profit Planet
</h1> </h1>
<p className="mt-3 text-sm sm:text-base text-gray-700">
Pick a platform to continue.
</p>
</div>
</div>
</div>
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
<div className="flex items-start justify-between gap-4">
<div>
<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>
</div>
<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

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