Bausatz

import React, { useMemo, useState } from „react“;
import { Card, CardContent } from „@/components/ui/card“;
import { Button } from „@/components/ui/button“;
import { Input } from „@/components/ui/input“;

const ZEITFENSTER = [„Morgen / Vormittag“, „Nachmittag“, „Abend“];

const startBlocks = [
{
id: „B01“,
title: „Anreise & Ankommen“,
category: „Organisation“,
description: „Individuelle Anreise, Parken am Hotel und Check-in im Biergasthof Riedberg.“,
marketingText: „Entspannt ankommen, das Auto abstellen und im Biergasthof Riedberg in der Bierregion Innviertel einchecken.“,
durationMinutes: 60,
allowedSlots: [„Morgen / Vormittag“, „Nachmittag“],
price: 0,
imageUrl: „“,
},
{
id: „B02“,
title: „Willkommensbier“,
category: „Bierkultur“,
description: „Gemeinsames Ankommen, Begrüßungsbier und erste Einstimmung auf die Bierreise.“,
marketingText: „Mit einem frisch eingeschenkten Bier beginnt die gemeinsame Zeit sinnvoll vergeudet – herzlich, unkompliziert und mit Innviertler Gastlichkeit.“,
durationMinutes: 30,
allowedSlots: [„Nachmittag“, „Abend“],
price: 5,
imageUrl: „https://www.riedberg.at/wp-content/uploads/2025/05/tischgesellschaft-prost-mehr-lachend.jpg“,
},
{
id: „B03“,
title: „Bierkeller & Bierkultur“,
category: „Bierkultur“,
description: „Bierkellerführung, Einführung in die Bierkultur und Geschichten rund ums Bier.“,
marketingText: „Ein Blick in den einzigartigen Bierkeller mit mehr als 500 Bieren aus aller Welt – geführt, erzählt und mit echter Bierleidenschaft vermittelt.“,
durationMinutes: 60,
allowedSlots: [„Nachmittag“, „Abend“],
price: 12,
imageUrl: „https://www.riedberg.at/wp-content/uploads/2025/02/Bierstachln-6kl.jpg“,
},
{
id: „B04“,
title: „Geführte Bierverkostung“,
category: „Bierkultur“,
description: „Verkostung mehrerer Bierstile mit Bierwissen, Sensorik und Geschichten.“,
marketingText: „Diplom-Biersommelier Karl Zuser jun. führt durch ausgewählte Bierstile, Aromen und Geschichten – fachlich fundiert und mit Schmäh.“,
durationMinutes: 90,
allowedSlots: [„Nachmittag“, „Abend“],
price: 19.9,
imageUrl: „https://www.riedberg.at/wp-content/uploads/2021/07/bierverkostung-bierkeller-karl-zuser-diplom-biersommelier-riedberg.webp“,
},
{
id: „B05“,
title: „Bierdegustationsmenü“,
category: „Kulinarik“,
description: „Mehrgängiges Biermenü mit passender Bierbegleitung.“,
marketingText: „Bier und Küche treffen sich am Teller und im Glas: ein genussvoller Abend mit passenden Bierbegleitungen und regionaler Wirtshausküche.“,
durationMinutes: 150,
allowedSlots: [„Abend“],
price: 59,
imageUrl: „“,
},
{
id: „B06“,
title: „Innviertler Wirtshausabend“,
category: „Kulinarik“,
description: „Gemütlicher Abend mit Innviertler Schmankerln, Wirtshauskultur und Gemeinschaft.“,
marketingText: „Ein ehrlicher Wirtshausabend mit regionaler Küche, Bier im Glas und gemeinsamer Zeit am Tisch.“,
durationMinutes: 120,
allowedSlots: [„Abend“],
price: 29,
imageUrl: „https://www.riedberg.at/wp-content/uploads/2025/05/tischgesellschaft-prost-mehr-lachend.jpg“,
},
{
id: „B07“,
title: „Bierfrühstück“,
category: „Hotel“,
description: „Frühstücksbuffet mit regionalen Zutaten in der Bierregion Innviertel.“,
marketingText: „Regional frühstücken und gestärkt in den nächsten Reisetag starten.“,
durationMinutes: 60,
allowedSlots: [„Morgen / Vormittag“],
price: 15,
imageUrl: „“,
},
{
id: „B08“,
title: „Bierstile entdecken“,
category: „Bierkultur“,
description: „Unterschiedliche Bierstile, internationale Biere, Aromen und Biercharaktere.“,
marketingText: „Von klassischen Stilen bis zu besonderen Raritäten: ein kompakter Einstieg in die Vielfalt der Bierwelt.“,
durationMinutes: 60,
allowedSlots: [„Nachmittag“, „Abend“],
price: 14,
imageUrl: „https://www.riedberg.at/wp-content/uploads/2025/02/Bierstachln-6kl.jpg“,
},
{
id: „B09“,
title: „Biergeschenke & Souvenirs“,
category: „Souvenir“,
description: „Biergeschenke, Bierschokolade, Bierpralinen oder Bierverkostungsratgeber.“,
marketingText: „Zum Mitnehmen, Weiterschenken oder Selbergenießen: bierige Erinnerungen aus dem Biergasthof Riedberg.“,
durationMinutes: 30,
allowedSlots: [„Morgen / Vormittag“, „Nachmittag“],
price: 10,
imageUrl: „“,
},
{
id: „B10“,
title: „Abschluss & Abreise“,
category: „Organisation“,
description: „Gemeinsamer Abschluss, letzter Blick in den Bierkeller und Heimreise.“,
marketingText: „Ein runder Abschluss, vielleicht noch ein Souvenir, und dann geht es mit guten Erinnerungen heimwärts.“,
durationMinutes: 30,
allowedSlots: [„Morgen / Vormittag“],
price: 0,
imageUrl: „“,
},
];

