feat(admin): eigener Arbeitsbereich mit Dashboard, Zaehlern und Schnellsuche
- Admin ohne Shop-Kopf-/Fusszeile (HideOnAdmin); kein doppeltes <main> mehr - Seitenleiste gruppiert (Verkauf, Sortiment, Kundschaft, Seite), aktive Seite als wandernde Flaeche mit Wabe, Zaehler fuer Wartendes: bezahlte offene Bestellungen, neue Retouren, unfreigegebene Bewertungen, neue Firmenanfragen (COUNT-Abfragen erst nach Admin-/2FA-Pruefung) - Schnellsuche (Strg/Cmd+K): Seiten und haeufige Aktionen per Tastatur, tolerante Wortsuche, Ziele nur aus fester Liste - Dashboard: Begruessung nach Tageszeit (Berliner Zeit), eine Leitzahl "Umsatz heute" mit Vergleich zu gestern und Mini-Verlauf, "Zu erledigen" mit direkten Links bzw. "Alles erledigt"-Siegel, ruhigere Kennzahl-Kacheln - Diagramme nach dataviz-Skill: per Validator gepruefter Diagrammton #a0722f (>=3:1; #b88a44 erreichte nur 2,93:1), gerundete Balkenkappen, 2px Luecke, Tooltip je Balken (auch per Tastatur), Meter mit Spur im selben Ton, Rang der Top-Produkte als Wabe - Einheitliche Admin-Details zentral: groessere Ueberschriften, Tabellenkopf als Etikett, feste Ziffernbreite, eigene Auswahlpfeile Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
15ddceaa39
commit
c95c713717
11 files changed
+762
-133
No files matched your search
@@ -124,7 +124,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
|||||||
<tbody className="divide-y divide-ink/10">
|
<tbody className="divide-y divide-ink/10">
|
||||||
{orders.map((order) => (
|
{orders.map((order) => (
|
||||||
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="p-4 text-forest">
|
<td className="p-4 text-forest whitespace-nowrap">
|
||||||
<Link href={`/admin/bestellungen/${order.id}`} className="underline">
|
<Link href={`/admin/bestellungen/${order.id}`} className="underline">
|
||||||
{order.orderNumber}
|
{order.orderNumber}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { noticeUrl } from "@/lib/notice";
|
import { noticeUrl } from "@/lib/notice";
|
||||||
import { getCurrentAdmin } from "@/lib/auth";
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logoutAction } from "@/app/konto/actions";
|
import { logoutAction } from "@/app/konto/actions";
|
||||||
import { AdminShell } from "@/components/admin-shell";
|
import { AdminShell } from "@/components/admin-shell";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -42,9 +43,28 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
redirect(noticeUrl("admin-2fa"));
|
redirect(noticeUrl("admin-2fa"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Was gerade auf Bearbeitung wartet — erscheint als Zaehler in der
|
||||||
|
// Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder
|
||||||
|
// abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
|
||||||
|
// unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
|
||||||
|
const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([
|
||||||
|
prisma.order.count({
|
||||||
|
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
||||||
|
}),
|
||||||
|
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
|
prisma.review.count({ where: { isApproved: false } }),
|
||||||
|
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
adminName={`${admin.firstName} ${admin.lastName}`}
|
adminName={`${admin.firstName} ${admin.lastName}`}
|
||||||
|
badges={{
|
||||||
|
"/admin/bestellungen": openOrders,
|
||||||
|
"/admin/retouren": openReturns,
|
||||||
|
"/admin/bewertungen": pendingReviews,
|
||||||
|
"/admin/anfragen": newInquiries,
|
||||||
|
}}
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+194
-49
@@ -5,6 +5,8 @@ import { getLocale } from "@/i18n/locale";
|
|||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import { checkUploadFiles } from "@/lib/upload-health";
|
import { checkUploadFiles } from "@/lib/upload-health";
|
||||||
import { checkUploadVolume } from "@/lib/uploads";
|
import { checkUploadVolume } from "@/lib/uploads";
|
||||||
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
|
import { HexMark } from "@/components/honeycomb";
|
||||||
|
|
||||||
const DASHBOARD_ICON_PROPS = {
|
const DASHBOARD_ICON_PROPS = {
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -99,7 +101,8 @@ function daysAgo(n: number): Date {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminDashboardPage() {
|
export default async function AdminDashboardPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
const [
|
const [
|
||||||
revenueToday,
|
revenueToday,
|
||||||
revenueMonth,
|
revenueMonth,
|
||||||
@@ -252,6 +255,39 @@ export default async function AdminDashboardPage() {
|
|||||||
const revenueTrend = [...revenueByDay.entries()];
|
const revenueTrend = [...revenueByDay.entries()];
|
||||||
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
|
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
|
||||||
|
|
||||||
|
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([
|
||||||
|
getCurrentAdmin(),
|
||||||
|
prisma.order.count({
|
||||||
|
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
||||||
|
}),
|
||||||
|
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
|
prisma.review.count({ where: { isApproved: false } }),
|
||||||
|
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
||||||
|
]);
|
||||||
|
const toDo = [
|
||||||
|
{ count: waitingOrders, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoOrders(waitingOrders) },
|
||||||
|
{ count: waitingReturns, href: "/admin/retouren", label: t.adminDashboardPage.toDoReturns(waitingReturns) },
|
||||||
|
{ count: waitingReviews, href: "/admin/bewertungen", label: t.adminDashboardPage.toDoReviews(waitingReviews) },
|
||||||
|
{ count: waitingInquiries, href: "/admin/anfragen", label: t.adminDashboardPage.toDoInquiries(waitingInquiries) },
|
||||||
|
].filter((item) => item.count > 0);
|
||||||
|
|
||||||
|
// Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC).
|
||||||
|
const berlinHour = Number(
|
||||||
|
new Intl.DateTimeFormat("de-DE", { hour: "numeric", hour12: false, timeZone: "Europe/Berlin" }).format(new Date())
|
||||||
|
);
|
||||||
|
const dayPart = berlinHour < 11 ? "morning" : berlinHour < 18 ? "day" : "evening";
|
||||||
|
const todayLine = new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
}).format(new Date());
|
||||||
|
|
||||||
|
// Vergleich mit gestern fuer die Leitzahl (aus dem ohnehin geladenen Verlauf).
|
||||||
|
const todayCents = revenueTrend[revenueTrend.length - 1]?.[1] ?? 0;
|
||||||
|
const yesterdayCents = revenueTrend[revenueTrend.length - 2]?.[1] ?? 0;
|
||||||
|
const deltaCents = todayCents - yesterdayCents;
|
||||||
|
|
||||||
// Bewusst NACH den Kennzahlen abgefragt und nicht in deren Promise.all:
|
// Bewusst NACH den Kennzahlen abgefragt und nicht in deren Promise.all:
|
||||||
// schlaegt der Dateiabgleich fehl (etwa weil das Upload-Verzeichnis gar
|
// schlaegt der Dateiabgleich fehl (etwa weil das Upload-Verzeichnis gar
|
||||||
// nicht lesbar ist), soll das Dashboard trotzdem seine Zahlen zeigen.
|
// nicht lesbar ist), soll das Dashboard trotzdem seine Zahlen zeigen.
|
||||||
@@ -262,7 +298,13 @@ export default async function AdminDashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1>
|
{/* Begruessung statt nackter Ueberschrift: der Tag beginnt hier. */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<p className="text-sm text-ink/60">{todayLine}</p>
|
||||||
|
<h1 className="mt-1 text-4xl md:text-5xl leading-tight text-forest">
|
||||||
|
{t.adminDashboardPage.greeting(dayPart, admin?.firstName ?? "")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Fehlen Dateien, zu denen die Datenbank noch Pfade haelt, ist das
|
{/* Fehlen Dateien, zu denen die Datenbank noch Pfade haelt, ist das
|
||||||
fast immer ein nicht persistentes Upload-Verzeichnis (in Kubernetes
|
fast immer ein nicht persistentes Upload-Verzeichnis (in Kubernetes
|
||||||
@@ -301,44 +343,131 @@ export default async function AdminDashboardPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid gap-6 lg:grid-cols-5">
|
||||||
{stats.map((stat) => {
|
{/* Die eine Leitzahl der Ansicht: Umsatz heute (Grundschrift,
|
||||||
const Icon = statIcons[stat.key];
|
proportionale Ziffern, siehe dataviz-Skill). */}
|
||||||
const primary = stat.key === "revenueToday";
|
<section className="relative overflow-hidden bg-forest p-8 text-cream lg:col-span-3">
|
||||||
return (
|
{/* Mini-Verlauf der letzten Tage: zeigt ohne Achsen, ob es
|
||||||
<div
|
aufwaerts geht — die Details stehen im Diagramm weiter unten. */}
|
||||||
key={stat.key}
|
<svg
|
||||||
className={`border p-6 flex items-start gap-4 ${
|
aria-hidden="true"
|
||||||
primary ? "border-honey/40 bg-honey/5" : "bg-background border-ink/10"
|
viewBox={`0 0 ${Math.max(1, revenueTrend.length - 1) * 10} 40`}
|
||||||
}`}
|
preserveAspectRatio="none"
|
||||||
>
|
className="absolute right-8 top-10 hidden h-20 w-2/5 sm:block"
|
||||||
<span
|
>
|
||||||
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 border ${
|
<polyline
|
||||||
primary ? "border-honey/40 text-honey-dark" : "border-ink/15 text-forest"
|
fill="none"
|
||||||
}`}
|
stroke="var(--color-honey-light)"
|
||||||
>
|
strokeOpacity="0.6"
|
||||||
<Icon />
|
strokeWidth="2"
|
||||||
</span>
|
vectorEffect="non-scaling-stroke"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
points={revenueTrend
|
||||||
|
.map(([, cents], i) => `${i * 10},${38 - (cents / maxDailyRevenue) * 34}`)
|
||||||
|
.join(" ")}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={(revenueTrend.length - 1) * 10}
|
||||||
|
cy={38 - ((revenueTrend[revenueTrend.length - 1]?.[1] ?? 0) / maxDailyRevenue) * 34}
|
||||||
|
r="3"
|
||||||
|
fill="var(--color-honey-light)"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-cream/70">{t.adminDashboardPage.todayRevenueLabel}</p>
|
||||||
|
<p className="mt-2 text-6xl font-semibold tracking-tight">{formatPrice(revenueToday._sum.totalCents ?? 0)}</p>
|
||||||
|
{todayCents !== 0 || yesterdayCents !== 0 ? (
|
||||||
|
<p className={`mt-3 text-sm ${deltaCents >= 0 ? "text-honey-light" : "text-cream/70"}`}>
|
||||||
|
{t.adminDashboardPage.vsYesterday(formatPrice(Math.abs(deltaCents)), deltaCents >= 0)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-8 border-t border-cream/15 pt-4 text-sm text-cream/70">
|
||||||
|
{t.adminDashboardPage.monthRevenueLabel}{" "}
|
||||||
|
<span className="ml-2 text-lg font-semibold text-cream">{formatPrice(revenueMonth._sum.totalCents ?? 0)}</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Was jetzt ansteht — direkt anklickbar. Wartet nichts, gibt es eine
|
||||||
|
kleine Belohnung statt einer leeren Liste. */}
|
||||||
|
<section className="border border-ink/10 bg-background p-6 lg:col-span-2">
|
||||||
|
<h2 className="text-lg text-forest">{t.adminDashboardPage.toDoHeading}</h2>
|
||||||
|
{toDo.length > 0 ? (
|
||||||
|
<ul className="mt-4 divide-y divide-ink/10">
|
||||||
|
{toDo.map((item) => (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="group flex items-center gap-3 py-3 text-sm text-ink/80 transition-colors hover:text-forest"
|
||||||
|
>
|
||||||
|
<span className="flex h-7 min-w-7 items-center justify-center rounded-full bg-honey-dark px-2 text-xs font-semibold text-cream tabular-nums">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4 text-ink/40 transition-[color,transform] duration-300 group-hover:translate-x-1 group-hover:text-forest"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
>
|
||||||
|
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 flex items-start gap-4">
|
||||||
|
<svg viewBox="0 0 24 26" aria-hidden="true" className="motion-stamp h-10 w-10 flex-shrink-0 animate-[wh-stamp_0.7s_var(--ease-arrive)_both]">
|
||||||
|
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="var(--color-honey)" />
|
||||||
|
<path d="M7.5 13.2l3 3 6-6.4" stroke="var(--color-cream)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-ink/65 tracking-wide-caps">{statLabels[stat.key]}</p>
|
<p className="font-serif text-xl text-forest">{t.adminDashboardPage.allDoneTitle}</p>
|
||||||
<p className="mt-1 text-2xl text-forest">{stat.value}</p>
|
<p className="mt-1 text-sm text-ink/65">{t.adminDashboardPage.allDoneText}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{stats
|
||||||
|
.filter((stat) => stat.key !== "revenueToday" && stat.key !== "revenueMonth")
|
||||||
|
.map((stat) => {
|
||||||
|
const Icon = statIcons[stat.key];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stat.key}
|
||||||
|
className="group border border-ink/10 bg-background p-5 transition-[border-color,transform] duration-300 hover:-translate-y-0.5 hover:border-honey/50"
|
||||||
|
>
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center border border-ink/10 text-forest transition-colors group-hover:border-honey/50 group-hover:text-honey-dark">
|
||||||
|
<Icon />
|
||||||
|
</span>
|
||||||
|
<p className="mt-4 text-xs text-ink/60">{statLabels[stat.key]}</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-forest">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 grid lg:grid-cols-2 gap-6">
|
<div className="mt-10 grid lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-background border border-ink/10 p-6">
|
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topProductsHeading}</h2>
|
<h2 className="text-lg text-forest mb-4">{t.adminDashboardPage.topProductsHeading}</h2>
|
||||||
{topProducts.length === 0 ? (
|
{topProducts.length === 0 ? (
|
||||||
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
|
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3 text-sm">
|
<ul className="space-y-3 text-sm">
|
||||||
{topProducts.map((product, i) => (
|
{topProducts.map((product, i) => (
|
||||||
<li key={product.name} className="flex items-center justify-between gap-4">
|
<li key={product.name} className="flex items-center justify-between gap-4">
|
||||||
<span className="text-ink/70">
|
<span className="flex items-center gap-3 text-ink/80">
|
||||||
{i + 1}. {product.name}{" "}
|
<span className="relative flex h-6 w-6 flex-shrink-0 items-center justify-center text-[10px] font-semibold text-cream">
|
||||||
|
<HexMark className={`absolute inset-0 h-6 w-6 ${i === 0 ? "text-[var(--color-chart-honey)]" : "text-forest/70"}`} />
|
||||||
|
<span className="relative">{i + 1}</span>
|
||||||
|
</span>
|
||||||
|
{product.name}{" "}
|
||||||
<span className="text-ink/65">{t.adminDashboardPage.unitsSuffix(product.quantity)}</span>
|
<span className="text-ink/65">{t.adminDashboardPage.unitsSuffix(product.quantity)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-forest whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
|
<span className="text-forest whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
|
||||||
@@ -348,8 +477,8 @@ export default async function AdminDashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background border border-ink/10 p-6">
|
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">
|
<h2 className="text-lg text-forest mb-4">
|
||||||
{t.adminDashboardPage.revenueByCategoryHeading}
|
{t.adminDashboardPage.revenueByCategoryHeading}
|
||||||
</h2>
|
</h2>
|
||||||
{revenueByCategory.length === 0 ? (
|
{revenueByCategory.length === 0 ? (
|
||||||
@@ -364,9 +493,10 @@ export default async function AdminDashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-forest">{formatPrice(totals.revenueCents)}</span>
|
<span className="text-forest">{formatPrice(totals.revenueCents)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-ink/10">
|
{/* Meter: Spur ist eine hellere Stufe desselben Tons. */}
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--color-chart-honey)_15%,transparent)]">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-honey"
|
className="motion-grow h-full origin-left rounded-full bg-[var(--color-chart-honey)] animate-[wh-grow_0.8s_var(--ease-arrive)_both]"
|
||||||
style={{ width: `${(totals.revenueCents / maxCategoryRevenue) * 100}%` }}
|
style={{ width: `${(totals.revenueCents / maxCategoryRevenue) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,23 +508,38 @@ export default async function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid lg:grid-cols-2 gap-6">
|
<div className="mt-6 grid lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-background border border-ink/10 p-6">
|
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">
|
<h2 className="text-lg text-forest mb-4">
|
||||||
{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}
|
{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-end gap-1 h-24">
|
{/* Balken nach dataviz-Skill: duenn, 4px gerundete Kappe an der
|
||||||
{revenueTrend.map(([day, revenueCents]) => (
|
Grundlinie verankert, 2px Luecke, gepruefter Honigton
|
||||||
<div
|
(#a0722f, >=3:1 gegen die Flaeche), Tooltip je Balken mit
|
||||||
key={day}
|
grosszuegiger Trefferflaeche. */}
|
||||||
className="flex-1 h-full flex flex-col justify-end"
|
<div className="relative flex h-32 items-end gap-[2px] border-b border-ink/15">
|
||||||
title={`${day}: ${formatPrice(revenueCents)}`}
|
{revenueTrend.map(([day, revenueCents], i) => {
|
||||||
>
|
const isToday = i === revenueTrend.length - 1;
|
||||||
<div
|
const label = new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
|
||||||
className="bg-honey w-full"
|
day: "numeric",
|
||||||
style={{ height: `${Math.max(2, (revenueCents / maxDailyRevenue) * 100)}%` }}
|
month: "short",
|
||||||
/>
|
}).format(new Date(day));
|
||||||
</div>
|
return (
|
||||||
))}
|
<div key={day} className="group relative flex h-full flex-1 flex-col justify-end" tabIndex={0}>
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-t-[4px] bg-[var(--color-chart-honey)] transition-[opacity,filter] duration-200 group-hover:brightness-110 ${
|
||||||
|
isToday ? "" : "opacity-70 group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
style={{ height: `${Math.max(2, (revenueCents / maxDailyRevenue) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 whitespace-nowrap bg-forest px-2 py-1 text-[11px] text-cream opacity-0 shadow transition-opacity duration-150 group-hover:opacity-100 group-focus:opacity-100"
|
||||||
|
>
|
||||||
|
{t.adminDashboardPage.chartTooltip(label, formatPrice(revenueCents))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex justify-between text-[10px] text-ink/65">
|
<div className="mt-2 flex justify-between text-[10px] text-ink/65">
|
||||||
<span>{t.adminDashboardPage.daysAgoLabel(REVENUE_TREND_DAYS)}</span>
|
<span>{t.adminDashboardPage.daysAgoLabel(REVENUE_TREND_DAYS)}</span>
|
||||||
@@ -402,8 +547,8 @@ export default async function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background border border-ink/10 p-6">
|
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
|
<h2 className="text-lg text-forest mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
|
||||||
{topDiscountCodes.length === 0 ? (
|
{topDiscountCodes.length === 0 ? (
|
||||||
<p className="text-sm text-ink/65">{t.adminDashboardPage.noVouchersYet}</p>
|
<p className="text-sm text-ink/65">{t.adminDashboardPage.noVouchersYet}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
--color-background: #faf8f3;
|
--color-background: #faf8f3;
|
||||||
--color-ink: #242424;
|
--color-ink: #242424;
|
||||||
--color-sage: #788774;
|
--color-sage: #788774;
|
||||||
|
/* Diagrammton, per dataviz-Validator geprueft: Helligkeitsband, Chroma
|
||||||
|
und >=3:1 gegen die helle Flaeche (#b88a44 erreichte nur 2,93:1). */
|
||||||
|
--color-chart-honey: #a0722f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -301,3 +304,53 @@ textarea::placeholder {
|
|||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Admin-Arbeitsbereich (components/admin-shell.tsx): einheitliche Details
|
||||||
|
fuer alle Admin-Seiten zentral statt in jeder der rund 20 Seiten einzeln.
|
||||||
|
Bewusst ausserhalb der Tailwind-Ebenen, damit es die kleinen Standard-
|
||||||
|
Groessen der Seiten ueberstimmt.
|
||||||
|
------------------------------------------------------------------------- */
|
||||||
|
.admin-content h1 {
|
||||||
|
font-size: clamp(2rem, 2.6vw, 2.75rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content table {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabellenkopf als ruhiges Etikett, bleibt beim Scrollen langer Listen oben. */
|
||||||
|
.admin-content thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: color-mix(in oklab, var(--color-cream) 70%, var(--color-background));
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in oklab, var(--color-ink) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content tbody tr {
|
||||||
|
transition: background-color 150ms var(--ease-arrive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auswahlfelder mit eigenem Pfeil statt des grauen Browser-Standards. */
|
||||||
|
.admin-content select {
|
||||||
|
appearance: none;
|
||||||
|
padding-right: 2rem;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2324352b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.6rem center;
|
||||||
|
background-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content select:hover,
|
||||||
|
.admin-content input:not([type="checkbox"]):not([type="radio"]):hover,
|
||||||
|
.admin-content textarea:hover {
|
||||||
|
border-color: color-mix(in oklab, var(--color-ink) 40%, transparent);
|
||||||
|
}
|
||||||
+7
-2
@@ -5,6 +5,7 @@ import { Header, CartDrawer } from "@/components/site-chrome";
|
|||||||
import { Footer } from "@/components/site-footer";
|
import { Footer } from "@/components/site-footer";
|
||||||
import { CookieConsentBanner } from "@/components/cookie-consent";
|
import { CookieConsentBanner } from "@/components/cookie-consent";
|
||||||
import { FlashToast } from "@/components/flash-toast";
|
import { FlashToast } from "@/components/flash-toast";
|
||||||
|
import { HideOnAdmin } from "@/components/hide-on-admin";
|
||||||
import { SubmitFeedback } from "@/components/submit-feedback";
|
import { SubmitFeedback } from "@/components/submit-feedback";
|
||||||
import { readFlash } from "@/lib/flash";
|
import { readFlash } from "@/lib/flash";
|
||||||
import { getSiteUrl } from "@/lib/url";
|
import { getSiteUrl } from "@/lib/url";
|
||||||
@@ -129,11 +130,15 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
>
|
>
|
||||||
{dict.nav.skipToContent}
|
{dict.nav.skipToContent}
|
||||||
</a>
|
</a>
|
||||||
<Header companyName={company.name} />
|
<HideOnAdmin>
|
||||||
|
<Header companyName={company.name} />
|
||||||
|
</HideOnAdmin>
|
||||||
<main id="main-content" className="flex-1">
|
<main id="main-content" className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer companyInfo={company} socialLinks={socialLinks} />
|
<HideOnAdmin>
|
||||||
|
<Footer companyInfo={company} socialLinks={socialLinks} />
|
||||||
|
</HideOnAdmin>
|
||||||
<CartDrawer />
|
<CartDrawer />
|
||||||
<CookieConsentBanner />
|
<CookieConsentBanner />
|
||||||
<FlashToast flash={flash} />
|
<FlashToast flash={flash} />
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { HexMark } from "@/components/honeycomb";
|
||||||
|
|
||||||
|
export interface PaletteEntry {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
section: "pages" | "actions";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schnellsuche fuer den Admin-Bereich (Strg/Cmd + K): jede Seite und die
|
||||||
|
* haeufigsten Aktionen sind zwei Tastendruecke entfernt, statt durch die
|
||||||
|
* Seitenleiste zu scrollen. Pfeiltasten waehlen, Enter springt, Esc schliesst.
|
||||||
|
*
|
||||||
|
* Sucht tolerant: jedes getippte Wort muss irgendwo im Eintrag vorkommen
|
||||||
|
* ("neu gut" findet "Neuen Gutschein anlegen"). Ziele sind ausschliesslich
|
||||||
|
* die fest hinterlegten Eintraege, nie die Eingabe selbst.
|
||||||
|
*/
|
||||||
|
export function AdminCommandPalette({
|
||||||
|
entries,
|
||||||
|
labels,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
entries: PaletteEntry[];
|
||||||
|
labels: { placeholder: string; noResults: string; pages: string; actions: string; dialog: string };
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Globale Tastenkombination — auch wenn der Fokus gerade woanders liegt.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenChange(!open);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return entries;
|
||||||
|
return entries.filter((entry) => words.every((word) => entry.label.toLowerCase().includes(word)));
|
||||||
|
}, [entries, query]);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
onOpenChange(false);
|
||||||
|
setQuery("");
|
||||||
|
setActive(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(entry: PaletteEntry | undefined) {
|
||||||
|
if (!entry) return;
|
||||||
|
close();
|
||||||
|
router.push(entry.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
close();
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActive((i) => Math.min(results.length - 1, i + 1));
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActive((i) => Math.max(0, i - 1));
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
go(results[active]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open ? (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-[65] flex items-start justify-center bg-forest/40 px-4 pt-[15vh] backdrop-blur-[2px]"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={labels.dialog}
|
||||||
|
initial={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -8, scale: 0.98, transition: { duration: 0.12 } }}
|
||||||
|
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
onAnimationComplete={() => inputRef.current?.focus()}
|
||||||
|
className="w-full max-w-lg overflow-hidden border border-ink/10 bg-background shadow-[0_40px_80px_-30px_rgba(36,53,43,0.6)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 border-b border-ink/10 px-5">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4 flex-shrink-0 text-ink/45" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5" />
|
||||||
|
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setActive(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder={labels.placeholder}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="admin-palette-results"
|
||||||
|
aria-activedescendant={results[active] ? `palette-${active}` : undefined}
|
||||||
|
className="h-14 w-full bg-transparent text-base focus:outline-none focus-visible:outline-none focus:shadow-none"
|
||||||
|
/>
|
||||||
|
<kbd className="flex-shrink-0 border border-ink/15 px-1.5 py-0.5 text-[10px] text-ink/50">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
<ul id="admin-palette-results" role="listbox" className="max-h-80 overflow-y-auto py-2">
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<li className="px-5 py-6 text-center text-sm text-ink/60">{labels.noResults}</li>
|
||||||
|
) : null}
|
||||||
|
{results.map((entry, i) => {
|
||||||
|
const showSection = i === 0 || results[i - 1].section !== entry.section;
|
||||||
|
return (
|
||||||
|
<li key={entry.href + entry.label} role="presentation">
|
||||||
|
{showSection ? (
|
||||||
|
<p className="px-5 pb-1 pt-3 text-[10px] tracking-wide-caps text-ink/45">
|
||||||
|
{entry.section === "pages" ? labels.pages : labels.actions}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
id={`palette-${i}`}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === active}
|
||||||
|
onMouseEnter={() => setActive(i)}
|
||||||
|
onClick={() => go(entry)}
|
||||||
|
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-sm transition-colors ${
|
||||||
|
i === active ? "bg-honey/10 text-forest" : "text-ink/75"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<HexMark
|
||||||
|
filled={i === active}
|
||||||
|
className={`h-2.5 w-2.5 flex-shrink-0 ${i === active ? "text-honey-dark" : "text-ink/25"}`}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{entry.label}</span>
|
||||||
|
{i === active ? (
|
||||||
|
<kbd className="border border-ink/15 px-1.5 py-0.5 text-[10px] text-ink/50">Enter</kbd>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
+213
-81
@@ -7,27 +7,74 @@ import { usePathname } from "next/navigation";
|
|||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { Logomark } from "@/components/logomark";
|
import { Logomark } from "@/components/logomark";
|
||||||
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||||
|
import { AdminCommandPalette, type PaletteEntry } from "@/components/admin-command-palette";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
|
|
||||||
function getAdminNav(t: Dictionary) {
|
/** Was an einer Seite gerade auf Bearbeitung wartet (Zaehler in der Seitenleiste). */
|
||||||
|
export type AdminBadges = Partial<Record<string, number>>;
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gruppiert statt einer langen Liste: 13 Eintraege lesen sich in vier
|
||||||
|
* kurzen Bloecken schneller, und die taegliche Arbeit (Verkauf) steht oben.
|
||||||
|
*/
|
||||||
|
function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavItem[] }[] {
|
||||||
return [
|
return [
|
||||||
{ href: "/admin", label: t.adminShell.navDashboard },
|
{ label: null, items: [{ href: "/admin", label: t.adminShell.navDashboard }] },
|
||||||
{ href: "/admin/bestellungen", label: t.adminShell.navOrders },
|
{
|
||||||
{ href: "/admin/retouren", label: t.adminShell.navReturns },
|
label: t.adminShell.groupSales,
|
||||||
{ href: "/admin/produkte", label: t.adminShell.navProducts },
|
items: [
|
||||||
{ href: "/admin/ernten", label: t.adminShell.navHarvests },
|
{ href: "/admin/bestellungen", label: t.adminShell.navOrders },
|
||||||
{ href: "/admin/chargen", label: t.adminShell.navBatches },
|
{ href: "/admin/retouren", label: t.adminShell.navReturns },
|
||||||
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
|
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
|
||||||
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
|
{ href: "/admin/versand", label: t.adminShell.navShipping },
|
||||||
{ href: "/admin/versand", label: t.adminShell.navShipping },
|
],
|
||||||
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
|
},
|
||||||
{ href: "/admin/bewertungen", label: t.adminShell.navReviews },
|
{
|
||||||
{ href: "/admin/inhalte", label: t.adminShell.navContent },
|
label: t.adminShell.groupRange,
|
||||||
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
|
items: [
|
||||||
|
{ href: "/admin/produkte", label: t.adminShell.navProducts },
|
||||||
|
{ href: "/admin/ernten", label: t.adminShell.navHarvests },
|
||||||
|
{ href: "/admin/chargen", label: t.adminShell.navBatches },
|
||||||
|
{ href: "/admin/bewertungen", label: t.adminShell.navReviews },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t.adminShell.groupCustomers,
|
||||||
|
items: [
|
||||||
|
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
|
||||||
|
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t.adminShell.groupSite,
|
||||||
|
items: [
|
||||||
|
{ href: "/admin/inhalte", label: t.adminShell.navContent },
|
||||||
|
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPaletteEntries(t: Dictionary): PaletteEntry[] {
|
||||||
|
const pages = getAdminNavGroups(t).flatMap((group) =>
|
||||||
|
group.items.map((item) => ({ ...item, section: "pages" as const }))
|
||||||
|
);
|
||||||
|
const actions: PaletteEntry[] = [
|
||||||
|
{ href: "/admin/produkte/neu", label: t.adminShell.actionNewProduct, section: "actions" },
|
||||||
|
{ href: "/admin/gutscheine/neu", label: t.adminShell.actionNewVoucher, section: "actions" },
|
||||||
|
{ href: "/admin/benutzer/neu", label: t.adminShell.actionNewUser, section: "actions" },
|
||||||
|
{ href: "/admin/versand/neu", label: t.adminShell.actionNewShippingTier, section: "actions" },
|
||||||
|
{ href: "/shop", label: t.adminShell.actionViewShop, section: "actions" },
|
||||||
|
];
|
||||||
|
return [...pages, ...actions];
|
||||||
|
}
|
||||||
|
|
||||||
function isActiveHref(pathname: string, href: string): boolean {
|
function isActiveHref(pathname: string, href: string): boolean {
|
||||||
if (href === "/admin") return pathname === "/admin";
|
if (href === "/admin") return pathname === "/admin";
|
||||||
return pathname === href || pathname.startsWith(`${href}/`);
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
@@ -35,107 +82,186 @@ function isActiveHref(pathname: string, href: string): boolean {
|
|||||||
|
|
||||||
function NavLinks({
|
function NavLinks({
|
||||||
pathname,
|
pathname,
|
||||||
adminNav,
|
groups,
|
||||||
|
badges,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
waitingLabel,
|
||||||
}: {
|
}: {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
adminNav: ReturnType<typeof getAdminNav>;
|
groups: ReturnType<typeof getAdminNavGroups>;
|
||||||
|
badges: AdminBadges;
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
|
waitingLabel: (count: number) => string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{adminNav.map((item) => {
|
{groups.map((group, groupIndex) => (
|
||||||
const active = isActiveHref(pathname, item.href);
|
<div key={group.label ?? groupIndex} className={groupIndex > 0 ? "mt-5" : ""}>
|
||||||
return (
|
{group.label ? (
|
||||||
<Link
|
<p className="px-5 pb-1.5 text-[10px] tracking-wide-caps text-cream/40">{group.label}</p>
|
||||||
key={item.href}
|
) : null}
|
||||||
href={item.href}
|
{group.items.map((item) => {
|
||||||
onClick={onNavigate}
|
const active = isActiveHref(pathname, item.href);
|
||||||
aria-current={active ? "page" : undefined}
|
const count = badges[item.href] ?? 0;
|
||||||
className={`group flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${
|
return (
|
||||||
active ? "bg-cream/10 text-cream" : "text-cream/70 hover:text-cream hover:bg-cream/5"
|
<Link
|
||||||
}`}
|
key={item.href}
|
||||||
>
|
href={item.href}
|
||||||
{/* Wabe statt farbigem Seitenbalken markiert die aktive Seite. */}
|
onClick={onNavigate}
|
||||||
<HexMark
|
aria-current={active ? "page" : undefined}
|
||||||
filled={active}
|
className={`group relative flex items-center gap-3 px-5 py-2 text-sm transition-colors ${
|
||||||
className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${
|
active ? "text-cream" : "text-cream/70 hover:text-cream"
|
||||||
active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90"
|
}`}
|
||||||
}`}
|
>
|
||||||
/>
|
{/* Aktive Seite als weich einblendende Flaeche, die beim
|
||||||
{item.label}
|
Wechsel zur neuen Seite hinueberwandert. */}
|
||||||
</Link>
|
{active ? (
|
||||||
);
|
<motion.span
|
||||||
})}
|
layoutId="admin-nav-active"
|
||||||
|
className="absolute inset-y-0.5 left-2 right-2 -z-10 bg-cream/10"
|
||||||
|
transition={{ type: "spring", stiffness: 420, damping: 36 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<HexMark
|
||||||
|
filled={active}
|
||||||
|
className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${
|
||||||
|
active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
{count > 0 ? (
|
||||||
|
<span
|
||||||
|
className="min-w-5 rounded-full bg-honey-dark px-1.5 text-center text-[11px] leading-5 text-cream tabular-nums"
|
||||||
|
title={waitingLabel(count)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{count}</span>
|
||||||
|
<span className="sr-only">{waitingLabel(count)}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Wordmark() {
|
function Wordmark() {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<Link href="/admin" className="flex items-center gap-2">
|
||||||
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" />
|
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" />
|
||||||
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
|
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
|
||||||
</span>
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; label: string; shortcut: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="mx-3 flex w-[calc(100%-1.5rem)] items-center gap-2 border border-cream/15 px-3 py-2 text-left text-xs text-cream/60 transition-colors hover:border-cream/35 hover:text-cream"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5" />
|
||||||
|
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="flex-1">{label}</span>
|
||||||
|
<kbd className="border border-cream/20 px-1 text-[10px] text-cream/50">{shortcut}</kbd>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminShellProps {
|
interface AdminShellProps {
|
||||||
adminName: string;
|
adminName: string;
|
||||||
|
badges?: AdminBadges;
|
||||||
logoutAction: () => void | Promise<void>;
|
logoutAction: () => void | Promise<void>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar-Layout fuer den Admin-Bereich statt der frueheren einzeiligen,
|
* Eigener Arbeitsbereich fuer die Imkerei: gruppierte Seitenleiste mit
|
||||||
* horizontal scrollenden Navigation (11 Punkte passten auf typischen
|
* Zaehlern fuer alles, was gerade wartet, und einer Schnellsuche (Strg/Cmd+K).
|
||||||
* Bildschirmbreiten nicht nebeneinander). Ab lg persistente Sidebar links,
|
* Ab lg persistente Seitenleiste links, darunter ein Vollbild-Menue nach
|
||||||
* darunter ein Vollbild-Overlay-Menue nach demselben Muster wie die mobile
|
* demselben Muster wie die mobile Hauptnavigation in site-chrome.tsx. Die
|
||||||
* Hauptnavigation in site-chrome.tsx.
|
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
|
||||||
|
* (components/hide-on-admin.tsx).
|
||||||
*/
|
*/
|
||||||
export function AdminShell({ adminName, logoutAction, children }: AdminShellProps) {
|
export function AdminShell({ adminName, badges = {}, logoutAction, children }: AdminShellProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const adminNav = getAdminNav(t);
|
const groups = getAdminNavGroups(t);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
||||||
|
const paletteLabels = {
|
||||||
|
placeholder: t.adminShell.palettePlaceholder,
|
||||||
|
noResults: t.adminShell.paletteNoResults,
|
||||||
|
pages: t.adminShell.paletteSectionPages,
|
||||||
|
actions: t.adminShell.paletteSectionActions,
|
||||||
|
dialog: t.adminShell.paletteOpen,
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<div className="px-5 py-4 border-t border-cream/10">
|
||||||
|
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
|
||||||
|
<form action={logoutAction} className="mt-2">
|
||||||
|
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
||||||
|
{t.adminShell.logout}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-cream/40 lg:flex">
|
<div className="min-h-screen bg-cream/40 lg:flex">
|
||||||
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-60 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
||||||
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
|
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
|
||||||
<div className="flex items-center h-16 px-5 border-b border-cream/10">
|
<div className="flex items-center h-16 px-5 border-b border-cream/10">
|
||||||
<Wordmark />
|
<Wordmark />
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label={t.adminShell.navAriaLabel} className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5">
|
<div className="pt-4">
|
||||||
<NavLinks pathname={pathname} adminNav={adminNav} />
|
<PaletteTrigger
|
||||||
</nav>
|
onClick={() => setPaletteOpen(true)}
|
||||||
<div className="px-5 py-4 border-t border-cream/10">
|
label={t.adminShell.paletteOpen}
|
||||||
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
|
shortcut={t.adminShell.paletteShortcut}
|
||||||
<form action={logoutAction} className="mt-2">
|
/>
|
||||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
|
||||||
{t.adminShell.logout}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<nav aria-label={t.adminShell.navAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
|
||||||
|
<NavLinks pathname={pathname} groups={groups} badges={badges} waitingLabel={t.adminShell.badgeWaiting} />
|
||||||
|
</nav>
|
||||||
|
{footer}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
|
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
|
||||||
<Wordmark />
|
<Wordmark />
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
aria-label={t.adminShell.openMenu}
|
<button
|
||||||
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
|
aria-label={t.adminShell.paletteOpen}
|
||||||
onClick={() => setMobileOpen(true)}
|
className="w-10 h-10 flex items-center justify-center"
|
||||||
>
|
onClick={() => setPaletteOpen(true)}
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
>
|
||||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
</svg>
|
<circle cx="11" cy="11" r="6.5" />
|
||||||
</button>
|
<path d="m16 16 4.5 4.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={t.adminShell.openMenu}
|
||||||
|
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-6xl mx-auto px-6 py-10 overflow-x-hidden">
|
{/* Kein zweites <main>: das Root-Layout stellt bereits eines. */}
|
||||||
{children}
|
<div className="admin-content max-w-6xl mx-auto px-6 py-10 overflow-x-hidden">{children}</div>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -159,20 +285,26 @@ export function AdminShell({ adminName, logoutAction, children }: AdminShellProp
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label={t.adminShell.mobileNavAriaLabel} className="flex-1 overflow-y-auto py-4">
|
<nav aria-label={t.adminShell.mobileNavAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
|
||||||
<NavLinks pathname={pathname} adminNav={adminNav} onNavigate={() => setMobileOpen(false)} />
|
<NavLinks
|
||||||
|
pathname={pathname}
|
||||||
|
groups={groups}
|
||||||
|
badges={badges}
|
||||||
|
waitingLabel={t.adminShell.badgeWaiting}
|
||||||
|
onNavigate={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="px-5 py-4 border-t border-cream/10 flex-shrink-0">
|
{footer}
|
||||||
<p className="text-xs text-cream/60">{t.adminShell.loggedInAs(adminName)}</p>
|
|
||||||
<form action={logoutAction} className="mt-2">
|
|
||||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
|
||||||
{t.adminShell.logout}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AdminCommandPalette
|
||||||
|
entries={getPaletteEntries(t)}
|
||||||
|
labels={paletteLabels}
|
||||||
|
open={paletteOpen}
|
||||||
|
onOpenChange={setPaletteOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blendet Shop-Kopf- und -Fusszeile im Admin-Bereich aus: dort arbeitet die
|
||||||
|
* Imkerei in einem eigenen Arbeitsbereich mit Seitenleiste
|
||||||
|
* (components/admin-shell.tsx), statt zwischen Shop-Navigation und
|
||||||
|
* Admin-Menue zu stehen.
|
||||||
|
*/
|
||||||
|
export function HideOnAdmin({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
if (pathname === "/admin" || pathname.startsWith("/admin/")) return null;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1234,6 +1234,19 @@ export const de: Dictionary = {
|
|||||||
noVouchersYet: "Noch keine Gutscheine eingelöst.",
|
noVouchersYet: "Noch keine Gutscheine eingelöst.",
|
||||||
usedCountLabel: (count, maxUses) => `${count}× genutzt${maxUses ? ` / ${maxUses}` : ""}`,
|
usedCountLabel: (count, maxUses) => `${count}× genutzt${maxUses ? ` / ${maxUses}` : ""}`,
|
||||||
viewAllOrders: "Alle Bestellungen ansehen →",
|
viewAllOrders: "Alle Bestellungen ansehen →",
|
||||||
|
greeting: (part, name) =>
|
||||||
|
`${part === "morning" ? "Guten Morgen" : part === "day" ? "Hallo" : "Guten Abend"}, ${name}.`,
|
||||||
|
todayRevenueLabel: "Umsatz heute",
|
||||||
|
monthRevenueLabel: "Umsatz diesen Monat",
|
||||||
|
vsYesterday: (delta, up) => `${up ? "↑" : "↓"} ${delta} gegenüber gestern`,
|
||||||
|
toDoHeading: "Zu erledigen",
|
||||||
|
toDoOrders: (count) => (count === 1 ? "1 Bestellung wartet auf Versand" : `${count} Bestellungen warten auf Versand`),
|
||||||
|
toDoReturns: (count) => (count === 1 ? "1 neue Retoure" : `${count} neue Retouren`),
|
||||||
|
toDoReviews: (count) => (count === 1 ? "1 Bewertung wartet auf Freigabe" : `${count} Bewertungen warten auf Freigabe`),
|
||||||
|
toDoInquiries: (count) => (count === 1 ? "1 neue Firmenanfrage" : `${count} neue Firmenanfragen`),
|
||||||
|
allDoneTitle: "Alles erledigt.",
|
||||||
|
allDoneText: "Gerade wartet nichts auf dich. Zeit für die Bienen.",
|
||||||
|
chartTooltip: (day, value) => `${day}: ${value}`,
|
||||||
},
|
},
|
||||||
adminContentPage: {
|
adminContentPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
@@ -1308,6 +1321,22 @@ export const de: Dictionary = {
|
|||||||
closeMenu: "Menü schließen",
|
closeMenu: "Menü schließen",
|
||||||
loggedInAs: (name) => `Angemeldet als ${name}`,
|
loggedInAs: (name) => `Angemeldet als ${name}`,
|
||||||
logout: "Abmelden",
|
logout: "Abmelden",
|
||||||
|
groupSales: "Verkauf",
|
||||||
|
groupRange: "Sortiment",
|
||||||
|
groupCustomers: "Kundschaft",
|
||||||
|
groupSite: "Seite",
|
||||||
|
paletteOpen: "Schnellsuche",
|
||||||
|
paletteShortcut: "Strg K",
|
||||||
|
palettePlaceholder: "Wohin? Seite oder Aktion suchen …",
|
||||||
|
paletteNoResults: "Nichts gefunden.",
|
||||||
|
paletteSectionPages: "Seiten",
|
||||||
|
paletteSectionActions: "Aktionen",
|
||||||
|
actionNewProduct: "Neues Produkt anlegen",
|
||||||
|
actionNewVoucher: "Neuen Gutschein anlegen",
|
||||||
|
actionNewUser: "Neues Konto anlegen",
|
||||||
|
actionNewShippingTier: "Neue Versandstufe anlegen",
|
||||||
|
actionViewShop: "Shop ansehen",
|
||||||
|
badgeWaiting: (count) => `${count} warten`,
|
||||||
twoFactorWarning:
|
twoFactorWarning:
|
||||||
"Dieses Admin-Konto hat noch keine Zwei-Faktor-Authentifizierung aktiviert, empfohlen für Konten mit Zugriff auf Bestellungen, Rückerstattungen und Kundendaten.",
|
"Dieses Admin-Konto hat noch keine Zwei-Faktor-Authentifizierung aktiviert, empfohlen für Konten mit Zugriff auf Bestellungen, Rückerstattungen und Kundendaten.",
|
||||||
twoFactorSetupLink: "Jetzt einrichten →",
|
twoFactorSetupLink: "Jetzt einrichten →",
|
||||||
|
|||||||
@@ -1220,6 +1220,19 @@ export const en: Dictionary = {
|
|||||||
noVouchersYet: "No vouchers redeemed yet.",
|
noVouchersYet: "No vouchers redeemed yet.",
|
||||||
usedCountLabel: (count, maxUses) => `used ${count}×${maxUses ? ` / ${maxUses}` : ""}`,
|
usedCountLabel: (count, maxUses) => `used ${count}×${maxUses ? ` / ${maxUses}` : ""}`,
|
||||||
viewAllOrders: "View all orders →",
|
viewAllOrders: "View all orders →",
|
||||||
|
greeting: (part, name) =>
|
||||||
|
`${part === "morning" ? "Good morning" : part === "day" ? "Hello" : "Good evening"}, ${name}.`,
|
||||||
|
todayRevenueLabel: "Revenue today",
|
||||||
|
monthRevenueLabel: "Revenue this month",
|
||||||
|
vsYesterday: (delta, up) => `${up ? "↑" : "↓"} ${delta} vs. yesterday`,
|
||||||
|
toDoHeading: "To do",
|
||||||
|
toDoOrders: (count) => (count === 1 ? "1 order waiting to ship" : `${count} orders waiting to ship`),
|
||||||
|
toDoReturns: (count) => (count === 1 ? "1 new return" : `${count} new returns`),
|
||||||
|
toDoReviews: (count) => (count === 1 ? "1 review awaiting approval" : `${count} reviews awaiting approval`),
|
||||||
|
toDoInquiries: (count) => (count === 1 ? "1 new business inquiry" : `${count} new business inquiries`),
|
||||||
|
allDoneTitle: "All done.",
|
||||||
|
allDoneText: "Nothing is waiting for you right now. Time for the bees.",
|
||||||
|
chartTooltip: (day, value) => `${day}: ${value}`,
|
||||||
},
|
},
|
||||||
adminContentPage: {
|
adminContentPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
@@ -1294,6 +1307,22 @@ export const en: Dictionary = {
|
|||||||
closeMenu: "Close menu",
|
closeMenu: "Close menu",
|
||||||
loggedInAs: (name) => `Signed in as ${name}`,
|
loggedInAs: (name) => `Signed in as ${name}`,
|
||||||
logout: "Sign out",
|
logout: "Sign out",
|
||||||
|
groupSales: "Sales",
|
||||||
|
groupRange: "Range",
|
||||||
|
groupCustomers: "Customers",
|
||||||
|
groupSite: "Site",
|
||||||
|
paletteOpen: "Quick search",
|
||||||
|
paletteShortcut: "Ctrl K",
|
||||||
|
palettePlaceholder: "Where to? Search a page or action …",
|
||||||
|
paletteNoResults: "Nothing found.",
|
||||||
|
paletteSectionPages: "Pages",
|
||||||
|
paletteSectionActions: "Actions",
|
||||||
|
actionNewProduct: "Create new product",
|
||||||
|
actionNewVoucher: "Create new voucher",
|
||||||
|
actionNewUser: "Create new account",
|
||||||
|
actionNewShippingTier: "Create new shipping tier",
|
||||||
|
actionViewShop: "View shop",
|
||||||
|
badgeWaiting: (count) => `${count} waiting`,
|
||||||
twoFactorWarning:
|
twoFactorWarning:
|
||||||
"This admin account doesn't have two-factor authentication enabled yet, recommended for accounts with access to orders, refunds, and customer data.",
|
"This admin account doesn't have two-factor authentication enabled yet, recommended for accounts with access to orders, refunds, and customer data.",
|
||||||
twoFactorSetupLink: "Set up now →",
|
twoFactorSetupLink: "Set up now →",
|
||||||
|
|||||||
@@ -895,6 +895,18 @@ export interface Dictionary {
|
|||||||
noVouchersYet: string;
|
noVouchersYet: string;
|
||||||
usedCountLabel: (count: number, maxUses: number | null) => string;
|
usedCountLabel: (count: number, maxUses: number | null) => string;
|
||||||
viewAllOrders: string;
|
viewAllOrders: string;
|
||||||
|
greeting: (part: "morning" | "day" | "evening", name: string) => string;
|
||||||
|
todayRevenueLabel: string;
|
||||||
|
monthRevenueLabel: string;
|
||||||
|
vsYesterday: (delta: string, up: boolean) => string;
|
||||||
|
toDoHeading: string;
|
||||||
|
toDoOrders: (count: number) => string;
|
||||||
|
toDoReturns: (count: number) => string;
|
||||||
|
toDoReviews: (count: number) => string;
|
||||||
|
toDoInquiries: (count: number) => string;
|
||||||
|
allDoneTitle: string;
|
||||||
|
allDoneText: string;
|
||||||
|
chartTooltip: (day: string, value: string) => string;
|
||||||
};
|
};
|
||||||
adminContentPage: {
|
adminContentPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
@@ -964,6 +976,22 @@ export interface Dictionary {
|
|||||||
closeMenu: string;
|
closeMenu: string;
|
||||||
loggedInAs: (name: string) => string;
|
loggedInAs: (name: string) => string;
|
||||||
logout: string;
|
logout: string;
|
||||||
|
groupSales: string;
|
||||||
|
groupRange: string;
|
||||||
|
groupCustomers: string;
|
||||||
|
groupSite: string;
|
||||||
|
paletteOpen: string;
|
||||||
|
paletteShortcut: string;
|
||||||
|
palettePlaceholder: string;
|
||||||
|
paletteNoResults: string;
|
||||||
|
paletteSectionPages: string;
|
||||||
|
paletteSectionActions: string;
|
||||||
|
actionNewProduct: string;
|
||||||
|
actionNewVoucher: string;
|
||||||
|
actionNewUser: string;
|
||||||
|
actionNewShippingTier: string;
|
||||||
|
actionViewShop: string;
|
||||||
|
badgeWaiting: (count: number) => string;
|
||||||
twoFactorWarning: string;
|
twoFactorWarning: string;
|
||||||
twoFactorSetupLink: string;
|
twoFactorSetupLink: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user