This commit is contained in:
2025-09-28 19:13:01 +02:00
parent 49edf780b5
commit 541ecb48f2
67 changed files with 5176 additions and 5008 deletions

View File

@@ -1,11 +1,20 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { listTopupsAdmin, patchTopupStatus, getUsersLite } from "../../api";
import { listTopupsAdmin, patchTopupStatus, getUsersLite, getPaypalSettings, updatePaypalSettings, createTopupAdmin } from "../../api";
const euro = (c) => (c ?? 0) / 100;
const fmt = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtDT = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "medium", timeZone: "Europe/Berlin" });
const SHOW_CREATE = false;
const toText = (v) => {
if (v == null) return "—";
if (typeof v === "object") {
try { return JSON.stringify(v); } catch { return "[obj]"; }
}
return String(v);
};
const STATUS_LABELS_DE = {
pending: "ausstehend",
confirmed: "bestätigt",
@@ -27,7 +36,6 @@ function Pill({ status }) {
}
/** Kleiner Avatar (Initialen) */
function Initials({ label }) {
const txt = String(label || "")
@@ -211,6 +219,26 @@ function StatusSelect({ value, onChange }) {
export default function AdminTransactionsPage() {
// PayPal-Settings (jetzt legal innerhalb der Component)
const [ppOpen, setPpOpen] = useState(false);
const [ppMe, setPpMe] = useState("");
const [ppReceiver, setPpReceiver] = useState("");
const [ppSaving, setPpSaving] = useState(false);
useEffect(() => {
(async () => {
try {
const cfg = await getPaypalSettings();
setPpMe(cfg?.paypal_me || "");
setPpReceiver(cfg?.paypal_receiver || "");
} catch {}
})();
}, []);
async function savePaypal() {
setPpSaving(true);
try { await updatePaypalSettings({ paypal_me: ppMe, paypal_receiver: ppReceiver }); }
finally { setPpSaving(false); }
}
// Daten
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
@@ -351,6 +379,40 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
</div>
</form>
)}
{/* PayPal-Einstellungen */}
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
<button type="button"
onClick={() => setPpOpen(o=>!o)}
className="w-full flex items-center justify-between text-left">
<h2 className="text-white/90 font-semibold">PayPal-Einstellungen</h2>
<span className="text-white/60">{ppOpen ? "▴" : "▾"}</span>
</button>
{ppOpen && (
<div className="mt-4 grid gap-3 md:grid-cols-3">
<label className="block">
<div className="text-xs text-white/60 mb-1">PayPal.me Handle (optional)</div>
<input value={ppMe} onChange={e=>setPpMe(e.target.value)}
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
placeholder="z. B. Getraenkewart" />
</label>
<label className="block md:col-span-2">
<div className="text-xs text-white/60 mb-1">Geschäfts-E-Mail für Webscr/IPN</div>
<input value={ppReceiver} onChange={e=>setPpReceiver(e.target.value)}
className="w-full bg-black/40 text-white rounded-xl border border-white/20 px-3 py-2"
placeholder="kasse@example.org" />
</label>
<div className="md:col-span-3">
<button onClick={savePaypal} disabled={ppSaving}
className="px-4 py-2 rounded-xl font-semibold bg-emerald-600/80 hover:bg-emerald-600 text-white shadow disabled:opacity-60">
Speichern
</button>
</div>
</div>
)}
</div>
{/* Tabelle */}
<div className="rounded-2xl bg-white/5 border border-cyan-400/20 p-5">
<div className="flex items-center justify-between mb-3">
@@ -402,7 +464,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
{rows.map(r => {
const u = usersById.get(r.user_id);
const canAct = String(r.status) === "pending";
const ts = r.created_at ? new Date(r.created_at) : null;
const ts = r.created_at ? new Date(String(r.created_at).replace(" ", "T")) : null;
return (
<tr key={r.id} className="border-t border-white/10">
<td className="py-2 pr-4 font-mono">{r.id}</td>
@@ -410,7 +472,7 @@ const [statusFilter, setStatusFilter] = useState(""); // "", "pending", "confirm
<td className="py-2 pr-4">{u?.name || u?.alias || u?.email || r.user_id}</td>
<td className="py-2 pr-4 font-mono">{fmt.format(euro(r.amount_cents))}</td>
<td className="py-2 pr-4"><Pill status={String(r.status)} /></td>
<td className="py-2 pr-4">{r.note || "—"}</td>
<td className="py-2 pr-4 font-mono break-all">{toText(r.note)}</td>
<td className="py-2 pr-4">
<div className="flex items-center gap-2">
<button

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { getCapabilities, getStatsSummary } from "../../api";
import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap } from "react-icons/fi";
import { FiCreditCard, FiTrendingUp, FiDatabase, FiZap, FiAlertTriangle } from "react-icons/fi";
function Panel({ title, icon: Icon, children, accent = "cyan" }) {
const accentClasses =
@@ -193,6 +193,23 @@ export default function DashboardPage() {
</div>
)}
</Panel>
</div>
{/* Leer */}
<Panel title="Leer :(" icon={FiAlertTriangle}>
<div className="text-white/70 text-sm mb-3">
Hier leere Produkte auflisten
</div>
<div className="flex flex-wrap gap-2">
<a
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl
bg-emerald-500/20 text-emerald-200 border border-emerald-400/30
hover:bg-emerald-500/30 transition"
>
</a>
</div>
</Panel>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,63 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { getAuditLogs, getUsersLite } from "../../api";
/* ----------------------------- kleine Utils ----------------------------- */
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
function euroDelta(newC, oldC) {
if (newC == null || oldC == null) return null;
const d = (newC - oldC) / 100;
// null/undefined → kein Wert
if (newC == null || oldC == null) return "—";
// Strings erlauben
const n = Number(newC);
const o = Number(oldC);
if (!Number.isFinite(n) || !Number.isFinite(o)) return "—";
const d = (n - o) / 100;
return (d >= 0 ? "+" : "") + d.toFixed(2) + " €";
}
function fmtDT(v) {
if (!v) return "—";
if (v == null) return "—_-";
// ganze oder dezimale Zahl-Strings → Zahl
if (typeof v === "string" && /^\d+(\.\d+)?$/.test(v)) v = Number(v);
// Sekunden → ms
if (typeof v === "number" && v < 1e12) v = v * 1000;
const d = new Date(v);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString();
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
}
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
// vereinheitlicht Zeitfelder aus der API
function normalizeLog(e) {
let t =
e.timestamp ?? e.ts ?? e.time ??
e.created_at ?? e.createdAt ?? e.created ??
e.occurred_at ?? e.date ??
(e.meta && e.meta.timestamp) ?? null;
if (t == null) return { ...e, timestamp: null };
// Zahl-String (Sekunden oder ms, ggf. mit Dezimalen)
if (typeof t === "string" && /^\d+(\.\d+)?$/.test(t)) {
const n = Number(t);
return { ...e, timestamp: n < 1e12 ? n * 1000 : n };
}
if (typeof t === "number") {
return { ...e, timestamp: t < 1e12 ? t * 1000 : t };
}
if (typeof t === "string") {
let s = t.trim();
s = s.replace(" ", "T"); // "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DDTHH:MM:SS"
s = s.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); // +0000 → +00:00
s = s.replace(/([+-]\d{2})$/, "$1:00"); // +00 → +00:00
s = s.replace(/(\.\d{3})\d+/, "$1"); // Mikrosekunden auf ms kürzen
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(s)) s += "Z"; // naive → UTC
return { ...e, timestamp: s };
}
return { ...e, timestamp: null };
}
/* -------------------------- NiceDropdown (inline) ------------------------ */
@@ -48,7 +92,7 @@ function NiceDropdown({
ref={btnRef}
onClick={() => setOpen(o => !o)}
className={classNames(
"w-full bg-gray-800 text-gray-100 rounded-xl border border-gray-600 px-3 py-2",
"w-full bg-white/10 text-gray-100 rounded-xl border border-gray-600 px-3 py-2",
"text-left flex items-center justify-between hover:bg-gray-700",
buttonClassName
)}
@@ -140,7 +184,7 @@ function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) {
type="button"
ref={btnRef}
onClick={() => setOpen(o => !o)}
className="w-full bg-gray-800 text-gray-100 rounded-xl border border-gray-600 px-3 py-2 flex items-center justify-between hover:bg-gray-700"
className="w-full bg-white/10- text-gray-100 rounded-xl border border-gray-600 px-3 py-2 flex items-center justify-between hover:bg-gray-700"
>
<span className={value ? "" : "text-white/60"}>
{selected ? (selected.name || selected.alias || selected.email) : placeholder}
@@ -155,7 +199,7 @@ function UserPicker({ value, onChange, placeholder = "Alle Nutzer" }) {
>
<input
autoFocus
className="w-full px-3 py-2 mb-2 rounded-lg bg-gray-800 text-gray-100 border border-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
className="w-full px-3 py-2 mb-2 rounded-lg bg-white/10 text-gray-100 border border-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
placeholder="Suchen…"
value={q}
onChange={(e) => setQ(e.target.value)}
@@ -231,7 +275,7 @@ export default function LogsPage() {
date_from,
date_to,
});
const arr = Array.isArray(data) ? data : (data.items || []);
const arr = (Array.isArray(data) ? data : (data.items || [])).map(normalizeLog);
setRows((prev) => (reset ? arr : [...prev, ...arr]));
setHasMore(arr.length === LIMIT);
setOffset(pageOffset + arr.length);
@@ -296,14 +340,14 @@ export default function LogsPage() {
/>
<input
className="ml-auto w-full sm:w-64 px-3 py-2 rounded-xl bg-gray-800 text-gray-100 border border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
className="ml-auto w-full sm:w-64 px-3 py-2 rounded-xl bg-white/10 text-gray-100 border border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
placeholder="Suche (User, Aktion, Info)…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button
className="px-3 py-2 rounded-xl bg-gray-800 border border-gray-600 hover:bg-gray-700 text-gray-100"
className="px-3 py-2 rounded-xl bg-white/10 border border-gray-600 hover:bg-gray-700 text-gray-100"
onClick={() => { setUserId(""); setAction(""); setRange("7d"); setQuery(""); load(true); }}
>
Zurücksetzen
@@ -325,11 +369,13 @@ export default function LogsPage() {
<tbody>
{visible.map((r) => (
<tr key={r.id ?? `${r.timestamp}-${r.user_id}-${r.action}`} className="border-t border-white/10">
<td className="py-2 pr-4 font-mono">{fmtDT(r.timestamp)}</td>
<td className="py-2 pr-4 font-mono">
{fmtDT(r.timestamp ?? r.created_at ?? r.ts ?? r.time ?? r.date)}
</td>
<td className="py-2 pr-4">{r.user?.name ?? r.user?.alias ?? r.user_id}</td>
<td className="py-2 pr-4 font-mono">{String(r.action ?? "")}</td>
<td className="py-2 pr-4">{r.info ?? ""}</td>
<td className="py-2 pr-4 font-mono">{euroDelta(r.new_balance_cents, r.old_balance_cents) ?? ""}</td>
<td className="py-2 pr-4 font-mono">{String(r.action ?? "-_-")}</td>
<td className="py-2 pr-4">{r.info ?? "-_-"}</td>
<td className="py-2 pr-4 font-mono">{euroDelta(r.new_balance_cents, r.old_balance_cents) ?? "-_-"}</td>
</tr>
))}
{loading && (

View File

@@ -7,6 +7,8 @@ import {
updateProduct,
} from "../../api";
import { FiEye, FiEdit2, FiX, FiPlus, FiTrash2, FiCheck, FiSearch } from "react-icons/fi";
import { createPortal } from "react-dom";
/* ---------- Helpers ---------- */
const euro = (cents) => ((Number(cents ?? 0)) / 100).toFixed(2);
@@ -160,7 +162,6 @@ const filtered = useMemo(() => {
setReassignInfo(null);
}
// Persistentes Speichern aus dem Edit-Dialog
async function saveEditedProduct(updated) {
const payload = {
name: updated.name,
@@ -170,12 +171,16 @@ const filtered = useMemo(() => {
supplier_number: updated.supplier_number ?? null,
stock: updated.stock,
is_active: updated.is_active,
// NEU:
pack_size: Math.max(1, Number(updated.pack_size) || 1),
purchase_price_cents: Number(updated.purchase_price_cents) || 0,
};
await updateProduct(updated.id, payload);
await refreshProductsAndCategories();
setEditItem(null);
}
if (loading) return <div className="text-white">Lade Produkte</div>;
if (err) return <div className="text-red-300">Fehler: {String(err.message || err)}</div>;
@@ -340,33 +345,63 @@ const filtered = useMemo(() => {
/* ---------- Sub-Komponenten ---------- */
function Modal({ title, children, onClose }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
// ESC schließt + Body-Scroll locken
useEffect(() => {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
const node = (
<div className="fixed inset-0 z-[9999]">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative w-full max-w-2xl rounded-2xl bg-slate-900 border border-cyan-400/20 shadow-xl">
<div className="flex items-center justify-between px-5 py-3 border-b border-white/10">
<h3 className="text-white/90 font-semibold">{title}</h3>
<button
onClick={onClose}
className="p-2 rounded hover:bg-white/10 text-white/70"
aria-label="Schließen"
title="Schließen"
>
<FiX />
</button>
{/* Centering-Layer */}
<div className="absolute inset-0 p-4 flex items-center justify-center">
<div
role="dialog"
aria-modal="true"
className="relative w-full max-w-2xl rounded-2xl bg-slate-900 border border-cyan-400/20 shadow-xl"
>
<div className="flex items-center justify-between px-5 py-3 border-b border-white/10">
<h3 className="text-white/90 font-semibold">{title}</h3>
<button
onClick={onClose}
className="p-2 rounded hover:bg-white/10 text-white/70"
aria-label="Schließen"
title="Schließen"
>
<FiX />
</button>
</div>
<div className="p-5 text-white/80">{children}</div>
</div>
<div className="p-5 text-white/80">{children}</div>
</div>
</div>
);
return createPortal(node, document.body);
}
function DetailView({ item }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoRow label="ID" value={<code>{item.id}</code>} />
<InfoRow label="Name" value={item.name ?? ""} />
<InfoRow label="Preis" value={`${euro(item.price_cents)}`} />
{/* NEU: Einkaufspreis & Pack-Größe */}
{"purchase_price_cents" in item && (
<InfoRow label="Einkaufspreis" value={`${euro(item.purchase_price_cents)}`} />
)}
{"pack_size" in item && (
<InfoRow label="Pack-Größe" value={displayVal(item.pack_size)} />
)}
<InfoRow label="Kategorie" value={item.category ?? ""} />
{"volume_ml" in item && <InfoRow label="Volumen (ml)" value={displayVal(item.volume_ml)} />}
<InfoRow label="Bestand" value={displayVal(item.stock)} />
@@ -376,6 +411,7 @@ function DetailView({ item }) {
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between border-b border-white/10 py-2">
@@ -392,6 +428,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
id: item.id,
name: item.name ?? "",
price_cents: Number(item.price_cents ?? 0),
// NEU:
purchase_price_cents: Number(item.purchase_price_cents ?? 0),
pack_size: Number(item.pack_size ?? 1),
category: item.category ?? "",
stock: item.stock ?? 0,
volume_ml: item.volume_ml ?? "",
@@ -409,6 +448,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
...item,
...form,
price_cents: Number(form.price_cents) || 0,
// NEU:
purchase_price_cents: Number(form.purchase_price_cents) || 0,
pack_size: Math.max(1, Number(form.pack_size) || 1),
stock: form.stock === "" ? null : Number(form.stock),
volume_ml: form.volume_ml === "" ? null : Number(form.volume_ml),
});
@@ -434,7 +476,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Field label="Preis (Cent)">
<Field label="Verkaufspreis (Cent)">
<input
type="number"
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
@@ -443,6 +485,27 @@ function EditForm({ item, onSave, onCancel, categories }) {
min={0}
/>
</Field>
<Field label="Einkaufspreis (Cent)">
<input
type="number"
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
value={form.purchase_price_cents}
onChange={(e) => set("purchase_price_cents", e.target.value)}
min={0}
/>
</Field>
<Field label="Pack-Größe (Stk/Pack)">
<input
type="number"
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
value={form.pack_size}
onChange={(e) => set("pack_size", e.target.value)}
min={1}
/>
</Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Field label="Volumen (ml)">
<input
type="number"
@@ -461,9 +524,6 @@ function EditForm({ item, onSave, onCancel, categories }) {
min={0}
/>
</Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Lieferantennr.">
<input
className="w-full px-3 py-2 rounded bg-white/10 border border-white/20 text-white"
@@ -471,7 +531,9 @@ function EditForm({ item, onSave, onCancel, categories }) {
onChange={(e) => set("supplier_number", e.target.value)}
/>
</Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Aktiv">
<Switch
checked={!!form.is_active}
@@ -480,15 +542,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
labelOff="inaktiv"
/>
</Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Preis (Anzeige)">
<div className="px-3 py-2 rounded bg-white/5 border border-white/10 text-white/80">
{euro(form.price_cents)}
</div>
</Field>
<div />
</div>
<div className="flex justify-end gap-3 pt-2">
@@ -510,6 +564,7 @@ function EditForm({ item, onSave, onCancel, categories }) {
);
}
function Field({ label, children }) {
return (
<label className="block">

View File

@@ -15,7 +15,6 @@ import {
} from "react-icons/fi";
/* ---------- Geld & PayPal ---------- */
const euroFmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
function parseEuroToCents(input) {
if (typeof input !== "string") return null;
@@ -29,7 +28,7 @@ function parseEuroToCents(input) {
function genCode5() {
return String(Math.floor(Math.random() * 100000)).padStart(5, "0");
}
function buildPaypalUrl(amountCents, code) {
function buildPaypalUrl(amountCents, code, topupId) {
if (!amountCents || amountCents <= 0) return null;
const amountEuro = (amountCents / 100).toFixed(2);
const me = import.meta.env.VITE_PAYPAL_ME && String(import.meta.env.VITE_PAYPAL_ME).trim();
@@ -45,6 +44,10 @@ function buildPaypalUrl(amountCents, code) {
amount: amountEuro,
item_name: `Bacchus Top-Up ${code || ""}`,
no_note: "1",
custom: `topup:${topupId || ""}|code:${code || ""}`,
notify_url: `${window.location.origin.replace(/\/$/, "")}/api/paypal/ipn`,
return: `${window.location.origin}/management/transactions?ok=1`,
cancel_return: `${window.location.origin}/management/transactions?canceled=1`,
});
return `https://www.paypal.com/cgi-bin/webscr?${qs.toString()}`;
}
@@ -134,6 +137,9 @@ export default function TransactionPage() {
const [err, setErr] = useState(null);
const [balanceCents, setBalanceCents] = useState(null);
const euroFmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
const [topupId, setTopupId] = useState(null);
const [amountInput, setAmountInput] = useState("");
const amountCents = useMemo(() => parseEuroToCents(amountInput), [amountInput]);
const [saved, setSaved] = useState(false);
@@ -195,8 +201,8 @@ export default function TransactionPage() {
const readyToSave = amountCents != null && amountCents > 0;
const paypalUrl = useMemo(
() => (saved ? buildPaypalUrl(amountCents, code) : null),
[saved, amountCents, code]
() => (saved ? buildPaypalUrl(amountCents, code, topupId) : null),
[saved, amountCents, code, topupId]
);
async function onSave() {
@@ -206,7 +212,8 @@ export default function TransactionPage() {
setCode(newCode);
setCopied(false);
try {
await createTopup(amountCents, newCode); // speichert Top-up + Code
const created = await createTopup(amountCents, newCode);
setTopupId(created?.id ?? null);
await reloadTx();
} catch (e) {
setTxErr(e?.message || "Top-up konnte nicht angelegt werden.");
@@ -230,7 +237,6 @@ export default function TransactionPage() {
<div className="text-3xl font-mono">
{typeof balanceCents === "number" ? euroFmt.format(balanceCents / 100) : "—"}
</div>
<div className="text-white/50 text-xs mt-1">Stand aus deinem Profil</div>
</Card>
{/* Aufladen */}

View File

@@ -541,12 +541,7 @@ async function openViewer(id) {
</div>
</div>
<TextField
label="Name"
value={formBase.name}
onChange={(v) => { setFormBase(f => ({ ...f, name: v })); setDirty(true); }}
inputClassName="bg-gray-800 text-gray-100 border-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/40"
/>
{/* Drawer (Bearbeiten) fixed */}
{open && (
@@ -886,22 +881,36 @@ function Th({ children, onClick, active, order }) {
);
}
function TextField({ label, value, onChange, type = "text", maxLength, placeholder }) {
function TextField({
label,
value,
onChange,
type = "text",
maxLength,
placeholder,
inputClassName = "",
}) {
return (
<label className="block">
<div className="text-white/60 text-xs mb-1">{label}</div>
<input
className="w-full px-3 py-2 rounded-xl bg-gre/5 border border-white/10 text-white/90 placeholder-white/30 focus:outline-none focus:border-cyan-400/40"
value={value}
onChange={(e) => onChange(e.target.value)}
type={type}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
maxLength={maxLength}
placeholder={placeholder}
className={cx(
"w-full px-3 py-2 rounded-xl border bg-gray-800 text-gray-100",
"border-gray-600 placeholder:text-gray-400",
"focus:outline-none focus:ring-2 focus:ring-gray-400/40",
inputClassName
)}
/>
</label>
);
}
function SelectField({ label, value, onChange, options }) {
return (
<label className="block">