Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev
This commit is contained in:
commit
886919e4dc
360
src/app/coffee-abonnements/page.tsx
Normal file
360
src/app/coffee-abonnements/page.tsx
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import PageLayout from '../components/PageLayout';
|
||||||
|
|
||||||
|
interface Coffee {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
pricePer10: number; // price for 10 units
|
||||||
|
image: string; // added
|
||||||
|
}
|
||||||
|
|
||||||
|
const coffees: Coffee[] = [
|
||||||
|
{ id: 'espresso', name: 'Espresso Roast', description: 'Kräftig und intensiv – dunkle Schokolade, geröstete Gewürze und ein langer, satt-cremiger Nachhall. Ideal für morgens oder den energiereichen Nachmittagsschub.', pricePer10: 12, image: 'https://images.unsplash.com/photo-1509042239860-f550ce710b93?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'crema', name: 'Caffè Crema', description: 'Ausgewogen & mild – samtiger Körper mit hellen Honig- und Mandelnoten. Ein sanfter, alltagstauglicher Kaffee für jede Uhrzeit und jeden Geschmack.', pricePer10: 11, image: 'https://images.unsplash.com/photo-1524079429932-1e9f39f1f268?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'colombia', name: 'Colombia Single Origin', description: 'Fruchtige Noten – rote Beeren, leichte Zitrus-Säure und ein sauberer, heller Abgang. Gewaschen auf Höhenlagen, perfekt für Filter und French Press.', pricePer10: 13, image: 'https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'ethiopia', name: 'Ethiopia Sidamo', description: 'Florale Eleganz – Jasmin, Bergamotte und eine feine Tee-ähnliche Struktur. Komplex und duftend, ideal für Genießer, die Nuancen entdecken möchten.', pricePer10: 14, image: 'https://images.unsplash.com/photo-1510626176961-4b57d4b273c4?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'sumatra', name: 'Sumatra Mandheling', description: 'Würzig & erdig – tiefer Körper, Kakao, Kräuter und dezente Tabaknoten. Vollmundig und schwer, hervorragend für Espresso-Mischungen.', pricePer10: 13, image: 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'kenya', name: 'Kenya AA', description: 'Lebendige Säure – schwarze Johannisbeere, saftige Traube und spritzige Frische. Klar konturiert und kraftvoll, toll als Pour Over.', pricePer10: 15, image: 'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'brazil', name: 'Brazil Santos', description: 'Nussig & schokoladig – weiche Textur, geröstete Haselnuss und Milchschokolade. Sehr balanciert und vielseitig für Espresso & Milchgetränke.', pricePer10: 11, image: 'https://images.unsplash.com/photo-1509042239860-f550ce710b93?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'italian', name: 'Italian Roast', description: 'Dunkel & ölig – karamellisierte Bitterstoffe, Rauch und intensive Tiefe. Klassisches Profil für kräftigen Cappuccino oder Latte.', pricePer10: 12, image: 'https://images.unsplash.com/photo-1512568400610-62da28bc8a13?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'house', name: 'House Blend', description: 'Harmonisch & rund – milde Süße, ausgewogene Säure und sanfter Körper. Entwickelt als universeller Alltagskaffee für jeden Zubereitungsstil.', pricePer10: 10, image: 'https://images.unsplash.com/photo-1461988091159-110d53f20702?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'decaf', name: 'Decaf Blend', description: 'Voller Geschmack – ohne Koffein. Kakao, leichte Karamell-Noten und sanfter Abgang. Entkoffeiniert im schonenden CO₂-Verfahren.', pricePer10: 12, image: 'https://images.unsplash.com/photo-1497935586351-b67a49e012bf?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'guatemala', name: 'Guatemala Huehuetenango', description: 'Karamell & Kakao – balanciert mit einer feinen Pflaumenfrucht. Angenehme Süße und klare Struktur – großartig als Filter und Chemex.', pricePer10: 14, image: 'https://images.unsplash.com/photo-1509978778153-491c9a09a1ab?auto=format&fit=crop&w=400&q=60' },
|
||||||
|
{ id: 'limited', name: 'Limited Reserve', description: 'Exklusiv & komplex – limitierte Mikrolots mit seltenen floralen und tropischen Noten. Für besondere Momente und anspruchsvolle Tassenprofile.', pricePer10: 18, image: 'https://images.unsplash.com/photo-1541167767034-41d7a96c58f1?auto=format&fit=crop&w=400&q=60' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CoffeeAbonnementPage() {
|
||||||
|
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||||
|
const [bump, setBump] = useState<Record<string, boolean>>({}); // added: bump animation flags
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
street: '',
|
||||||
|
postalCode: '',
|
||||||
|
city: '',
|
||||||
|
frequency: 'monatlich',
|
||||||
|
startDate: ''
|
||||||
|
});
|
||||||
|
const TAX_RATE = 0.07; // 7% Steuer
|
||||||
|
|
||||||
|
const selectedEntries = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(selections).map(([id, qty]) => ({
|
||||||
|
coffee: coffees.find(c => c.id === id)!,
|
||||||
|
quantity: qty
|
||||||
|
})),
|
||||||
|
[selections]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPrice = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedEntries.reduce(
|
||||||
|
(sum, entry) => sum + (entry.quantity / 10) * entry.coffee.pricePer10,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
[selectedEntries]
|
||||||
|
);
|
||||||
|
const taxAmount = useMemo(() => totalPrice * TAX_RATE, [totalPrice]);
|
||||||
|
const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxAmount]);
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
selectedEntries.length > 0 &&
|
||||||
|
Object.values(form).every(v => v.trim() !== '');
|
||||||
|
|
||||||
|
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setForm(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCoffee = (id: string) => {
|
||||||
|
setSelections(prev => {
|
||||||
|
const copy = { ...prev };
|
||||||
|
if (id in copy) {
|
||||||
|
delete copy[id];
|
||||||
|
} else {
|
||||||
|
copy[id] = 10; // default start quantity
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeQuantity = (id: string, delta: number) => {
|
||||||
|
setSelections(prev => {
|
||||||
|
if (!(id in prev)) return prev;
|
||||||
|
const next = prev[id] + delta;
|
||||||
|
if (next < 10 || next > 120) return prev;
|
||||||
|
const updated = { ...prev, [id]: next };
|
||||||
|
setBump(b => ({ ...b, [id]: true })); // trigger bump animation
|
||||||
|
setTimeout(() => setBump(b => ({ ...b, [id]: false })), 250);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-8 space-y-10 bg-white">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Kaffee Abonnement konfigurieren</h1>
|
||||||
|
|
||||||
|
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">1. Kaffeesorten & Mengen wählen</h2>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{coffees.map(coffee => {
|
||||||
|
const active = coffee.id in selections;
|
||||||
|
const qty = selections[coffee.id] || 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={coffee.id}
|
||||||
|
className={`group rounded-xl border p-4 shadow-sm transition ${
|
||||||
|
active ? 'border-emerald-600 bg-emerald-50 shadow-md' : 'border-gray-200 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative overflow-hidden rounded-md mb-3">
|
||||||
|
<img
|
||||||
|
src={coffee.image}
|
||||||
|
alt={coffee.name}
|
||||||
|
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{/* CHANGED: enhanced price badge */}
|
||||||
|
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
|
||||||
|
<span
|
||||||
|
aria-label={`Preis €${coffee.pricePer10} pro 10 Stück`}
|
||||||
|
className={`relative inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm transition-transform group-hover:scale-105 ${
|
||||||
|
active ? 'bg-emerald-600' : 'bg-emerald-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
€{coffee.pricePer10}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 border border-emerald-300 shadow-sm">
|
||||||
|
pro 10 Stk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<h3 className="font-semibold text-sm">{coffee.name}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-600 leading-relaxed">
|
||||||
|
{coffee.description}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCoffee(coffee.id)}
|
||||||
|
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
||||||
|
active
|
||||||
|
? 'border-emerald-600 text-emerald-700 bg-white hover:bg-emerald-100'
|
||||||
|
: 'border-gray-300 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active ? 'Entfernen' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
{active && (
|
||||||
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-medium text-gray-500">Menge (10–120)</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center rounded-full bg-emerald-600 text-white px-3 py-1 text-xs font-semibold transition-transform duration-300 ${
|
||||||
|
bump[coffee.id] ? 'scale-110' : 'scale-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{qty} Stk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => changeQuantity(coffee.id, -10)}
|
||||||
|
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||||
|
>
|
||||||
|
-10
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={10}
|
||||||
|
max={120}
|
||||||
|
step={10}
|
||||||
|
value={qty}
|
||||||
|
onChange={e =>
|
||||||
|
changeQuantity(
|
||||||
|
coffee.id,
|
||||||
|
parseInt(e.target.value, 10) - qty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full appearance-none cursor-pointer bg-transparent"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to right,#059669 0%,#059669 ' +
|
||||||
|
((qty - 10) / (120 - 10)) * 100 +
|
||||||
|
'%,#e5e7eb ' +
|
||||||
|
((qty - 10) / (120 - 10)) * 100 +
|
||||||
|
'%,#e5e7eb 100%)',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '999px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => changeQuantity(coffee.id, +10)}
|
||||||
|
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
||||||
|
>
|
||||||
|
+10
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-gray-500">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span className="font-semibold text-gray-700">
|
||||||
|
€{((qty / 10) * coffee.pricePer10).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 2: Form (light styling) */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">2. Deine Daten</h2>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Vorname</label>
|
||||||
|
<input
|
||||||
|
name="firstName"
|
||||||
|
value={form.firstName}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Nachname</label>
|
||||||
|
<input
|
||||||
|
name="lastName"
|
||||||
|
value={form.lastName}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">E-Mail</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">Straße & Nr.</label>
|
||||||
|
<input
|
||||||
|
name="street"
|
||||||
|
value={form.street}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">PLZ</label>
|
||||||
|
<input
|
||||||
|
name="postalCode"
|
||||||
|
value={form.postalCode}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Stadt</label>
|
||||||
|
<input
|
||||||
|
name="city"
|
||||||
|
value={form.city}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Lieferintervall</label>
|
||||||
|
<select
|
||||||
|
name="frequency"
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="monatlich">Monatlich</option>
|
||||||
|
<option value="zweimonatlich">Alle 2 Monate</option>
|
||||||
|
<option value="vierteljährlich">Vierteljährlich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Startdatum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="startDate"
|
||||||
|
value={form.startDate}
|
||||||
|
onChange={handleInput}
|
||||||
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 3: Summary (light styling) */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">3. Zusammenfassung</h2>
|
||||||
|
<div className="rounded-lg border border-gray-200 p-6 bg-white space-y-4 shadow-sm">
|
||||||
|
{selectedEntries.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-600">Noch keine Kaffees ausgewählt.</p>
|
||||||
|
)}
|
||||||
|
{selectedEntries.map(entry => (
|
||||||
|
<div
|
||||||
|
key={entry.coffee.id}
|
||||||
|
className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{entry.coffee.name}</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{entry.quantity} Stk • <span className="inline-flex items-center font-semibold text-emerald-700">€{entry.coffee.pricePer10}/10</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right font-semibold">
|
||||||
|
€{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-between pt-2 border-t">
|
||||||
|
<span className="text-sm font-semibold">Gesamt (Netto)</span>
|
||||||
|
<span className="text-lg font-bold">€{totalPrice.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm">Steuer ({(TAX_RATE * 100).toFixed(0)}%)</span>
|
||||||
|
<span className="text-sm font-medium">€{taxAmount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm font-semibold">Gesamt inkl. Steuer</span>
|
||||||
|
<span className="text-lg font-bold">€{totalWithTax.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={`w-full mt-2 rounded-md px-4 py-3 font-semibold transition ${
|
||||||
|
canSubmit
|
||||||
|
? 'bg-emerald-600 text-white hover:bg-emerald-500'
|
||||||
|
: 'bg-gray-300 text-gray-600 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Abonnement abschließen
|
||||||
|
</button>
|
||||||
|
{!canSubmit && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Bitte mindestens eine Kaffeesorte wählen und alle Felder ausfüllen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -25,7 +25,7 @@ export default function PageLayout({
|
|||||||
const isMobile = isMobileDevice();
|
const isMobile = isMobileDevice();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full flex flex-col bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors">
|
<div className="min-h-screen w-full flex flex-col bg-white text-gray-900">
|
||||||
|
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<div className="relative z-50 w-full flex-shrink-0">
|
<div className="relative z-50 w-full flex-shrink-0">
|
||||||
|
|||||||
@ -46,6 +46,11 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
const [selectedStatus, setSelectedStatus] = useState<UserStatus>('pending')
|
const [selectedStatus, setSelectedStatus] = useState<UserStatus>('pending')
|
||||||
const token = useAuthStore(state => state.accessToken)
|
const token = useAuthStore(state => state.accessToken)
|
||||||
|
|
||||||
|
// Contract preview state (lazy-loaded)
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && userId && token) {
|
if (isOpen && userId && token) {
|
||||||
fetchUserDetails()
|
fetchUserDetails()
|
||||||
@ -133,6 +138,22 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadContractPreview = async () => {
|
||||||
|
if (!userId || !token || !userDetails) return
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setPreviewError(null)
|
||||||
|
try {
|
||||||
|
const html = await AdminAPI.getContractPreviewHtml(token, String(userId), userDetails.user.user_type)
|
||||||
|
setPreviewHtml(html)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('UserDetailModal.loadContractPreview error:', e)
|
||||||
|
setPreviewError(e?.message || 'Failed to load contract preview')
|
||||||
|
setPreviewHtml(null)
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string | undefined | null) => {
|
const formatDate = (dateString: string | undefined | null) => {
|
||||||
if (!dateString) return 'N/A'
|
if (!dateString) return 'N/A'
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
@ -367,6 +388,63 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Contract Preview (admin verify flow) */}
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
|
||||||
|
Contract Preview
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadContractPreview}
|
||||||
|
disabled={previewLoading}
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{previewLoading ? 'Loading…' : (previewHtml ? 'Refresh Preview' : 'Load Preview')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!previewHtml) return
|
||||||
|
const blob = new Blob([previewHtml], { type: 'text/html' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}}
|
||||||
|
disabled={!previewHtml}
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Open in new tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
{previewError && (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 mb-4">
|
||||||
|
{previewError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{previewLoading && (
|
||||||
|
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
|
||||||
|
Loading preview…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!previewLoading && previewHtml && (
|
||||||
|
<div className="rounded-md border border-gray-200 overflow-hidden">
|
||||||
|
<iframe
|
||||||
|
title="Contract Preview"
|
||||||
|
className="w-full h-[600px] bg-white"
|
||||||
|
srcDoc={previewHtml}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!previewLoading && !previewHtml && !previewError && (
|
||||||
|
<p className="text-sm text-gray-500">Click "Load Preview" to render the latest active contract template for this user.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Profile Information */}
|
{/* Profile Information */}
|
||||||
{userDetails.user.user_type === 'personal' && userDetails.personalProfile && (
|
{userDetails.user.user_type === 'personal' && userDetails.personalProfile && (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import PageLayout from '../../../components/PageLayout'
|
import PageLayout from '../../../components/PageLayout'
|
||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
import { useUserStatus } from '../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../hooks/useUserStatus'
|
||||||
|
import { API_BASE_URL } from '../../../utils/api'
|
||||||
|
|
||||||
export default function CompanySignContractPage() {
|
export default function CompanySignContractPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -23,11 +24,43 @@ export default function CompanySignContractPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDate(new Date().toISOString().slice(0,10))
|
setDate(new Date().toISOString().slice(0,10))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load latest contract preview for company user
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPreview = async () => {
|
||||||
|
if (!accessToken) return
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setPreviewError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(text || 'Failed to load contract preview')
|
||||||
|
}
|
||||||
|
const html = await res.text()
|
||||||
|
setPreviewHtml(html)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('CompanySignContractPage.loadPreview error:', e)
|
||||||
|
setPreviewError(e?.message || 'Failed to load contract preview')
|
||||||
|
setPreviewHtml(null)
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadPreview()
|
||||||
|
}, [accessToken])
|
||||||
|
|
||||||
const valid = () => {
|
const valid = () => {
|
||||||
const companyValid = companyName.trim().length >= 3 // Min 3 characters for company name
|
const companyValid = companyName.trim().length >= 3 // Min 3 characters for company name
|
||||||
const repNameValid = repName.trim().length >= 3 // Min 3 characters for representative name
|
const repNameValid = repName.trim().length >= 3 // Min 3 characters for representative name
|
||||||
@ -183,15 +216,64 @@ export default function CompanySignContractPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="rounded-lg border border-gray-200 h-64 sm:h-72 bg-white flex items-center justify-center relative overflow-hidden">
|
<div className="rounded-lg border border-gray-200 bg-white relative overflow-hidden">
|
||||||
<p className="text-xs text-gray-500 text-center px-6">
|
<div className="flex items-center justify-between p-3 border-b border-gray-200 bg-gray-50">
|
||||||
(Vertragsvorschau / PDF Platzhalter)
|
<h3 className="text-sm font-semibold text-gray-900">Vertragsvorschau (Unternehmen)</h3>
|
||||||
<br/>Company Contract PDF would render here.
|
<div className="flex items-center gap-2">
|
||||||
</p>
|
<button
|
||||||
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-white via-white/90 to-transparent text-[11px] text-gray-500 text-center">
|
type="button"
|
||||||
Scroll preview (disabled in mock)
|
onClick={() => {
|
||||||
|
if (!previewHtml) return
|
||||||
|
const blob = new Blob([previewHtml], { type: 'text/html' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}}
|
||||||
|
disabled={!previewHtml}
|
||||||
|
className="inline-flex items-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-2.5 py-1.5 text-xs disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Open in new tab
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!accessToken) return
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setPreviewError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(text || 'Failed to reload preview')
|
||||||
|
}
|
||||||
|
const html = await res.text()
|
||||||
|
setPreviewHtml(html)
|
||||||
|
} catch (e: any) {
|
||||||
|
setPreviewError(e?.message || 'Failed to reload preview')
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={previewLoading}
|
||||||
|
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-2.5 py-1.5 text-xs disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{previewLoading ? 'Lade…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{previewLoading ? (
|
||||||
|
<div className="h-72 flex items-center justify-center text-xs text-gray-500">Lade Vorschau…</div>
|
||||||
|
) : previewError ? (
|
||||||
|
<div className="h-72 flex items-center justify-center text-xs text-red-600 px-4 text-center">{previewError}</div>
|
||||||
|
) : previewHtml ? (
|
||||||
|
<iframe title="Company Contract Preview" className="w-full h-72" srcDoc={previewHtml} />
|
||||||
|
) : (
|
||||||
|
<div className="h-72 flex items-center justify-center text-xs text-gray-500">Keine Vorschau verfügbar.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import PageLayout from '../../../components/PageLayout'
|
import PageLayout from '../../../components/PageLayout'
|
||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
import { useUserStatus } from '../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../hooks/useUserStatus'
|
||||||
|
import { API_BASE_URL } from '../../../utils/api'
|
||||||
|
|
||||||
export default function PersonalSignContractPage() {
|
export default function PersonalSignContractPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -21,11 +22,42 @@ export default function PersonalSignContractPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDate(new Date().toISOString().slice(0, 10))
|
setDate(new Date().toISOString().slice(0, 10))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load latest contract preview for personal user
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
if (!accessToken) return
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setPreviewError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(text || 'Failed to load contract preview')
|
||||||
|
}
|
||||||
|
const html = await res.text()
|
||||||
|
setPreviewHtml(html)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('PersonalSignContractPage.loadPreview error:', e)
|
||||||
|
setPreviewError(e?.message || 'Failed to load contract preview')
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [accessToken])
|
||||||
|
|
||||||
const valid = () => {
|
const valid = () => {
|
||||||
const nameValid = fullName.trim().length >= 3 // Min 3 characters for name
|
const nameValid = fullName.trim().length >= 3 // Min 3 characters for name
|
||||||
const locationValid = location.trim().length >= 2 // Min 2 characters for location
|
const locationValid = location.trim().length >= 2 // Min 2 characters for location
|
||||||
@ -175,15 +207,35 @@ export default function PersonalSignContractPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="rounded-lg border border-gray-200 h-64 sm:h-72 bg-white relative flex items-center justify-center overflow-hidden">
|
<div className="rounded-lg border border-gray-200 bg-white relative overflow-hidden">
|
||||||
<p className="text-xs text-gray-500 text-center px-6">
|
<div className="flex items-center justify-between p-3 border-b border-gray-200 bg-gray-50">
|
||||||
(Vertragsvorschau / PDF Platzhalter)
|
<h3 className="text-sm font-semibold text-gray-900">Vertragsvorschau</h3>
|
||||||
<br/>Der vollständige Vertrag wird hier als PDF gerendert.
|
<div className="flex items-center gap-2">
|
||||||
</p>
|
<button
|
||||||
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-white via-white/90 to-transparent text-[11px] text-gray-500 text-center">
|
type="button"
|
||||||
Scroll preview (disabled in mock)
|
onClick={async () => {
|
||||||
|
if (!previewHtml) return
|
||||||
|
const blob = new Blob([previewHtml], { type: 'text/html' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}}
|
||||||
|
disabled={!previewHtml}
|
||||||
|
className="inline-flex items-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-2.5 py-1.5 text-xs disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Open in new tab
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{previewLoading ? (
|
||||||
|
<div className="h-72 flex items-center justify-center text-xs text-gray-500">Lade Vorschau…</div>
|
||||||
|
) : previewError ? (
|
||||||
|
<div className="h-72 flex items-center justify-center text-xs text-red-600 px-4 text-center">{previewError}</div>
|
||||||
|
) : previewHtml ? (
|
||||||
|
<iframe title="Contract Preview" className="w-full h-72" srcDoc={previewHtml} />
|
||||||
|
) : (
|
||||||
|
<div className="h-72 flex items-center justify-center text-xs text-gray-500">Keine Vorschau verfügbar.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -46,12 +46,16 @@ export const API_ENDPOINTS = {
|
|||||||
ADMIN_UPDATE_USER_VERIFICATION: '/api/admin/update-verification/:id',
|
ADMIN_UPDATE_USER_VERIFICATION: '/api/admin/update-verification/:id',
|
||||||
ADMIN_UPDATE_USER_PROFILE: '/api/admin/update-user-profile/:id',
|
ADMIN_UPDATE_USER_PROFILE: '/api/admin/update-user-profile/:id',
|
||||||
ADMIN_UPDATE_USER_STATUS: '/api/admin/update-user-status/:id',
|
ADMIN_UPDATE_USER_STATUS: '/api/admin/update-user-status/:id',
|
||||||
|
<<<<<<< HEAD
|
||||||
// Coffee products (admin)
|
// Coffee products (admin)
|
||||||
ADMIN_COFFEE_LIST: '/api/admin/coffee',
|
ADMIN_COFFEE_LIST: '/api/admin/coffee',
|
||||||
ADMIN_COFFEE_CREATE: '/api/admin/coffee',
|
ADMIN_COFFEE_CREATE: '/api/admin/coffee',
|
||||||
ADMIN_COFFEE_UPDATE: '/api/admin/coffee/:id',
|
ADMIN_COFFEE_UPDATE: '/api/admin/coffee/:id',
|
||||||
ADMIN_COFFEE_SET_STATE: '/api/admin/coffee/:id/state',
|
ADMIN_COFFEE_SET_STATE: '/api/admin/coffee/:id/state',
|
||||||
ADMIN_COFFEE_DELETE: '/api/admin/coffee/:id',
|
ADMIN_COFFEE_DELETE: '/api/admin/coffee/:id',
|
||||||
|
=======
|
||||||
|
ADMIN_CONTRACT_PREVIEW: '/api/admin/contracts/:id/preview',
|
||||||
|
>>>>>>> 757b530e14a4c81bd8a54ae569dab647f906421a
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Helper Functions
|
// API Helper Functions
|
||||||
@ -437,6 +441,19 @@ export class AdminAPI {
|
|||||||
const resp = await ApiClient.delete(endpoint, token)
|
const resp = await ApiClient.delete(endpoint, token)
|
||||||
if (!resp.ok) throw new Error('Failed to delete product')
|
if (!resp.ok) throw new Error('Failed to delete product')
|
||||||
return true
|
return true
|
||||||
|
static async getContractPreviewHtml(token: string, userId: string, userType?: 'personal' | 'company') {
|
||||||
|
let endpoint = API_ENDPOINTS.ADMIN_CONTRACT_PREVIEW.replace(':id', userId)
|
||||||
|
if (userType) {
|
||||||
|
const qs = new URLSearchParams({ userType }).toString()
|
||||||
|
endpoint += `?${qs}`
|
||||||
|
}
|
||||||
|
const response = await ApiClient.get(endpoint, token)
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Failed to fetch contract preview' }))
|
||||||
|
throw new Error(error.message || 'Failed to fetch contract preview')
|
||||||
|
}
|
||||||
|
// Return HTML string
|
||||||
|
return response.text()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user