Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-, Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb. Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) - Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) - PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin. Module: - Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery - Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen - Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts - Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen - Essensplanung und Wochenbudget mit Sportbonus - Einkaufslisten mit Maerkten und Laufweg-Sortierung - Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung - Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
335 lines
16 KiB
TypeScript
335 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { ArrowTopRightOnSquareIcon, MapPinIcon, PlusIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
|
|
import { useMemo, useState } from "react";
|
|
import { FormField, inputClassName } from "@/app/_components/form-field";
|
|
import { uid } from "@/lib/hellth-data";
|
|
import { trpc } from "@/lib/trpc";
|
|
|
|
type DepartmentDraft = {
|
|
id: string;
|
|
name: string;
|
|
sortOrder: number;
|
|
};
|
|
|
|
type StoreDraft = {
|
|
id?: string;
|
|
name: string;
|
|
address?: string | null;
|
|
placeId?: string | null;
|
|
latitude?: number | null;
|
|
longitude?: number | null;
|
|
favorite: boolean;
|
|
departments: DepartmentDraft[];
|
|
};
|
|
|
|
function defaultDepartments(storeId: string): DepartmentDraft[] {
|
|
return [
|
|
{ id: `${storeId}-produce`, name: "Obst & Gemüse", sortOrder: 1 },
|
|
{ id: `${storeId}-bakery`, name: "Brot & Backwaren", sortOrder: 2 },
|
|
{ id: `${storeId}-dairy`, name: "Milchprodukte", sortOrder: 3 },
|
|
{ id: `${storeId}-protein`, name: "Fleisch/Fisch/Protein", sortOrder: 4 },
|
|
{ id: `${storeId}-drinks`, name: "Getränke", sortOrder: 5 },
|
|
{ id: `${storeId}-other`, name: "Sonstiges", sortOrder: 99 },
|
|
];
|
|
}
|
|
|
|
function mapsSearchUrl(name: string, address?: string | null) {
|
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([name, address].filter(Boolean).join(" "))}`;
|
|
}
|
|
|
|
export default function MaerktePage() {
|
|
const utils = trpc.useUtils();
|
|
const [storeName, setStoreName] = useState("");
|
|
const [storeAddress, setStoreAddress] = useState("");
|
|
const [storeLat, setStoreLat] = useState("");
|
|
const [storeLng, setStoreLng] = useState("");
|
|
const [departmentNames, setDepartmentNames] = useState<Record<string, string>>({});
|
|
const [changeNotes, setChangeNotes] = useState<Record<string, string>>({});
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
|
|
const storesQuery = trpc.health.listStores.useQuery();
|
|
const requestsQuery = trpc.health.myStoreChangeRequests.useQuery();
|
|
const saveStoreMutation = trpc.health.saveStore.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.health.listStores.invalidate();
|
|
setStatus("Markt gespeichert.");
|
|
},
|
|
});
|
|
const submitStoreChangeRequest = trpc.health.submitStoreChangeRequest.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.health.myStoreChangeRequests.invalidate();
|
|
setStatus("Änderungsantrag eingereicht.");
|
|
},
|
|
});
|
|
const setStoreFavoriteMutation = trpc.health.setStoreFavorite.useMutation({
|
|
onSuccess: () => utils.health.listStores.invalidate(),
|
|
});
|
|
const deleteStoreMutation = trpc.health.deleteStore.useMutation({
|
|
onSuccess: () => utils.health.listStores.invalidate(),
|
|
});
|
|
|
|
const stores = useMemo(() => storesQuery.data ?? [], [storesQuery.data]);
|
|
|
|
function saveDraft(draft: StoreDraft) {
|
|
saveStoreMutation.mutate({
|
|
id: draft.id,
|
|
name: draft.name,
|
|
address: draft.address || null,
|
|
placeId: draft.placeId || null,
|
|
latitude: draft.latitude ?? null,
|
|
longitude: draft.longitude ?? null,
|
|
favorite: draft.favorite,
|
|
departments: draft.departments
|
|
.map((department, index) => ({
|
|
id: department.id,
|
|
name: department.name,
|
|
sortOrder: department.sortOrder || index + 1,
|
|
}))
|
|
.sort((a, b) => a.sortOrder - b.sortOrder),
|
|
});
|
|
}
|
|
|
|
function addStore() {
|
|
if (!storeName.trim()) return;
|
|
const storeId = uid("store");
|
|
saveDraft({
|
|
id: storeId,
|
|
name: storeName.trim(),
|
|
address: storeAddress.trim() || null,
|
|
latitude: storeLat ? Number(storeLat.replace(",", ".")) : null,
|
|
longitude: storeLng ? Number(storeLng.replace(",", ".")) : null,
|
|
favorite: false,
|
|
departments: defaultDepartments(storeId),
|
|
});
|
|
setStoreName("");
|
|
setStoreAddress("");
|
|
setStoreLat("");
|
|
setStoreLng("");
|
|
}
|
|
|
|
function useCurrentLocation() {
|
|
if (!navigator.geolocation) {
|
|
setStatus("Standortfreigabe ist in diesem Browser nicht verfügbar.");
|
|
return;
|
|
}
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
setStoreLat(position.coords.latitude.toFixed(6));
|
|
setStoreLng(position.coords.longitude.toFixed(6));
|
|
setStatus("Standort übernommen. Bitte Marktname und Adresse ergänzen.");
|
|
},
|
|
() => setStatus("Standort konnte nicht gelesen werden."),
|
|
{ enableHighAccuracy: true, timeout: 10000 },
|
|
);
|
|
}
|
|
|
|
function toggleFavorite(store: StoreDraft) {
|
|
if (!store.id) return;
|
|
setStoreFavoriteMutation.mutate({ storeId: store.id, favorite: !store.favorite });
|
|
}
|
|
|
|
function submitChange(store: StoreDraft, requestType: "new_store" | "structure_update" | "location_update" = "structure_update") {
|
|
submitStoreChangeRequest.mutate({
|
|
storeId: store.id ?? null,
|
|
requestType,
|
|
title: `${store.name}: Änderung übernehmen`,
|
|
note: changeNotes[store.id ?? store.name] || null,
|
|
store: {
|
|
name: store.name,
|
|
address: store.address ?? null,
|
|
placeId: store.placeId ?? null,
|
|
latitude: store.latitude ?? null,
|
|
longitude: store.longitude ?? null,
|
|
},
|
|
departments: store.departments,
|
|
});
|
|
}
|
|
|
|
function addDepartment(store: StoreDraft) {
|
|
const name = departmentNames[store.id ?? store.name]?.trim();
|
|
if (!name) return;
|
|
const maxOrder = Math.max(0, ...store.departments.map((department) => department.sortOrder));
|
|
saveDraft({
|
|
...store,
|
|
departments: [
|
|
...store.departments,
|
|
{ id: uid(`${store.id ?? "store"}-dept`), name, sortOrder: maxOrder + 1 },
|
|
],
|
|
});
|
|
setDepartmentNames((current) => ({ ...current, [store.id ?? store.name]: "" }));
|
|
}
|
|
|
|
function removeDepartment(store: StoreDraft, departmentId: string) {
|
|
if (store.departments.length <= 1) return;
|
|
saveDraft({ ...store, departments: store.departments.filter((department) => department.id !== departmentId) });
|
|
}
|
|
|
|
function moveDepartment(store: StoreDraft, departmentId: string, direction: -1 | 1) {
|
|
const sorted = [...store.departments].sort((a, b) => a.sortOrder - b.sortOrder);
|
|
const index = sorted.findIndex((department) => department.id === departmentId);
|
|
const targetIndex = index + direction;
|
|
if (index < 0 || targetIndex < 0 || targetIndex >= sorted.length) return;
|
|
const current = sorted[index]!;
|
|
const target = sorted[targetIndex]!;
|
|
sorted[index] = { ...current, sortOrder: target.sortOrder };
|
|
sorted[targetIndex] = { ...target, sortOrder: current.sortOrder };
|
|
saveDraft({ ...store, departments: sorted });
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
|
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
|
<p className="text-sm font-black uppercase text-[#0F766E]">Einkaufen</p>
|
|
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Märkte & Laufwege</h1>
|
|
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
|
Speichere nahe Lebensmittelläden, pinne ihren Standort und sortiere die Abteilungen so, wie du wirklich durch den Markt läufst.
|
|
</p>
|
|
</header>
|
|
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<div className="grid gap-3 lg:grid-cols-[1fr_1.4fr_120px_120px_130px]">
|
|
<FormField label="Marktname">
|
|
<input value={storeName} onChange={(event) => setStoreName(event.target.value)} placeholder="z. B. Kaufland" className={inputClassName} />
|
|
</FormField>
|
|
<FormField label="Adresse">
|
|
<input value={storeAddress} onChange={(event) => setStoreAddress(event.target.value)} placeholder="Straße, Ort" className={inputClassName} />
|
|
</FormField>
|
|
<FormField label="Breite">
|
|
<input value={storeLat} onChange={(event) => setStoreLat(event.target.value)} placeholder="48.3705" className={inputClassName} />
|
|
</FormField>
|
|
<FormField label="Länge">
|
|
<input value={storeLng} onChange={(event) => setStoreLng(event.target.value)} placeholder="10.8978" className={inputClassName} />
|
|
</FormField>
|
|
<div className="flex items-end gap-2">
|
|
<button type="button" onClick={useCurrentLocation} className="grid h-12 w-12 place-items-center rounded-2xl bg-gray-100 text-gray-700" aria-label="Standort übernehmen" title="Standort übernehmen">
|
|
<MapPinIcon className="h-5 w-5" />
|
|
</button>
|
|
<button type="button" onClick={addStore} className="grid h-12 w-12 place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Markt speichern" title="Markt speichern">
|
|
<PlusIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<a href={mapsSearchUrl(storeName || "Supermarkt", storeAddress)} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-teal-50 px-4 py-2 text-xs font-black text-[#0F766E]">
|
|
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
|
|
In Google Maps suchen
|
|
</a>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
{stores.map((store) => {
|
|
const canEdit = store.ownerUserId !== null;
|
|
const draft: StoreDraft = {
|
|
id: store.id,
|
|
name: store.name,
|
|
address: store.address,
|
|
placeId: store.placeId,
|
|
latitude: store.latitude,
|
|
longitude: store.longitude,
|
|
favorite: store.favorite,
|
|
departments: store.departments.map((department) => ({
|
|
id: department.id,
|
|
name: department.name,
|
|
sortOrder: department.sortOrder,
|
|
})),
|
|
};
|
|
const departmentKey = store.id;
|
|
return (
|
|
<section key={store.id} className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-2xl font-black text-gray-950">{store.name}</h2>
|
|
<p className="text-sm font-bold text-gray-400">
|
|
{store.departments.length} Abteilungen
|
|
{!canEdit ? " · globale Vorlage" : " · eigener Markt"}
|
|
</p>
|
|
{store.address ? <p className="mt-1 text-sm font-bold text-gray-500">{store.address}</p> : null}
|
|
{store.latitude != null && store.longitude != null ? (
|
|
<a href={`https://www.google.com/maps/search/?api=1&query=${store.latitude},${store.longitude}`} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs font-black text-[#0F766E]">
|
|
<MapPinIcon className="h-4 w-4" />
|
|
Pin öffnen
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button type="button" onClick={() => toggleFavorite(draft)} className={`rounded-full p-2 ${store.favorite ? "bg-amber-100 text-amber-600" : "bg-gray-100 text-gray-300"}`} aria-label="Favorit" title="Favorit">
|
|
<StarIcon className="h-5 w-5" />
|
|
</button>
|
|
{canEdit ? (
|
|
<button type="button" onClick={() => deleteStoreMutation.mutate({ id: store.id })} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Markt löschen" title="Markt löschen">
|
|
<TrashIcon className="h-5 w-5" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 space-y-2">
|
|
{[...store.departments].sort((a, b) => a.sortOrder - b.sortOrder).map((department, index) => (
|
|
<div key={department.id} className="grid grid-cols-[40px_1fr_96px_40px] items-center gap-2 rounded-2xl bg-gray-50 px-3 py-2">
|
|
<span className="grid h-8 w-8 place-items-center rounded-full bg-white text-sm font-black text-gray-400">{index + 1}</span>
|
|
<span className="text-sm font-black text-gray-950">{department.name}</span>
|
|
<div className="flex gap-1">
|
|
{canEdit ? (
|
|
<>
|
|
<button type="button" onClick={() => moveDepartment(draft, department.id, -1)} className="rounded-full bg-white px-2 py-1 text-xs font-black text-gray-500">hoch</button>
|
|
<button type="button" onClick={() => moveDepartment(draft, department.id, 1)} className="rounded-full bg-white px-2 py-1 text-xs font-black text-gray-500">runter</button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
{canEdit ? (
|
|
<button type="button" onClick={() => removeDepartment(draft, department.id)} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Abteilung löschen" title="Abteilung löschen">
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
) : <span />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{canEdit ? (
|
|
<div className="mt-4 grid grid-cols-[1fr_44px] gap-2">
|
|
<input
|
|
value={departmentNames[departmentKey] ?? ""}
|
|
onChange={(event) => setDepartmentNames((current) => ({ ...current, [departmentKey]: event.target.value }))}
|
|
placeholder="Neue Abteilung"
|
|
className={inputClassName}
|
|
/>
|
|
<button type="button" onClick={() => addDepartment(draft)} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Abteilung hinzufügen" title="Abteilung hinzufügen">
|
|
<PlusIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-4 rounded-3xl bg-gray-50 p-4">
|
|
<FormField label="Änderung einreichen" hint={canEdit ? "Eigene Märkte können vom Admin als globale Vorlage übernommen werden." : "Globale Vorlagen werden nach Admin-Prüfung aktualisiert."}>
|
|
<textarea value={changeNotes[departmentKey] ?? ""} onChange={(event) => setChangeNotes((current) => ({ ...current, [departmentKey]: event.target.value }))} placeholder="Was hat sich im Markt geändert?" className={`${inputClassName} min-h-20`} />
|
|
</FormField>
|
|
<button type="button" onClick={() => submitChange(draft)} className="mt-3 w-full rounded-full bg-[#0F766E] px-4 py-3 text-sm font-black text-white">
|
|
Änderung zur Prüfung einreichen
|
|
</button>
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Meine Änderungsanträge</h2>
|
|
<div className="mt-3 grid gap-2">
|
|
{(requestsQuery.data ?? []).slice(0, 8).map((request) => (
|
|
<div key={request.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-bold">
|
|
<span className="min-w-0 truncate text-gray-950">{request.title}</span>
|
|
<span className={`rounded-full px-3 py-1 text-xs ${request.status === "accepted" ? "bg-emerald-100 text-emerald-700" : request.status === "rejected" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}`}>
|
|
{request.status === "accepted" ? "übernommen" : request.status === "rejected" ? "abgelehnt" : "in Prüfung"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{!(requestsQuery.data ?? []).length ? <p className="text-sm font-bold text-gray-400">Noch keine Änderungsanträge.</p> : null}
|
|
</div>
|
|
</section>
|
|
|
|
{status ? <div className="rounded-3xl bg-emerald-50 px-5 py-4 text-sm font-bold text-emerald-800">{status}</div> : null}
|
|
</div>
|
|
);
|
|
}
|