const templateOptions = [
{
id: „leer“,
name: „Leerer Reiseplan“,
nights: 1,
assignments: [],
},
{
id: „schnupper“,
name: „Bier.Schnupperwochenende“,
nights: 2,
assignments: [
{ dayIndex: 0, slot: „Nachmittag“, blockId: „B01“ },
{ dayIndex: 0, slot: „Nachmittag“, blockId: „B02“ },
{ dayIndex: 0, slot: „Abend“, blockId: „B05“ },
{ dayIndex: 1, slot: „Morgen / Vormittag“, blockId: „B07“ },
{ dayIndex: 1, slot: „Nachmittag“, blockId: „B03“ },
{ dayIndex: 1, slot: „Nachmittag“, blockId: „B04“ },
{ dayIndex: 1, slot: „Abend“, blockId: „B06“ },
{ dayIndex: 2, slot: „Morgen / Vormittag“, blockId: „B07“ },
{ dayIndex: 2, slot: „Morgen / Vormittag“, blockId: „B09“ },
{ dayIndex: 2, slot: „Morgen / Vormittag“, blockId: „B10“ },
],
},
];

function euro(value) {
return new Intl.NumberFormat(„de-AT“, { style: „currency“, currency: „EUR“ }).format(Number(value || 0));
}

function minutesToText(minutes) {
const value = Number(minutes || 0);
if (!value) return „0 Min.“;
if (value < 60) return `${value} Min.`;
const hours = Math.floor(value / 60);
const rest = value % 60;
return rest ? `${hours} Std. ${rest} Min.` : `${hours} Std.`;
}

function makeDays(nights) {
const numberOfNights = Math.max(1, Number(nights || 1));
const days = [];
for (let index = 0; index <= numberOfNights; index += 1) {
let label = „“;
if (index === 0) label = „Anreisetag“;
else if (index === numberOfNights) label = „Abreisetag“;
else label = `Aufenthaltstag ${index}`;

days.push({
id: `T${index + 1}`,
label,
slots: {
„Morgen / Vormittag“: [],
Nachmittag: [],
Abend: [],
},
});
}
return days;
}

