import React, { useMemo, useState } from "react";
import { motion } from "framer-motion";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Calculator, Mail, Phone, Ruler, Send, Shapes, User } from "lucide-react";
const LETTERS = "ABCDEFG";
const DEFAULT_NM_PRICE = 24990;
function sideLabels(n) {
return Array.from({ length: n }, (_, i) => `${LETTERS[i]}${LETTERS[(i + 1) % n]}`);
}
function diagLabels(n) {
return Array.from({ length: Math.max(0, n - 3) }, (_, i) => `A${LETTERS[i + 2]}`);
}
function parseNum(v) {
if (typeof v === "number") return v;
const x = parseFloat(String(v).replace(",", "."));
return Number.isFinite(x) ? x : 0;
}
function dist(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function circleIntersections(c0, r0, c1, r1) {
const dx = c1.x - c0.x;
const dy = c1.y - c0.y;
const d = Math.hypot(dx, dy);
if (d === 0) return [];
if (d > r0 + r1) return [];
if (d < Math.abs(r0 - r1)) return [];
const a = (r0 * r0 - r1 * r1 + d * d) / (2 * d);
const h2 = r0 * r0 - a * a;
if (h2 < 0) return [];
const h = Math.sqrt(Math.max(0, h2));
const xm = c0.x + (a * dx) / d;
const ym = c0.y + (a * dy) / d;
const rx = (-dy * h) / d;
const ry = (dx * h) / d;
return [
{ x: xm + rx, y: ym + ry },
{ x: xm - rx, y: ym - ry },
];
}
function polygonArea(points) {
let s = 0;
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
s += p1.x * p2.y - p2.x * p1.y;
}
return s / 2;
}
function centroid(points) {
return {
x: points.reduce((a, p) => a + p.x, 0) / points.length,
y: points.reduce((a, p) => a + p.y, 0) / points.length,
};
}
function normalize(v) {
const len = Math.hypot(v.x, v.y);
if (!len) return { x: 0, y: 0 };
return { x: v.x / len, y: v.y / len };
}
function add(a, b) {
return { x: a.x + b.x, y: a.y + b.y };
}
function sub(a, b) {
return { x: a.x - b.x, y: a.y - b.y };
}
function mul(a, s) {
return { x: a.x * s, y: a.y * s };
}
function dot(a, b) {
return a.x * b.x + a.y * b.y;
}
function buildPolygon(n, sides, diags) {
const pts = [{ x: 0, y: 0 }, { x: sides[0], y: 0 }];
const A = pts[0];
for (let k = 2; k < n; k++) {
const prev = pts[k - 1];
const rPrev = sides[k - 1];
const rA = k === n - 1 ? sides[n - 1] : diags[k - 2];
const cands = circleIntersections(prev, rPrev, A, rA);
if (!cands.length) throw new Error(`Nem szerkeszthető a(z) ${LETTERS[k]} pont.`);
pts.push(cands.sort((p, q) => q.y - p.y || q.x - p.x)[0]);
}
for (let i = 0; i < n; i++) {
const got = dist(pts[i], pts[(i + 1) % n]);
if (Math.abs(got - sides[i]) > 0.5) {
throw new Error(`Élhossz hiba: ${sideLabels(n)[i]}`);
}
}
if (polygonArea(pts) < 0) {
return [pts[0], ...pts.slice(1).reverse()];
}
return pts;
}
function insetPolygon(points, allowances) {
const c = centroid(points);
const out = points.map((pt, i) => {
const prev = points[(i - 1 + points.length) % points.length];
const next = points[(i + 1) % points.length];
let b = add(normalize(sub(prev, pt)), normalize(sub(next, pt)));
b = normalize(b);
if (dot(b, sub(c, pt)) < 0) b = mul(b, -1);
return add(pt, mul(b, allowances[i]));
});
if (Math.abs(polygonArea(out)) < 1e-6) throw new Error("A csökkentett alak túl kicsi.");
return out;
}
function toSvgPath(points, width, height, pad = 24) {
if (!points.length) return "";
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const dx = Math.max(maxX - minX, 1);
const dy = Math.max(maxY - minY, 1);
const scale = Math.min((width - pad * 2) / dx, (height - pad * 2) / dy);
const map = (p) => ({
x: pad + (p.x - minX) * scale,
y: height - pad - (p.y - minY) * scale,
});
const mapped = points.map(map);
const d = mapped
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
.join(" ");
return {
path: `${d} Z`,
mapped,
};
}
function formatFt(v) {
return new Intl.NumberFormat("hu-HU", { style: "currency", currency: "HUF", maximumFractionDigits: 0 }).format(v || 0);
}
function formatM2(v) {
return `${(v || 0).toFixed(2)} m²`;
}
function approxAreaM2(reducedCmPoints) {
return Math.abs(polygonArea(reducedCmPoints)) / 10000;
}
function initialState(n) {
return {
customerName: "",
email: "",
phone: "",
material: "HDPE 320",
color: "homok",
sailName: "egyedi napvitorla",
note: "",
n,
curvaturePercent: 4,
nmPrice: DEFAULT_NM_PRICE,
sides: sideLabels(n).map(() => "500"),
diags: diagLabels(n).map(() => "700"),
allowances: Array.from({ length: n }, () => "25"),
gdpr: false,
terms: false,
};
}
export default function NapvitorlaMVP() {
const [form, setForm] = useState(initialState(4));
const [submitting, setSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState("");
const calc = useMemo(() => {
try {
const n = Number(form.n);
const sides = form.sides.map(parseNum);
const diags = form.diags.map(parseNum);
const allowances = form.allowances.map(parseNum);
const original = buildPolygon(n, sides, diags);
const reduced = insetPolygon(original, allowances);
const areaM2 = approxAreaM2(reduced);
const total = areaM2 * parseNum(form.nmPrice);
const outerSvg = toSvgPath(original, 520, 420);
const innerSvg = toSvgPath(reduced, 520, 420);
return {
ok: true,
original,
reduced,
areaM2,
total,
outerSvg,
innerSvg,
};
} catch (e) {
return {
ok: false,
error: e.message || "Hibás geometria",
};
}
}, [form]);
function updateShape(nextN) {
setForm((prev) => {
const n = Number(nextN);
return {
...prev,
n,
sides: sideLabels(n).map((_, i) => prev.sides[i] ?? ""),
diags: diagLabels(n).map((_, i) => prev.diags[i] ?? ""),
allowances: Array.from({ length: n }, (_, i) => prev.allowances[i] ?? "25"),
};
});
}
function updateArray(field, index, value) {
setForm((prev) => ({
...prev,
[field]: prev[field].map((v, i) => (i === index ? value : v)),
}));
}
async function handleOrder() {
setSubmitMsg("");
if (!form.gdpr || !form.terms) {
setSubmitMsg("A megrendeléshez fogadd el az adatkezelést és a feltételeket.");
return;
}
if (!calc.ok) {
setSubmitMsg("Előbb javítani kell a méreteket.");
return;
}
const payload = {
customer: {
name: form.customerName,
email: form.email,
phone: form.phone,
},
product: {
sailName: form.sailName,
material: form.material,
color: form.color,
n: form.n,
curvaturePercent: parseNum(form.curvaturePercent),
nmPrice: parseNum(form.nmPrice),
sides: form.sides.map(parseNum),
diags: form.diags.map(parseNum),
allowances: form.allowances.map(parseNum),
note: form.note,
},
calculation: {
areaM2: calc.areaM2,
total: calc.total,
},
};
try {
setSubmitting(true);
const res = await fetch("/api/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.message || "Sikertelen küldés");
setSubmitMsg("A megrendelés elküldve. Hamarosan jelentkeztek nálatok.");
} catch (err) {
setSubmitMsg(`Demó módban fut: a backend még nincs bekötve. Várható végpont: POST /api/order. (${err.message})`);
} finally {
setSubmitting(false);
}
}
return (
Napvitorla webes MVP
Élő kalkuláció
Egyedi napvitorla tervező és megrendelő
A vevő megadja a méreteket, az oldal kirajzolja a formát, kiszámolja a területet és az árat, majd egy gombbal elküldi a rendelést emailben.
Vevő adatai
Geometria és anyag
Élő rajz
A szaggatott vonal a külső kontúr, a vastag vonal a csökkentett forma.
Ár kalkuláció
{!calc.ok && (
{calc.error}
)}
Megrendelés
A végleges verzióban a gomb elküldi az adatokat a backendnek, amely emailt küld nektek a teljes rendelési adattal.
setForm({ ...form, gdpr: Boolean(v) })} />
setForm({ ...form, terms: Boolean(v) })} />
{submitMsg && {submitMsg} }
Következő lépés a kész rendszerhez
Ehhez a demóhoz már csak a backend kell bekötni.
{[
"FastAPI végpont: POST /api/order",
"Email küldés SMTP / Resend / SendGrid",
"Rendelés mentése adatbázisba",
"PDF vagy képes ajánlat csatolás",
].map((item) => (
);
}
setForm({ ...form, customerName: e.target.value })} placeholder="Kiss Péter" />
setForm({ ...form, email: e.target.value })} placeholder="vevo@email.hu" />
setForm({ ...form, phone: e.target.value })} placeholder="+36 30 123 4567" />
setForm({ ...form, sailName: e.target.value })} placeholder="Terasz fölé" />
setForm({ ...form, curvaturePercent: e.target.value })} />
setForm({ ...form, material: e.target.value })} />
setForm({ ...form, color: e.target.value })} />
Élek (cm)
{sideLabels(form.n).map((label, i) => (
updateArray("sides", i, e.target.value)} />
))}
Átlók (cm)
{diagLabels(form.n).length ? diagLabels(form.n).map((label, i) => (
updateArray("diags", i, e.target.value)} />
)) : Háromszögnél nincs szükség átlóra.
}
Sarokhely a szereléknek (cm)
{Array.from({ length: form.n }, (_, i) => (
updateArray("allowances", i, e.target.value)} />
))}
setForm({ ...form, nmPrice: e.target.value })} />
Terület
{calc.ok ? formatM2(calc.areaM2) : "—"}
Egységár
{formatFt(parseNum(form.nmPrice))}
Végösszeg
{calc.ok ? formatFt(calc.total) : "—"}
Az email tartalma: vevő adatai, méretek, terület, ár, anyag, szín, megjegyzés, időpont, rendelési azonosító.
Elfogadom az adatkezelést.
Elfogadom a megrendelési feltételeket.
{item}
))}