feat: redesign CMS and unify German admin UI
This commit is contained in:
@@ -24,6 +24,7 @@ import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0
|
||||
import { CalendarNavLink as CalendarNavLink_4d06fac03127b339306d5f5ecb073251 } from '../../../components/admin/CalendarNavLink'
|
||||
import { AnalyticsNavLink as AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962 } from '../../../components/admin/AnalyticsNavLink'
|
||||
import { ConversationsNavLink as ConversationsNavLink_67a2594cf28806bc76971e67212a6181 } from '../../../components/admin/ConversationsNavLink'
|
||||
import { AdminWelcome as AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6 } from '../../../components/admin/AdminWelcome'
|
||||
import { AdminCalendarView as AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a } from '../../../components/admin/AdminCalendarView'
|
||||
import { AdminAnalyticsView as AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0 } from '../../../components/admin/AdminAnalyticsView'
|
||||
import { AdminConversationsView as AdminConversationsView_180f5c400a066a0a8f248da44d961055 } from '../../../components/admin/AdminConversationsView'
|
||||
@@ -57,6 +58,7 @@ export const importMap = {
|
||||
"/components/admin/CalendarNavLink#CalendarNavLink": CalendarNavLink_4d06fac03127b339306d5f5ecb073251,
|
||||
"/components/admin/AnalyticsNavLink#AnalyticsNavLink": AnalyticsNavLink_29fa5c324cecba6cafb11381cbfac962,
|
||||
"/components/admin/ConversationsNavLink#ConversationsNavLink": ConversationsNavLink_67a2594cf28806bc76971e67212a6181,
|
||||
"/components/admin/AdminWelcome#AdminWelcome": AdminWelcome_b3c24bec21c663ad093f131aaae3d0a6,
|
||||
"/components/admin/AdminCalendarView#AdminCalendarView": AdminCalendarView_a2760c3ae85f4fd51d79a465baaf023a,
|
||||
"/components/admin/AdminAnalyticsView#AdminAnalyticsView": AdminAnalyticsView_55d38702e4d86d52435b5ac4df3f55d0,
|
||||
"/components/admin/AdminConversationsView#AdminConversationsView": AdminConversationsView_180f5c400a066a0a8f248da44d961055,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } fro
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal
|
||||
* POST /api/admin/calendar-feed/regenerate - (re)generates Anna's personal
|
||||
* ICS feed link. Only the new token's hash is stored; the plaintext URL is
|
||||
* returned exactly once in this response and can never be retrieved again
|
||||
* — generating a new one immediately invalidates whichever link was issued
|
||||
* - generating a new one immediately invalidates whichever link was issued
|
||||
* before (see collections/Users.ts).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -23,7 +23,7 @@ function normalizeEventName(value: unknown): string | null {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */
|
||||
/** Hostname only (never path/query) - see collections/AnalyticsEvents.ts. */
|
||||
function referrerHostFrom(referrer: unknown): string | undefined {
|
||||
if (typeof referrer !== "string" || !referrer) return undefined;
|
||||
try {
|
||||
@@ -35,7 +35,7 @@ function referrerHostFrom(referrer: unknown): string | undefined {
|
||||
|
||||
/**
|
||||
* Public tracking beacon, called by lib/analytics/client.ts only after the
|
||||
* visitor has granted statistics consent — but the client's own claim is
|
||||
* visitor has granted statistics consent - but the client's own claim is
|
||||
* never trusted alone: this route re-checks the CookieSettings global's
|
||||
* `statistics.enabled` flag server-side before writing anything, so a
|
||||
* global kill-switch (or a stale/tampered client) can never bypass it.
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Customer } from "@/payload-types";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Deliberately identical whether or not an account exists / is already
|
||||
// verified — this endpoint must never let a caller find out either of
|
||||
// verified - this endpoint must never let a caller find out either of
|
||||
// those things about an email address that isn't their own session.
|
||||
const GENERIC_RESPONSE = {
|
||||
ok: true,
|
||||
@@ -60,7 +60,7 @@ export async function POST(request: Request) {
|
||||
body = await request.json();
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
||||
} catch {
|
||||
// no body — handled below as a missing email
|
||||
// no body - handled below as a missing email
|
||||
}
|
||||
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
|
||||
if (!email || email.length > 254) {
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function POST(request: Request, { params }: Args) {
|
||||
}
|
||||
|
||||
// The beforeChange hook on booking-requests re-checks for conflicts
|
||||
// (with an advisory lock) before allowing this to become "confirmed" —
|
||||
// (with an advisory lock) before allowing this to become "confirmed" -
|
||||
// the alternative slot may have been taken by someone else since it was
|
||||
// proposed.
|
||||
await payload.update({
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function POST(request: Request, { params }: Args) {
|
||||
if (!user || user.collection !== "customers") {
|
||||
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
}
|
||||
// Cancelling is deliberately allowed regardless of verification status —
|
||||
// Cancelling is deliberately allowed regardless of verification status -
|
||||
// only creating/confirming a booking is a gated "protected" action.
|
||||
|
||||
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function POST(request: Request) {
|
||||
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
|
||||
|
||||
// Re-validate the slot is actually free right now (defense against a
|
||||
// stale slot list) — the hard, race-safe check still runs again when
|
||||
// stale slot list) - the hard, race-safe check still runs again when
|
||||
// Anna confirms (see collections/BookingRequests.ts).
|
||||
const dayBounds = berlinDayBounds(start);
|
||||
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ function safeEqual(a: string, b: string): boolean {
|
||||
/**
|
||||
* Intended to be called by an external cron job once daily, e.g.:
|
||||
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup
|
||||
* (a second, separate cron entry from /api/cron/event-reminders — reuses
|
||||
* (a second, separate cron entry from /api/cron/event-reminders - reuses
|
||||
* the same CRON_SECRET, no new secret needed)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
|
||||
/**
|
||||
* Used by the Docker healthcheck (see docker-compose.yml) and by
|
||||
* setup.sh/deploy.sh to confirm the app can actually reach Postgres before
|
||||
* being considered "up" — not just that the Node process is listening.
|
||||
* being considered "up" - not just that the Node process is listening.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function POST(request: Request, { params }: Args) {
|
||||
}
|
||||
|
||||
const { user } = await payload.auth({ headers: request.headers });
|
||||
// Must check the collection, not just presence — a logged-in customer
|
||||
// Must check the collection, not just presence - a logged-in customer
|
||||
// (collection "customers") must never be treated as host.
|
||||
const isHost = user?.collection === "users" && user.role === "admin";
|
||||
|
||||
@@ -81,7 +81,7 @@ export async function POST(request: Request, { params }: Args) {
|
||||
role: isHost ? "host" : "participant",
|
||||
accessVersion: access.accessVersion,
|
||||
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
|
||||
// Only meaningful for participants — a host authenticates via a real
|
||||
// Only meaningful for participants - a host authenticates via a real
|
||||
// session already, so there's no need to anchor a kick to it (and
|
||||
// hosts can't be kicked in the first place.
|
||||
clientId: isHost ? undefined : clientId,
|
||||
|
||||
+383
-3
@@ -1,3 +1,383 @@
|
||||
/* Optional custom overrides for the Payload admin UI. Left empty intentionally —
|
||||
the admin panel uses Payload's own default styling, only the public site
|
||||
uses the ANOUMA design system (see app/(frontend)/globals.css). */
|
||||
@font-face {
|
||||
font-family: "Anouma Admin";
|
||||
src: url("/fonts/baar-antropos.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--anouma-ink: #342c31;
|
||||
--anouma-plum: #66505f;
|
||||
--anouma-mauve: #8b616d;
|
||||
--anouma-rose: #d19ca1;
|
||||
--anouma-cream: #f7f2ed;
|
||||
--anouma-paper: #fffdfb;
|
||||
--anouma-line: #e4d9d2;
|
||||
--font-body: "Anouma Admin", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--anouma-cream);
|
||||
color: var(--anouma-ink);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* The left navigation is intentionally dark and quiet. Content stays light. */
|
||||
.nav {
|
||||
background: var(--anouma-ink);
|
||||
border-right: 1px solid #4b3f46;
|
||||
color: #f8f2ed;
|
||||
}
|
||||
|
||||
.nav__header {
|
||||
border-bottom: 1px solid #4b3f46;
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
|
||||
.nav__header a,
|
||||
.nav__header button {
|
||||
color: #fffaf6;
|
||||
}
|
||||
|
||||
.nav__links {
|
||||
padding: 18px 12px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.nav-group__label,
|
||||
.nav-group__toggle {
|
||||
color: #bdaeb4;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-group__label {
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
|
||||
.nav__link {
|
||||
border-radius: 4px;
|
||||
color: #f8f2ed;
|
||||
font-size: 14px;
|
||||
margin: 2px 0;
|
||||
min-height: 38px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.nav__link:hover,
|
||||
.nav__link:focus-visible,
|
||||
.nav__link.active {
|
||||
background: #4c3d47;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav__link.active {
|
||||
box-shadow: inset 3px 0 0 var(--anouma-rose);
|
||||
}
|
||||
|
||||
.nav__controls {
|
||||
border-top: 1px solid #4b3f46;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.app-header,
|
||||
.step-nav {
|
||||
background: var(--anouma-paper);
|
||||
border-bottom: 1px solid var(--anouma-line);
|
||||
}
|
||||
|
||||
.template-default__wrap,
|
||||
.dashboard__wrap,
|
||||
.collection-list__wrap,
|
||||
.edit-view__wrap {
|
||||
background: var(--anouma-cream);
|
||||
}
|
||||
|
||||
.template-default__wrap {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.dashboard__header,
|
||||
.collection-list__header,
|
||||
.edit-view__header {
|
||||
border-bottom: 1px solid var(--anouma-line);
|
||||
margin-bottom: 26px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.dashboard__header h1,
|
||||
.collection-list__header h1,
|
||||
.edit-view__header h1 {
|
||||
color: var(--anouma-ink);
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.card,
|
||||
.dashboard__card,
|
||||
.table-wrap,
|
||||
.collection-list__table-wrap,
|
||||
.field-type,
|
||||
.document-fields__main {
|
||||
border-color: var(--anouma-line);
|
||||
}
|
||||
|
||||
.card,
|
||||
.dashboard__card,
|
||||
.table-wrap {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgb(52 44 49 / 4%);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn--style-primary {
|
||||
--bg-color: var(--anouma-mauve);
|
||||
--hover-bg: var(--anouma-plum);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
.rs__control {
|
||||
border-color: #d7c9c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
.rs__control--is-focused {
|
||||
border-color: var(--anouma-mauve);
|
||||
box-shadow: 0 0 0 2px rgb(139 97 109 / 12%);
|
||||
}
|
||||
|
||||
.field-label,
|
||||
.field-label label {
|
||||
color: var(--anouma-plum);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.description,
|
||||
.field-description {
|
||||
color: #7b6b72;
|
||||
}
|
||||
|
||||
.table thead th,
|
||||
.table th {
|
||||
background: #f1e9e4;
|
||||
color: var(--anouma-plum);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: #fbf7f4;
|
||||
}
|
||||
|
||||
.anouma-admin-welcome {
|
||||
background: var(--anouma-paper);
|
||||
border: 1px solid var(--anouma-line);
|
||||
border-left: 4px solid var(--anouma-mauve);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 12px 32px rgb(52 44 49 / 5%);
|
||||
margin: 0 0 28px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.anouma-admin-welcome__intro {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.anouma-admin-eyebrow {
|
||||
color: var(--anouma-mauve);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
margin: 0 0 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.anouma-admin-welcome h1 {
|
||||
color: var(--anouma-ink);
|
||||
font-size: clamp(25px, 3vw, 36px);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.anouma-admin-welcome__intro > p:last-child {
|
||||
color: #776870;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklinks {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink {
|
||||
background: #faf5f1;
|
||||
border: 1px solid var(--anouma-line);
|
||||
border-radius: 4px;
|
||||
color: var(--anouma-ink);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 154px;
|
||||
padding: 16px;
|
||||
text-decoration: none;
|
||||
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink:hover,
|
||||
.anouma-admin-quicklink:focus-visible {
|
||||
background: #fffaf7;
|
||||
border-color: var(--anouma-mauve);
|
||||
color: var(--anouma-ink);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink__number {
|
||||
color: var(--anouma-rose);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink__kicker {
|
||||
color: var(--anouma-mauve);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink strong {
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklink > span:last-child {
|
||||
color: #7b6b72;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-item,
|
||||
.anouma-custom-nav-action {
|
||||
margin: 0 0 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-link,
|
||||
.anouma-custom-nav-button {
|
||||
align-items: center;
|
||||
border: 1px solid #5a4a55;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-link {
|
||||
color: #fffaf6;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-link:hover,
|
||||
.anouma-custom-nav-link:focus-visible {
|
||||
background: #4c3d47;
|
||||
border-color: #7d6371;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-link__icon {
|
||||
color: var(--anouma-rose);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-button {
|
||||
background: var(--anouma-mauve);
|
||||
border-color: var(--anouma-mauve);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-button:hover,
|
||||
.anouma-custom-nav-button:focus-visible {
|
||||
background: var(--anouma-plum);
|
||||
border-color: var(--anouma-plum);
|
||||
}
|
||||
|
||||
.anouma-custom-nav-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-input {
|
||||
background: #fffaf6;
|
||||
border: 1px solid #d7c9c1;
|
||||
border-radius: 3px;
|
||||
color: var(--anouma-ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
margin-top: 6px;
|
||||
padding: 6px 7px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anouma-custom-nav-error {
|
||||
color: #f1b5b5;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.anouma-admin-quicklinks {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.anouma-admin-welcome {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.anouma-admin-quicklinks {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user