function sumBlocks(items) {
return items.reduce((sum, block) => sum + Number(block.price || 0), 0);
}

function calculateGroupTotal(items, persons) {
return sumBlocks(items) * Number(persons || 0);
}

function makeInstance(block) {
return { …block, instanceId: `${block.id}-${Date.now()}-${Math.random()}` };
}

function runInternalTests() {
const testBlocks = [{ price: 5 }, { price: 19.9 }, { price: „10“ }, { price: 0 }];
const total = sumBlocks(testBlocks);
const groupTotal = calculateGroupTotal(testBlocks, 10);
const threeNights = makeDays(3);

console.assert(Math.abs(total – 34.9) < 0.0001, „sumBlocks sollte Preise korrekt summieren“);
console.assert(Math.abs(groupTotal – 349) < 0.0001, „calculateGroupTotal sollte Personen berücksichtigen“);
console.assert(euro(0).includes(„€“), „euro sollte Euro-Format ausgeben“);
console.assert(threeNights.length === 4, „3 Nächte sollten 4 Kalendertage ergeben“);
console.assert(threeNights[0].label === „Anreisetag“, „Erster Tag sollte Anreisetag sein“);
console.assert(threeNights[3].label === „Abreisetag“, „Letzter Tag sollte Abreisetag sein“);
}

runInternalTests();

export default function BierreiseBaukastenRechner() {
const [blocks, setBlocks] = useState(startBlocks);
const [persons, setPersons] = useState(10);
const [nights, setNights] = useState(2);
const [days, setDays] = useState(makeDays(2));
const [selectedTemplate, setSelectedTemplate] = useState(„leer“);
const [newBlock, setNewBlock] = useState({
title: „“,
category: „“,
description: „“,
marketingText: „“,
durationMinutes: „60“,
allowedSlots: „Nachmittag, Abend“,
price: „“,
imageUrl: „“,
});

const selectedBlocks = useMemo(() => {
return days.flatMap((day) => ZEITFENSTER.flatMap((slot) => day.slots[slot] || []));
}, [days]);

const totalPerPerson = sumBlocks(selectedBlocks);
const totalGroup = calculateGroupTotal(selectedBlocks, persons);
const totalDuration = selectedBlocks.reduce((sum, block) => sum + Number(block.durationMinutes || 0), 0);

function rebuildDays(nextNights) {
const cleanNights = Math.max(1, Number(nextNights || 1));
setNights(cleanNights);
setDays(makeDays(cleanNights));
setSelectedTemplate(„leer“);
}

function applyTemplate(templateId) {
const template = templateOptions.find((item) => item.id === templateId) || templateOptions[0];
setSelectedTemplate(templateId);
setNights(template.nights);

const nextDays = makeDays(template.nights);
template.assignments.forEach((assignment) => {
const block = blocks.find((item) => item.id === assignment.blockId);
const day = nextDays[assignment.dayIndex];
if (block && day && day.slots[assignment.slot]) {
day.slots[assignment.slot].push(makeInstance(block));
}
});
setDays(nextDays);
}

function onDragStart(event, block) {
event.dataTransfer.setData(„application/json“, JSON.stringify(block));
event.dataTransfer.effectAllowed = „copy“;
}

function onDrop(event, dayIndex, slot) {
event.preventDefault();
const payload = event.dataTransfer.getData(„application/json“);
if (!payload) return;

try {
const block = JSON.parse(payload);
const allowed = Array.isArray(block.allowedSlots) ? block.allowedSlots : [];
if (allowed.length > 0 && !allowed.includes(slot)) {
window.alert(`${block.title} passt laut Zeitfenster nicht in: ${slot}`);
return;
}

const instance = makeInstance(block);
setDays((oldDays) =>
oldDays.map((day, index) => {
if (index !== dayIndex) return day;
return {
…day,
slots: {
…day.slots,
[slot]: […day.slots[slot], instance],
},
};
})
);
} catch (error) {
console.error(„Baustein konnte nicht eingefügt werden.“, error);
}
}

function removeFromSlot(dayIndex, slot, instanceId) {
setDays((oldDays) =>
oldDays.map((day, index) => {
if (index !== dayIndex) return day;
return {
…day,
slots: {
…day.slots,
[slot]: day.slots[slot].filter((item) => item.instanceId !== instanceId),
},
};
})
);
}

function addCustomBlock() {
if (!newBlock.title.trim()) return;
const nextNumber = String(blocks.length + 1).padStart(2, „0“);
const allowedSlots = newBlock.allowedSlots
.split(„,“)
.map((item) => item.trim())
.filter(Boolean);

setBlocks([
…blocks,
{
id: `B${nextNumber}`,
title: newBlock.title.trim(),
category: newBlock.category.trim() || „Eigener Baustein“,
description: newBlock.description.trim(),
marketingText: newBlock.marketingText.trim(),
durationMinutes: Number(newBlock.durationMinutes || 0),
allowedSlots,
price: Number(newBlock.price || 0),
imageUrl: newBlock.imageUrl.trim(),
},
]);
setNewBlock({
title: „“,
category: „“,
description: „“,
marketingText: „“,
durationMinutes: „60“,
allowedSlots: „Nachmittag, Abend“,
price: „“,
imageUrl: „“,
});
}

function clearPlan() {
setDays(makeDays(nights));
setSelectedTemplate(„leer“);
}

function makeOfferText() {
const lines = [];
lines.push(`Angebotsskizze für ${persons || 0} Personen`);
lines.push(`Aufenthaltsdauer: ${nights} Nächte / ${days.length} Tage`);
lines.push(„“);

days.forEach((day) => {
lines.push(day.label.toUpperCase());
ZEITFENSTER.forEach((slot) => {
const items = day.slots[slot];
if (items.length === 0) return;
lines.push(`${slot}:`);
items.forEach((item) => {
lines.push(`- ${item.title}: ${item.marketingText || item.description}`);
});
});
lines.push(„“);
});

lines.push(`Summe pro Person: ${euro(totalPerPerson)}`);
lines.push(`Summe für die Gruppe: ${euro(totalGroup)}`);
return lines.join(„\n“);
}

return (
<div className=“min-h-screen bg-stone-50 p-6 text-stone-900″>
<div className=“mx-auto max-w-7xl space-y-6″>
<div className=“rounded-3xl bg-white p-6 shadow-sm“>
<p className=“text-sm uppercase tracking-wide text-stone-500″>Biergasthof Riedberg</p>
<h1 className=“mt-1 text-3xl font-bold“>Bierreise-Baukasten mit Kalkulation</h1>
<p className=“mt-2 max-w-3xl text-stone-600″>
Ein einfacher Entwurf für Angebote: Aufenthaltsdauer wählen, Personenanzahl eintragen, Bausteine in passende Zeitfenster ziehen und Kalkulation prüfen.
</p>
</div>

<Card className=“rounded-3xl shadow-sm“>
<CardContent className=“grid gap-4 p-5 md:grid-cols-4″>
<div>
<label className=“text-sm font-medium text-stone-600″>Personenanzahl</label>
<Input type=“number“ min=“1″ value={persons} onChange={(event) => setPersons(event.target.value)} />
</div>
<div>
<label className=“text-sm font-medium text-stone-600″>Aufenthaltsdauer in Nächten</label>
<Input type=“number“ min=“1″ max=“7″ value={nights} onChange={(event) => rebuildDays(event.target.value)} />
</div>
<div>
<label className=“text-sm font-medium text-stone-600″>Fixes Angebot</label>
<select
className=“mt-0 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm“
value={selectedTemplate}
onChange={(event) => applyTemplate(event.target.value)}
>
{templateOptions.map((template) => (
<option key={template.id} value={template.id}>{template.name}</option>
))}
</select>
</div>
<div className=“flex items-end gap-2″>
<Button variant=“outline“ className=“w-full rounded-2xl“ onClick={clearPlan}>Plan leeren</Button>
</div>
</CardContent>
</Card>

<div className=“grid gap-6 lg:grid-cols-[430px_1fr]“>
<div className=“space-y-4″>
<Card className=“rounded-3xl shadow-sm“>
<CardContent className=“p-5″>
<h2 className=“text-xl font-semibold“>Bausteine</h2>
<p className=“mb-4 text-sm text-stone-500″>Bausteine ziehen und im Reiseplan ablegen. Umlaute und Sonderzeichen sind normale UTF-8-Texte.</p>
<div className=“space-y-3″>
{blocks.map((block) => (
<div
key={block.id}
draggable
onDragStart={(event) => onDragStart(event, block)}
className=“cursor-grab rounded-2xl border bg-white p-4 shadow-sm active:cursor-grabbing“
>
<div className=“flex items-start gap-3″>
<span className=“mt-1 shrink-0 select-none text-stone-400″ title=“Ziehen“>⋮⋮</span>
<div className=“min-w-0 flex-1″>
<div className=“flex items-start justify-between gap-3″>
<div>
<p className=“text-xs font-semibold uppercase text-stone-500″>{block.id} · {block.category}</p>
<h3 className=“font-semibold“>{block.title}</h3>
</div>
<span className=“shrink-0 rounded-full bg-stone-100 px-3 py-1 text-sm font-medium“>{euro(block.price)}</span>
</div>
<p className=“mt-2 text-sm leading-relaxed text-stone-600″>{block.description}</p>
<p className=“mt-2 text-xs text-stone-500″>
Dauer: {minutesToText(block.durationMinutes)} · Zeitfenster: {block.allowedSlots.join(„, „) || „frei“}
</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>

<Card className=“rounded-3xl shadow-sm“>
<CardContent className=“space-y-3 p-5″>
<h2 className=“text-xl font-semibold“>Eigenen Baustein anlegen</h2>
<Input placeholder=“Titel, z. B. Brauereibesuch“ value={newBlock.title} onChange={(e) => setNewBlock({ …newBlock, title: e.target.value })} />
<Input placeholder=“Kategorie“ value={newBlock.category} onChange={(e) => setNewBlock({ …newBlock, category: e.target.value })} />
<Input placeholder=“Kurzbeschreibung“ value={newBlock.description} onChange={(e) => setNewBlock({ …newBlock, description: e.target.value })} />
<Input placeholder=“Marketingtext für Angebot“ value={newBlock.marketingText} onChange={(e) => setNewBlock({ …newBlock, marketingText: e.target.value })} />
<Input placeholder=“Dauer in Minuten“ type=“number“ value={newBlock.durationMinutes} onChange={(e) => setNewBlock({ …newBlock, durationMinutes: e.target.value })} />
<Input placeholder=“Zeitfenster, z. B. Nachmittag, Abend“ value={newBlock.allowedSlots} onChange={(e) => setNewBlock({ …newBlock, allowedSlots: e.target.value })} />
<Input placeholder=“Preis pro Person“ type=“number“ value={newBlock.price} onChange={(e) => setNewBlock({ …newBlock, price: e.target.value })} />
<Input placeholder=“Foto-URL“ value={newBlock.imageUrl} onChange={(e) => setNewBlock({ …newBlock, imageUrl: e.target.value })} />
<Button className=“w-full rounded-2xl“ onClick={addCustomBlock}>+ Baustein hinzufügen</Button>
</CardContent>
</Card>
</div>

<div className=“space-y-6″>
<Card className=“rounded-3xl shadow-sm“>
<CardContent className=“p-5″>
<h2 className=“text-xl font-semibold“>Reiseplan</h2>
<p className=“mb-4 text-sm text-stone-500″>Jeder Aufenthaltstag entsteht aus Nächten plus Abreisetag.</p>
<div className=“space-y-5″>
{days.map((day, dayIndex) => (
<div key={day.id} className=“rounded-3xl border bg-white p-4″>
<h3 className=“mb-4 text-lg font-bold“>{day.label}</h3>
<div className=“grid gap-4 xl:grid-cols-3″>
{ZEITFENSTER.map((slot) => (
<div
key={`${day.id}-${slot}`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => onDrop(event, dayIndex, slot)}
className=“min-h-40 rounded-3xl border-2 border-dashed border-stone-200 bg-stone-50 p-4″
>
<div className=“mb-3 flex items-center justify-between gap-3″>
<h4 className=“font-semibold“>{slot}</h4>
<span className=“text-xs text-stone-500″>Ablegen</span>
</div>
<div className=“space-y-3″>
{day.slots[slot].length === 0 && <p className=“text-sm text-stone-400″>Noch leer.</p>}
{day.slots[slot].map((item) => (
<div key={item.instanceId} className=“rounded-2xl bg-white p-3 shadow-sm“>
{item.imageUrl && (
<img src={item.imageUrl} alt={item.title} className=“mb-3 h-28 w-full rounded-xl object-cover“ />
)}
<div className=“flex items-start justify-between gap-3″>
<div>
<p className=“text-xs font-semibold uppercase text-stone-500″>{item.id} · {item.category}</p>
<h5 className=“font-semibold“>{item.title}</h5>
<p className=“mt-1 text-sm text-stone-600″>{item.marketingText || item.description}</p>
<p className=“mt-2 text-xs text-stone-500″>Dauer: {minutesToText(item.durationMinutes)}</p>
</div>
<div className=“flex shrink-0 flex-col items-end gap-2″>
<span className=“rounded-full bg-stone-100 px-3 py-1 text-sm font-medium“>{euro(item.price)}</span>
<Button variant=“ghost“ size=“sm“ onClick={() => removeFromSlot(dayIndex, slot, item.instanceId)} aria-label={`${item.title} entfernen`}>×</Button>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>

<Card className=“rounded-3xl shadow-sm“>
<CardContent className=“p-5″>
<h2 className=“text-xl font-semibold“>Kalkulation</h2>
<div className=“mt-4 grid gap-4 sm:grid-cols-4″>
<div className=“rounded-2xl bg-stone-100 p-4″>
<p className=“text-sm text-stone-500″>Bausteine</p>
<p className=“text-2xl font-bold“>{selectedBlocks.length}</p>
</div>
<div className=“rounded-2xl bg-stone-100 p-4″>
<p className=“text-sm text-stone-500″>Programmdauer</p>
<p className=“text-2xl font-bold“>{minutesToText(totalDuration)}</p>
</div>
<div className=“rounded-2xl bg-stone-100 p-4″>
<p className=“text-sm text-stone-500″>Summe pro Person</p>
<p className=“text-2xl font-bold“>{euro(totalPerPerson)}</p>
</div>
<div className=“rounded-2xl bg-stone-100 p-4″>
<p className=“text-sm text-stone-500″>Summe Gruppe</p>
<p className=“text-2xl font-bold“>{euro(totalGroup)}</p>
</div>
</div>
</CardContent>
</Card>

<Card className=“rounded-3xl shadow-sm“>
<CardContent className=“p-5″>
<h2 className=“text-xl font-semibold“>Angebotstext aus Bausteinen</h2>
<p className=“mb-3 text-sm text-stone-500″>Erster Rohtext für E-Mail oder Angebot. Später können wir daraus eine richtige Angebotsvorlage machen.</p>
<textarea
readOnly
value={makeOfferText()}
className=“h-80 w-full rounded-2xl border bg-white p-4 font-mono text-sm“
/>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

Call Now Button