Compare commits
2
Commits
866d3726d1
...
641afa1ae4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
641afa1ae4 | ||
|
|
f1c0da4673 |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
-- Abgelehnte Bewertungen werden nicht mehr geloescht, sondern markiert und
|
||||||
|
-- im Archiv unter "Abgelehnt" gefuehrt. null = nicht abgelehnt.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "reviews" ADD COLUMN "rejectedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Mehrere Ernten derselben Sorte im selben Jahr zulassen.
|
||||||
|
--
|
||||||
|
-- Bisher erzwang "harvests_year_type_key" genau eine Ernte je (Jahr, Sorte).
|
||||||
|
-- Eine Imkerei schleudert dieselbe Sorte aber oft mehrmals im Jahr; der
|
||||||
|
-- ApiCore-Sync scheiterte daran mit P2002. Eindeutig ist ab jetzt nur noch
|
||||||
|
-- der Slug (oeffentliche Adresse /ernten/<slug>).
|
||||||
|
--
|
||||||
|
-- Der Sync erkannte importierte Ernten bisher am Slug "apicore-<ID>". Diese
|
||||||
|
-- ID wandert in eine eigene Spalte, damit der Slug lesbar werden darf.
|
||||||
|
-- Keine Zeile wird geloescht; die Aenderung ist rein additiv bis auf den
|
||||||
|
-- entfernten Unique-Index.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "harvests" ADD COLUMN "apicoreHarvestId" TEXT;
|
||||||
|
|
||||||
|
-- Backfill: bisherige Sync-Ernten behalten ihre ApiCore-Zuordnung.
|
||||||
|
UPDATE "harvests"
|
||||||
|
SET "apicoreHarvestId" = substring("slug" from 9)
|
||||||
|
WHERE "slug" LIKE 'apicore-%';
|
||||||
|
|
||||||
|
-- Von Hand angelegte Ernten bekamen ihren Slug bisher ohne Umlaut-Behandlung
|
||||||
|
-- ("Frühtracht" 2026 -> "fr-htracht-2026"). Bis jetzt war das unsichtbar;
|
||||||
|
-- ab jetzt ist der Slug die oeffentliche Adresse. Genau diese Form wird auf
|
||||||
|
-- die Schreibweise von lib/slugify.ts gebracht ("fruehtracht-2026") — nur,
|
||||||
|
-- wenn sie sich unterscheidet und die neue Adresse noch frei ist. Sonst
|
||||||
|
-- bleibt der alte Slug: er funktioniert, ist nur weniger schoen.
|
||||||
|
WITH renamed AS (
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
trim(both '-' from regexp_replace(
|
||||||
|
replace(replace(replace(replace(lower("type"), 'ä', 'ae'), 'ö', 'oe'), 'ü', 'ue'), 'ß', 'ss'),
|
||||||
|
'[^a-z0-9]+', '-', 'g'
|
||||||
|
)) || '-' || "year" AS "newSlug"
|
||||||
|
FROM "harvests"
|
||||||
|
WHERE "apicoreHarvestId" IS NULL
|
||||||
|
AND "slug" = regexp_replace(lower("type"), '[^a-z0-9]+', '-', 'g') || '-' || "year"
|
||||||
|
)
|
||||||
|
UPDATE "harvests" h
|
||||||
|
SET "slug" = r."newSlug"
|
||||||
|
FROM renamed r
|
||||||
|
WHERE h."id" = r."id"
|
||||||
|
AND r."newSlug" <> h."slug"
|
||||||
|
AND r."newSlug" <> ('-' || h."year")
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "harvests" o WHERE o."slug" = r."newSlug")
|
||||||
|
-- z.B. "Frühtracht" und "Fruehtracht" im selben Jahr: beide ergaeben
|
||||||
|
-- denselben Slug, der Unique-Index liesse die Migration scheitern.
|
||||||
|
AND (SELECT count(*) FROM renamed r2 WHERE r2."newSlug" = r."newSlug") = 1;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "harvests_apicoreHarvestId_key" ON "harvests"("apicoreHarvestId");
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "harvests_year_type_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "harvests_year_type_idx" ON "harvests"("year", "type");
|
||||||
+12
-2
@@ -148,9 +148,14 @@ model Address {
|
|||||||
model Harvest {
|
model Harvest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
year Int
|
year Int
|
||||||
/// z.B. "Fruehtracht", "Sommertracht"
|
/// z.B. "Fruehtracht", "Sommertracht". Bewusst NICHT eindeutig je Jahr:
|
||||||
|
/// dieselbe Sorte wird oft mehrmals im Jahr geerntet.
|
||||||
type String
|
type String
|
||||||
|
/// Oeffentliche Adresse /ernten/<slug> — siehe lib/harvest-slug.ts.
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
/// ID der Ernte in ApiCore; Wiedererkennungs-Schluessel des Syncs
|
||||||
|
/// (admin/actions/apicore-sync.ts). null fuer von Hand angelegte Ernten.
|
||||||
|
apicoreHarvestId String? @unique
|
||||||
harvestDate DateTime
|
harvestDate DateTime
|
||||||
location String @default("Altenmuenster")
|
location String @default("Altenmuenster")
|
||||||
description String
|
description String
|
||||||
@@ -170,7 +175,8 @@ model Harvest {
|
|||||||
batches Batch[]
|
batches Batch[]
|
||||||
products Product[]
|
products Product[]
|
||||||
|
|
||||||
@@unique([year, type])
|
/// Nur noch fuer die Weiterleitung alter Adressen /ernten/<jahr>/<sorte>.
|
||||||
|
@@index([year, type])
|
||||||
@@map("harvests")
|
@@map("harvests")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +332,10 @@ model Review {
|
|||||||
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
|
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
|
||||||
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
|
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
|
||||||
isApproved Boolean @default(false)
|
isApproved Boolean @default(false)
|
||||||
|
/// Vom Admin abgelehnt: bleibt im Archiv nachvollziehbar statt geloescht zu
|
||||||
|
/// werden. Offen ist eine Bewertung, solange weder isApproved noch
|
||||||
|
/// rejectedAt gesetzt ist (siehe lib/review-status.ts).
|
||||||
|
rejectedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import {
|
|||||||
type ApiCoreProduct,
|
type ApiCoreProduct,
|
||||||
type ApiCoreBottlingBatchWithHarvestId,
|
type ApiCoreBottlingBatchWithHarvestId,
|
||||||
} from "@/lib/apicore-client";
|
} from "@/lib/apicore-client";
|
||||||
|
import { harvestSlugCandidates, writeWithFreeSlug } from "@/lib/harvest-slug";
|
||||||
|
import { findTakenHarvestSlugs } from "@/lib/harvest-slug-db";
|
||||||
import {
|
import {
|
||||||
slugForApiCoreSource,
|
hasLegacyApiCoreSlug,
|
||||||
buildHarvestDescription,
|
buildHarvestDescription,
|
||||||
buildProductDescription,
|
buildProductDescription,
|
||||||
deriveJarWeightGrams,
|
deriveJarWeightGrams,
|
||||||
@@ -59,8 +61,10 @@ import { requireAdmin } from "./_shared";
|
|||||||
* der Losnummer — die kann in ApiCore korrigiert werden.
|
* der Losnummer — die kann in ApiCore korrigiert werden.
|
||||||
*
|
*
|
||||||
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
|
* Idempotent ist das alles ueber einen stabilen Schluessel je Datensatz:
|
||||||
* "apicore-<ApiCore-ID>" als Harvest.slug, und die ApiCore-ID der Abfuellung
|
* die ApiCore-ID der Ernte als Harvest.apicoreHarvestId, und die ApiCore-ID
|
||||||
* als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId.
|
* der Abfuellung als Batch.apicoreBottlingId bzw. Product.apicoreBottlingId.
|
||||||
|
* Mehrere Ernten derselben Sorte im selben Jahr sind ausdruecklich erlaubt;
|
||||||
|
* sie unterscheiden sich im Slug am Erntedatum (lib/harvest-slug.ts).
|
||||||
*
|
*
|
||||||
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
|
* Wabenhains "type" bei Ernten (Erntesaison, z.B. "Sommertracht") und
|
||||||
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
|
* ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") sind KEINE
|
||||||
@@ -101,6 +105,21 @@ async function wasEditedByAdmin(harvestId: string): Promise<boolean> {
|
|||||||
return entries > 0;
|
return entries > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ernten aus der Zeit, als der Slug "apicore-<ID>" der Sync-Schluessel war,
|
||||||
|
* bekommen eine lesbare Adresse. Gefahrlos auch fuer veroeffentlichte
|
||||||
|
* Ernten: oeffentlich verlinkt war bis dahin /ernten/<jahr>/<sorte>, nie der
|
||||||
|
* Slug. Gibt zurueck, ob umbenannt wurde.
|
||||||
|
*/
|
||||||
|
async function replaceLegacySlug(harvest: { id: string; slug: string; type: string; harvestDate: Date }): Promise<boolean> {
|
||||||
|
if (!hasLegacyApiCoreSlug(harvest.slug)) return false;
|
||||||
|
await writeWithFreeSlug(harvestSlugCandidates(harvest.type, harvest.harvestDate), (slug) =>
|
||||||
|
prisma.harvest.update({ where: { id: harvest.id }, data: { slug } }),
|
||||||
|
findTakenHarvestSlugs
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
|
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
|
||||||
* siehe syncProducts().
|
* siehe syncProducts().
|
||||||
@@ -132,7 +151,6 @@ async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCount
|
|||||||
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
const slug = slugForApiCoreSource(source.id);
|
|
||||||
const date = new Date(source.date);
|
const date = new Date(source.date);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
counts.failed++;
|
counts.failed++;
|
||||||
@@ -142,17 +160,19 @@ async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCount
|
|||||||
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
|
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
const existing = await prisma.harvest.findUnique({ where: { apicoreHarvestId: source.id } });
|
||||||
const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
|
const shopOwned = existing && (existing.isPublished || (await wasEditedByAdmin(existing.id)));
|
||||||
if (existing && shopOwned) {
|
if (existing && shopOwned) {
|
||||||
// Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
|
// Nur die ApiCore-eigenen Fakten; Sorte, Ort und Text gehoeren jetzt dem Shop.
|
||||||
const facts = harvestFactChanges(existing, date);
|
const facts = harvestFactChanges(existing, date);
|
||||||
if (!existing.isPublished) counts.protectedDrafts++;
|
if (!existing.isPublished) counts.protectedDrafts++;
|
||||||
|
const renamed = await replaceLegacySlug(existing);
|
||||||
if (Object.keys(facts).length === 0) {
|
if (Object.keys(facts).length === 0) {
|
||||||
counts.skipped++;
|
if (renamed) counts.updated++;
|
||||||
|
else counts.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await prisma.harvest.update({ where: { slug }, data: facts });
|
await prisma.harvest.update({ where: { id: existing.id }, data: facts });
|
||||||
counts.updated++;
|
counts.updated++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -166,17 +186,23 @@ async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCount
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.harvest.update({ where: { slug }, data });
|
await prisma.harvest.update({ where: { id: existing.id }, data });
|
||||||
|
await replaceLegacySlug({ ...existing, ...data });
|
||||||
counts.updated++;
|
counts.updated++;
|
||||||
} else {
|
} else {
|
||||||
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
|
// Der Slug ist die oeffentliche Adresse und wird aus Sorte und Datum
|
||||||
|
// gebildet; mehrere Ernten derselben Sorte im selben Jahr bekommen
|
||||||
|
// ueber das Erntedatum je eine eigene (lib/harvest-slug.ts).
|
||||||
|
await writeWithFreeSlug(harvestSlugCandidates(type, date), (slug) =>
|
||||||
|
prisma.harvest.create({ data: { ...data, slug, apicoreHarvestId: source.id, isPublished: false } }),
|
||||||
|
findTakenHarvestSlugs
|
||||||
|
);
|
||||||
counts.created++;
|
counts.created++;
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
counts.failed++;
|
counts.failed++;
|
||||||
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
|
// Bewusst nicht fatal fuer den gesamten Sync-Lauf: die uebrigen Ernten
|
||||||
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
|
// werden weiter verarbeitet, der Fehler steht im Server-Log.
|
||||||
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
|
|
||||||
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
|
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${describeError(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,7 +433,7 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const harvest = await prisma.harvest.findUnique({
|
const harvest = await prisma.harvest.findUnique({
|
||||||
where: { slug: slugForApiCoreSource(source.harvestSourceId) },
|
where: { apicoreHarvestId: source.harvestSourceId },
|
||||||
});
|
});
|
||||||
if (!harvest) {
|
if (!harvest) {
|
||||||
counts.failed++;
|
counts.failed++;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { logAdminAction } from "@/lib/audit-log";
|
|||||||
import { isValidUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
|
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
|
||||||
import { succeed } from "@/lib/flash";
|
import { succeed } from "@/lib/flash";
|
||||||
|
import { harvestSlugCandidates, writeWithFreeSlug } from "@/lib/harvest-slug";
|
||||||
|
import { findTakenHarvestSlugs } from "@/lib/harvest-slug-db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
|
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
|
||||||
@@ -26,25 +28,33 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
|||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const year = num(formData, "year");
|
const year = num(formData, "year");
|
||||||
const type = str(formData, "type");
|
const type = str(formData, "type");
|
||||||
const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`;
|
const harvestDate = new Date(str(formData, "harvestDate"));
|
||||||
|
if (!type || !Number.isInteger(year) || year < 1 || Number.isNaN(harvestDate.getTime())) {
|
||||||
|
return fail("/admin/ernten", "Bitte Sorte, Jahr und ein gültiges Erntedatum angeben.");
|
||||||
|
}
|
||||||
// Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz
|
// Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz
|
||||||
// (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer
|
// (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer
|
||||||
// manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor
|
// manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor
|
||||||
// Einfuehrung von isPublished).
|
// Einfuehrung von isPublished).
|
||||||
const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on";
|
const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on";
|
||||||
|
|
||||||
const harvest = await prisma.harvest.create({
|
// Dieselbe Sorte darf mehrmals im Jahr geerntet werden; die zweite bekommt
|
||||||
|
// eine Adresse mit Erntedatum statt eines Fehlers (lib/harvest-slug.ts).
|
||||||
|
const harvest = await writeWithFreeSlug(harvestSlugCandidates(type, harvestDate), (slug) =>
|
||||||
|
prisma.harvest.create({
|
||||||
data: {
|
data: {
|
||||||
year,
|
year,
|
||||||
type,
|
type,
|
||||||
slug,
|
slug,
|
||||||
harvestDate: new Date(str(formData, "harvestDate")),
|
harvestDate,
|
||||||
location: str(formData, "location") || "Altenmünster",
|
location: str(formData, "location") || "Altenmünster",
|
||||||
description: str(formData, "description"),
|
description: str(formData, "description"),
|
||||||
images: imageSlots(formData),
|
images: imageSlots(formData),
|
||||||
isPublished,
|
isPublished,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
findTakenHarvestSlugs
|
||||||
|
);
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "harvest.create",
|
action: "harvest.create",
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { requireAdmin, str, fail } from "./_shared";
|
import { requireAdmin, str, fail } from "./_shared";
|
||||||
import { setFlash } from "@/lib/flash";
|
import { setFlash } from "@/lib/flash";
|
||||||
|
import { isInquiryStatus } from "@/lib/inquiry-status";
|
||||||
|
|
||||||
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
const status = str(formData, "status");
|
const status = str(formData, "status");
|
||||||
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return fail("/admin/anfragen", "Ungültiger Status.");
|
if (!isInquiryStatus(status)) return fail("/admin/anfragen", "Ungültiger Status.");
|
||||||
|
|
||||||
await prisma.businessInquiry.update({
|
await prisma.businessInquiry.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" },
|
data: { status },
|
||||||
});
|
});
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
const review = await prisma.review.update({
|
const review = await prisma.review.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isApproved: true },
|
data: { isApproved: true, rejectedAt: null },
|
||||||
select: { product: { select: { slug: true, name: true } } },
|
select: { product: { select: { slug: true, name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,6 +28,33 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
|
|||||||
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
|
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lehnt eine Bewertung ab, statt sie zu loeschen: sie verschwindet aus dem
|
||||||
|
* Shop (falls sie dort war) und steht im Archiv unter "Abgelehnt", damit die
|
||||||
|
* Entscheidung nachvollziehbar bleibt und sich rueckgaengig machen laesst.
|
||||||
|
*/
|
||||||
|
export async function rejectReviewAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const review = await prisma.review.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isApproved: false, rejectedAt: new Date() },
|
||||||
|
select: { product: { select: { slug: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "review.reject",
|
||||||
|
entityType: "Review",
|
||||||
|
entityId: id,
|
||||||
|
detail: review.product.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/bewertungen");
|
||||||
|
revalidatePath(`/honig/${review.product.slug}`);
|
||||||
|
await setFlash("success", "Bewertung abgelehnt — sie steht jetzt im Archiv.");
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const id = str(formData, "id");
|
const id = str(formData, "id");
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
AdminSearchForm,
|
AdminSearchForm,
|
||||||
type AdminBadgeTone,
|
type AdminBadgeTone,
|
||||||
} from "@/components/admin-list";
|
} from "@/components/admin-list";
|
||||||
|
import { AdminArchiveTabs } from "@/components/admin-archive";
|
||||||
|
import { isArchiveView } from "@/lib/archive";
|
||||||
|
import { inquiryViewWhere } from "@/lib/inquiry-status";
|
||||||
import { updateBusinessInquiryStatusAction } from "../actions";
|
import { updateBusinessInquiryStatusAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -28,11 +31,11 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
|||||||
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
|
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
|
||||||
ABGELEHNT: t.adminInquiriesPage.statusRejected,
|
ABGELEHNT: t.adminInquiriesPage.statusRejected,
|
||||||
};
|
};
|
||||||
const { q } = await props.searchParams;
|
const { q, ansicht } = await props.searchParams;
|
||||||
const query = typeof q === "string" ? q.trim() : "";
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
|
const isArchive = isArchiveView(ansicht);
|
||||||
|
|
||||||
const inquiries = await prisma.businessInquiry.findMany({
|
const filterWhere = query
|
||||||
where: query
|
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ companyName: insensitiveContains(query) },
|
{ companyName: insensitiveContains(query) },
|
||||||
@@ -40,23 +43,45 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
|||||||
{ email: insensitiveContains(query) },
|
{ email: insensitiveContains(query) },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: undefined,
|
: {};
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
|
const [inquiries, activeCount, archivedCount] = await Promise.all([
|
||||||
|
prisma.businessInquiry.findMany({
|
||||||
|
where: { ...filterWhere, ...inquiryViewWhere(isArchive) },
|
||||||
|
// Aktiv: zuletzt eingegangen oben. Archiv: zuletzt erledigt oben.
|
||||||
|
orderBy: isArchive ? { updatedAt: "desc" } : { createdAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
}),
|
||||||
|
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(false) } }),
|
||||||
|
prisma.businessInquiry.count({ where: { ...filterWhere, ...inquiryViewWhere(true) } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
|
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
|
||||||
<AdminPageHeader title={t.adminInquiriesPage.title} />
|
<AdminPageHeader title={t.adminInquiriesPage.title} />
|
||||||
|
|
||||||
|
<AdminArchiveTabs
|
||||||
|
basePath="/admin/anfragen"
|
||||||
|
isArchive={isArchive}
|
||||||
|
counts={{ active: activeCount, archived: archivedCount }}
|
||||||
|
keepParams={{ q: query }}
|
||||||
|
labels={{
|
||||||
|
ariaLabel: t.adminArchive.tabsAriaLabel,
|
||||||
|
active: t.adminArchive.tabActive,
|
||||||
|
archive: t.adminArchive.tabArchive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleInquiries}</p> : null}
|
||||||
|
|
||||||
<AdminSearchForm
|
<AdminSearchForm
|
||||||
query={query}
|
query={query}
|
||||||
label={t.adminInquiriesPage.searchLabel}
|
label={t.adminInquiriesPage.searchLabel}
|
||||||
placeholder={t.adminInquiriesPage.searchPlaceholder}
|
placeholder={t.adminInquiriesPage.searchPlaceholder}
|
||||||
submitLabel={t.adminInquiriesPage.filterButton}
|
submitLabel={t.adminInquiriesPage.filterButton}
|
||||||
resetHref="/admin/anfragen"
|
resetHref={isArchive ? "/admin/anfragen?ansicht=archiv" : "/admin/anfragen"}
|
||||||
resetLabel={t.adminInquiriesPage.resetFilter}
|
resetLabel={t.adminInquiriesPage.resetFilter}
|
||||||
|
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{inquiries.map((inquiry) => (
|
{inquiries.map((inquiry) => (
|
||||||
@@ -119,7 +144,15 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
|||||||
{inquiries.length === 0 ? (
|
{inquiries.length === 0 ? (
|
||||||
<AdminEmptyState
|
<AdminEmptyState
|
||||||
bordered
|
bordered
|
||||||
message={query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
|
message={
|
||||||
|
query
|
||||||
|
? t.adminInquiriesPage.noResultsFiltered
|
||||||
|
: isArchive
|
||||||
|
? t.adminArchive.emptyArchive
|
||||||
|
: archivedCount > 0
|
||||||
|
? t.adminInquiriesPage.noOpenInquiries
|
||||||
|
: t.adminInquiriesPage.noInquiriesYet
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
@@ -12,58 +13,109 @@ import {
|
|||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
AdminSearchForm,
|
AdminSearchForm,
|
||||||
} from "@/components/admin-list";
|
} from "@/components/admin-list";
|
||||||
|
import { AdminArchiveTabs } from "@/components/admin-archive";
|
||||||
import { insensitiveContains } from "@/lib/prisma-search";
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
import { approveReviewAction, deleteReviewAction } from "../actions";
|
import { isArchiveView } from "@/lib/archive";
|
||||||
|
import { approvedReviewWhere, pendingReviewWhere, rejectedReviewWhere } from "@/lib/review-status";
|
||||||
|
import { approveReviewAction, deleteReviewAction, rejectReviewAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||||
|
|
||||||
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
|
const MAX_ROWS = 100;
|
||||||
const t = getDictionary(await getLocale());
|
const BASE_PATH = "/admin/bewertungen";
|
||||||
const { q } = await props.searchParams;
|
|
||||||
const query = typeof q === "string" ? q.trim() : "";
|
|
||||||
|
|
||||||
const reviews = await prisma.review.findMany({
|
const REVIEW_INCLUDE = {
|
||||||
where: query
|
product: { select: { name: true, slug: true } },
|
||||||
? {
|
customer: { select: { firstName: true, lastName: true } },
|
||||||
|
} satisfies Prisma.ReviewInclude;
|
||||||
|
|
||||||
|
type AdminReview = Prisma.ReviewGetPayload<{ include: typeof REVIEW_INCLUDE }>;
|
||||||
|
type ReviewTexts = Dictionary["adminReviewsPage"];
|
||||||
|
|
||||||
|
function searchWhere(query: string): Prisma.ReviewWhereInput {
|
||||||
|
if (!query) return {};
|
||||||
|
return {
|
||||||
OR: [
|
OR: [
|
||||||
{ product: { name: insensitiveContains(query) } },
|
{ product: { name: insensitiveContains(query) } },
|
||||||
{ customer: { firstName: insensitiveContains(query) } },
|
{ customer: { firstName: insensitiveContains(query) } },
|
||||||
{ customer: { lastName: insensitiveContains(query) } },
|
{ customer: { lastName: insensitiveContains(query) } },
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
: undefined,
|
}
|
||||||
include: {
|
|
||||||
product: { select: { name: true, slug: true } },
|
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
|
||||||
customer: { select: { firstName: true, lastName: true } },
|
const t = getDictionary(await getLocale());
|
||||||
},
|
const s = t.adminReviewsPage;
|
||||||
orderBy: [{ isApproved: "asc" }, { createdAt: "desc" }],
|
const { q, ansicht } = await props.searchParams;
|
||||||
take: 100,
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
});
|
const isArchive = isArchiveView(ansicht);
|
||||||
|
const filter = searchWhere(query);
|
||||||
|
|
||||||
|
const findReviews = (where: Prisma.ReviewWhereInput, orderBy: Prisma.ReviewOrderByWithRelationInput) =>
|
||||||
|
prisma.review.findMany({ where: { ...filter, ...where }, include: REVIEW_INCLUDE, orderBy, take: MAX_ROWS });
|
||||||
|
|
||||||
|
// Offene: aelteste zuerst, sie warten am laengsten. Archiv: zuletzt entschiedene oben.
|
||||||
|
const [pending, approved, rejected, pendingCount, decidedCount] = await Promise.all([
|
||||||
|
isArchive ? Promise.resolve([]) : findReviews(pendingReviewWhere(), { createdAt: "asc" }),
|
||||||
|
isArchive ? findReviews(approvedReviewWhere(), { updatedAt: "desc" }) : Promise.resolve([]),
|
||||||
|
isArchive ? findReviews(rejectedReviewWhere(), { rejectedAt: "desc" }) : Promise.resolve([]),
|
||||||
|
prisma.review.count({ where: { ...filter, ...pendingReviewWhere() } }),
|
||||||
|
prisma.review.count({ where: { ...filter, NOT: pendingReviewWhere() } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} />
|
<BackLink href="/admin" label={s.backToDashboard} />
|
||||||
<AdminPageHeader title={t.adminReviewsPage.title} />
|
<AdminPageHeader title={s.title} />
|
||||||
|
|
||||||
|
<AdminArchiveTabs
|
||||||
|
basePath={BASE_PATH}
|
||||||
|
isArchive={isArchive}
|
||||||
|
counts={{ active: pendingCount, archived: decidedCount }}
|
||||||
|
keepParams={{ q: query }}
|
||||||
|
labels={{
|
||||||
|
ariaLabel: t.adminArchive.tabsAriaLabel,
|
||||||
|
active: t.adminArchive.tabActive,
|
||||||
|
archive: t.adminArchive.tabArchive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleReviews}</p> : null}
|
||||||
|
|
||||||
<AdminSearchForm
|
<AdminSearchForm
|
||||||
query={query}
|
query={query}
|
||||||
label={t.adminReviewsPage.searchLabel}
|
label={s.searchLabel}
|
||||||
placeholder={t.adminReviewsPage.searchPlaceholder}
|
placeholder={s.searchPlaceholder}
|
||||||
submitLabel={t.adminReviewsPage.filterButton}
|
submitLabel={s.filterButton}
|
||||||
resetHref="/admin/bewertungen"
|
resetHref={isArchive ? `${BASE_PATH}?ansicht=archiv` : BASE_PATH}
|
||||||
resetLabel={t.adminReviewsPage.resetFilter}
|
resetLabel={s.resetFilter}
|
||||||
|
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isArchive ? (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<DecidedColumn title={s.columnApproved} reviews={approved} decision="approved" s={s} />
|
||||||
|
<DecidedColumn title={s.columnRejected} reviews={rejected} decision="rejected" s={s} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PendingTable reviews={pending} query={query} s={s} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingTable({ reviews, query, s }: { reviews: AdminReview[]; query: string; s: ReviewTexts }) {
|
||||||
|
return (
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-4">{t.adminReviewsPage.colProduct}</th>
|
<th className="p-4">{s.colProduct}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colCustomer}</th>
|
<th className="p-4">{s.colCustomer}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colRating}</th>
|
<th className="p-4">{s.colRating}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colComment}</th>
|
<th className="p-4">{s.colComment}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colDate}</th>
|
<th className="p-4">{s.colDate}</th>
|
||||||
<th className="p-4">{t.adminReviewsPage.colStatus}</th>
|
<th className="p-4">{s.colStatus}</th>
|
||||||
<th className="p-4" />
|
<th className="p-4" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -71,55 +123,124 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
|
|||||||
{reviews.map((review) => (
|
{reviews.map((review) => (
|
||||||
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
|
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<Link href={`/honig/${review.product.slug}`} className="link-draw text-heading">
|
<ProductLink review={review} />
|
||||||
{review.product.name}
|
|
||||||
</Link>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{review.customer.firstName} {review.customer.lastName}
|
{review.customer.firstName} {review.customer.lastName}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 whitespace-nowrap">
|
<td className="p-4 whitespace-nowrap">
|
||||||
<StarRating value={review.rating} />
|
<Stars rating={review.rating} />
|
||||||
<span className="sr-only">{review.rating} / 5</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
|
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
|
||||||
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
|
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{review.isApproved ? (
|
|
||||||
<AdminBadge tone="positive">{t.adminReviewsPage.statusPublished}</AdminBadge>
|
|
||||||
) : (
|
|
||||||
<AdminBadge tone="pending" pulse>
|
<AdminBadge tone="pending" pulse>
|
||||||
{t.adminReviewsPage.statusPending}
|
{s.statusPending}
|
||||||
</AdminBadge>
|
</AdminBadge>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<AdminRowActions>
|
<ReviewActions review={review} decision="pending" s={s} />
|
||||||
{!review.isApproved ? (
|
|
||||||
<form action={approveReviewAction}>
|
|
||||||
<input type="hidden" name="id" value={review.id} />
|
|
||||||
<AdminActionButton>{t.adminReviewsPage.approve}</AdminActionButton>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
<ConfirmForm
|
|
||||||
action={deleteReviewAction}
|
|
||||||
confirmMessage={t.adminReviewsPage.deleteConfirm}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={review.id} />
|
|
||||||
<AdminActionButton tone="danger">{t.adminReviewsPage.delete}</AdminActionButton>
|
|
||||||
</ConfirmForm>
|
|
||||||
</AdminRowActions>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{reviews.length === 0 ? (
|
{reviews.length === 0 ? (
|
||||||
<AdminEmptyState
|
<AdminEmptyState message={query ? s.noResultsFiltered : s.noPendingReviews} />
|
||||||
message={query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecidedColumn({
|
||||||
|
title,
|
||||||
|
reviews,
|
||||||
|
decision,
|
||||||
|
s,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
reviews: AdminReview[];
|
||||||
|
decision: "approved" | "rejected";
|
||||||
|
s: ReviewTexts;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section aria-label={title} className="bg-background border border-ink/10">
|
||||||
|
<h2 className="flex items-center justify-between gap-3 border-b border-ink/10 px-5 py-3 text-heading">
|
||||||
|
{title}
|
||||||
|
<AdminBadge tone={decision === "approved" ? "positive" : "neutral"}>{reviews.length}</AdminBadge>
|
||||||
|
</h2>
|
||||||
|
{reviews.length === 0 ? (
|
||||||
|
<p className="px-5 py-6 text-sm text-ink/60">{s.emptyColumn}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-ink/10">
|
||||||
|
{reviews.map((review) => (
|
||||||
|
<li key={review.id} className="space-y-2 px-5 py-4 text-sm">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<ProductLink review={review} />
|
||||||
|
<Stars rating={review.rating} />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-ink/70">
|
||||||
|
{review.customer.firstName} {review.customer.lastName} ·{" "}
|
||||||
|
<span className="tabular-nums">{formatDate(review.createdAt)}</span>
|
||||||
|
</p>
|
||||||
|
{review.comment ? <p className="text-ink/80">{review.comment}</p> : null}
|
||||||
|
{decision === "rejected" && review.rejectedAt ? (
|
||||||
|
<p className="text-xs text-ink/55">{s.rejectedOn(formatDate(review.rejectedAt))}</p>
|
||||||
|
) : null}
|
||||||
|
<ReviewActions review={review} decision={decision} s={s} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductLink({ review }: { review: AdminReview }) {
|
||||||
|
return (
|
||||||
|
<Link href={`/honig/${review.product.slug}`} className="link-draw text-heading">
|
||||||
|
{review.product.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stars({ rating }: { rating: number }) {
|
||||||
|
return (
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
<StarRating value={rating} />
|
||||||
|
<span className="sr-only">{rating} / 5</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freischalten und Ablehnen jeweils nur dort, wo es den Stand aendert; Loeschen immer. */
|
||||||
|
function ReviewActions({
|
||||||
|
review,
|
||||||
|
decision,
|
||||||
|
s,
|
||||||
|
}: {
|
||||||
|
review: AdminReview;
|
||||||
|
decision: "pending" | "approved" | "rejected";
|
||||||
|
s: ReviewTexts;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AdminRowActions>
|
||||||
|
{decision !== "approved" ? (
|
||||||
|
<form action={approveReviewAction}>
|
||||||
|
<input type="hidden" name="id" value={review.id} />
|
||||||
|
<AdminActionButton>{s.approve}</AdminActionButton>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{decision !== "rejected" ? (
|
||||||
|
<ConfirmForm action={rejectReviewAction} confirmMessage={s.rejectConfirm}>
|
||||||
|
<input type="hidden" name="id" value={review.id} />
|
||||||
|
<AdminActionButton>{s.reject}</AdminActionButton>
|
||||||
|
</ConfirmForm>
|
||||||
|
) : null}
|
||||||
|
<ConfirmForm action={deleteReviewAction} confirmMessage={s.deleteConfirm}>
|
||||||
|
<input type="hidden" name="id" value={review.id} />
|
||||||
|
<AdminActionButton tone="danger">{s.delete}</AdminActionButton>
|
||||||
|
</ConfirmForm>
|
||||||
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ 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 { prisma } from "@/lib/prisma";
|
||||||
|
import { pendingReviewWhere } from "@/lib/review-status";
|
||||||
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";
|
||||||
@@ -58,7 +59,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
|||||||
}),
|
}),
|
||||||
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
|
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
|
||||||
prisma.review.count({ where: { isApproved: false } }),
|
prisma.review.count({ where: pendingReviewWhere() }),
|
||||||
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { pendingReviewWhere } from "@/lib/review-status";
|
||||||
import { COUNTED_SALE_WHERE } from "@/lib/sales";
|
import { COUNTED_SALE_WHERE } from "@/lib/sales";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -264,7 +265,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
|||||||
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
||||||
}),
|
}),
|
||||||
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||||
prisma.review.count({ where: { isApproved: false } }),
|
prisma.review.count({ where: pendingReviewWhere() }),
|
||||||
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
||||||
]);
|
]);
|
||||||
const toDo = [
|
const toDo = [
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export async function GET() {
|
|||||||
comment: r.comment,
|
comment: r.comment,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
isApproved: r.isApproved,
|
isApproved: r.isApproved,
|
||||||
|
rejectedAt: r.rejectedAt,
|
||||||
})),
|
})),
|
||||||
wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })),
|
wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })),
|
||||||
newsletter: newsletter
|
newsletter: newsletter
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
|
import { getPublishedHarvestSlugByYearType } from "@/lib/data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alte Ernten-Adresse /ernten/<jahr>/<sorte> (z.B. /ernten/2026/Fr%C3%BChtracht).
|
||||||
|
*
|
||||||
|
* Sie stand in Sitemap, Links und Suchmaschinen, bevor Ernten eigene Slugs
|
||||||
|
* bekamen. 308 statt 404, damit diese Links weiter funktionieren und
|
||||||
|
* Suchmaschinen die neue Adresse uebernehmen.
|
||||||
|
*
|
||||||
|
* Das erste Segment heisst [slug], weil Next.js auf derselben Ebene keine
|
||||||
|
* zwei unterschiedlich benannten dynamischen Segmente erlaubt — hier steht
|
||||||
|
* darin das Jahr.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const YEAR_PATTERN = /^\d{4}$/;
|
||||||
|
|
||||||
|
function decodeSegment(segment: string): string | null {
|
||||||
|
try {
|
||||||
|
// Next.js liefert das Segment hier noch kodiert ("Fr%C3%BChtracht").
|
||||||
|
return decodeURIComponent(segment).normalize("NFC");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LegacyHarvestRedirect(props: PageProps<"/ernten/[slug]/[type]">) {
|
||||||
|
const { slug: year, type } = await props.params;
|
||||||
|
const decodedType = decodeSegment(type);
|
||||||
|
if (!YEAR_PATTERN.test(year) || !decodedType) notFound();
|
||||||
|
|
||||||
|
const target = await getPublishedHarvestSlugByYearType(Number(year), decodedType);
|
||||||
|
if (!target) notFound();
|
||||||
|
permanentRedirect(`/ernten/${target}`);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { ContentImage } from "@/components/content-image";
|
|||||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ProductTeaserLink } from "@/components/product-card";
|
import { ProductTeaserLink } from "@/components/product-card";
|
||||||
import { getHarvestByYearType } from "@/lib/data";
|
import { getHarvestBySlug } from "@/lib/data";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -15,29 +15,29 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
|||||||
// entkoppelt den Build vom DB-Zugriff und haelt Chargen-/Bestandsdaten aktuell.
|
// entkoppelt den Build vom DB-Zugriff und haelt Chargen-/Bestandsdaten aktuell.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata(props: PageProps<"/ernten/[slug]">): Promise<Metadata> {
|
||||||
props: PageProps<"/ernten/[year]/[type]">
|
const { slug } = await props.params;
|
||||||
): Promise<Metadata> {
|
const harvest = await getHarvestBySlug(slug);
|
||||||
const { year, type } = await props.params;
|
|
||||||
const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type));
|
|
||||||
if (!harvest) return {};
|
if (!harvest) return {};
|
||||||
return {
|
return {
|
||||||
title: `${harvest.type} ${harvest.year}`,
|
title: `${harvest.type} ${harvest.year}`,
|
||||||
description: harvest.description,
|
description: harvest.description,
|
||||||
alternates: { canonical: `/ernten/${year}/${type}` },
|
alternates: { canonical: `/ernten/${harvest.slug}` },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function HarvestDetailPage(
|
/**
|
||||||
props: PageProps<"/ernten/[year]/[type]">
|
* Eine Ernte je Slug. Frueher /ernten/<jahr>/<sorte> — das reichte nicht,
|
||||||
) {
|
* sobald dieselbe Sorte mehrmals im Jahr geerntet wird. Alte Adressen leitet
|
||||||
|
* ./[type]/page.tsx hierher weiter.
|
||||||
|
*/
|
||||||
|
export default async function HarvestDetailPage(props: PageProps<"/ernten/[slug]">) {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = getDictionary(locale);
|
const t = getDictionary(locale);
|
||||||
const { year, type } = await props.params;
|
const { slug } = await props.params;
|
||||||
// Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht"
|
// Slugs bestehen nur aus a-z, 0-9 und "-" (lib/slugify.ts) — ein Decoding
|
||||||
// statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf
|
// wie bei der frueheren Sorten-Adresse ist nicht noetig.
|
||||||
// automatisches Decoding zu verlassen.
|
const harvest = await getHarvestBySlug(slug);
|
||||||
const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type));
|
|
||||||
if (!harvest) notFound();
|
if (!harvest) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +50,7 @@ export default async function ErntenPage() {
|
|||||||
{byYear[year].map((harvest, i) => (
|
{byYear[year].map((harvest, i) => (
|
||||||
<ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
|
<ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
|
||||||
<Link
|
<Link
|
||||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
href={`/ernten/${harvest.slug}`}
|
||||||
className="group block"
|
className="group block"
|
||||||
>
|
>
|
||||||
<span className="relative block overflow-hidden rounded-lg transition-[transform,box-shadow] duration-500 ease-[var(--ease-arrive)] group-hover:-translate-y-1 group-hover:shadow-[0_28px_50px_-24px_rgba(36,53,43,0.5)]">
|
<span className="relative block overflow-hidden rounded-lg transition-[transform,box-shadow] duration-500 ease-[var(--ease-arrive)] group-hover:-translate-y-1 group-hover:shadow-[0_28px_50px_-24px_rgba(36,53,43,0.5)]">
|
||||||
|
|||||||
@@ -482,7 +482,11 @@ export default async function ProductDetailPage(
|
|||||||
</p>
|
</p>
|
||||||
) : ownReview ? (
|
) : ownReview ? (
|
||||||
<p className="text-sm text-ink/70">
|
<p className="text-sm text-ink/70">
|
||||||
{!ownReview.isApproved ? t.productPage.alreadyReviewedPending : t.productPage.alreadyReviewed}
|
{ownReview.isApproved
|
||||||
|
? t.productPage.alreadyReviewed
|
||||||
|
: ownReview.rejectedAt
|
||||||
|
? t.productPage.alreadyReviewedRejected
|
||||||
|
: t.productPage.alreadyReviewedPending}
|
||||||
</p>
|
</p>
|
||||||
) : !canReview ? (
|
) : !canReview ? (
|
||||||
<p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p>
|
<p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p>
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
})),
|
})),
|
||||||
...harvests.map((h) => ({
|
...harvests.map((h) => ({
|
||||||
url: `${base}/ernten/${h.year}/${encodeURIComponent(h.type)}`,
|
url: `${base}/ernten/${h.slug}`,
|
||||||
lastModified: h.updatedAt,
|
lastModified: h.updatedAt,
|
||||||
changeFrequency: "monthly" as const,
|
changeFrequency: "monthly" as const,
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
|
|||||||
@@ -835,6 +835,14 @@ export const de: Dictionary = {
|
|||||||
deleteConfirm: "Diese Bewertung wirklich löschen?",
|
deleteConfirm: "Diese Bewertung wirklich löschen?",
|
||||||
delete: "Löschen",
|
delete: "Löschen",
|
||||||
noReviewsYet: "Noch keine Bewertungen vorhanden.",
|
noReviewsYet: "Noch keine Bewertungen vorhanden.",
|
||||||
|
statusRejected: "Abgelehnt",
|
||||||
|
reject: "Ablehnen",
|
||||||
|
rejectConfirm: "Diese Bewertung ablehnen? Sie wird nicht im Shop angezeigt und landet im Archiv.",
|
||||||
|
noPendingReviews: "Keine offenen Bewertungen — alles entschieden.",
|
||||||
|
columnApproved: "Akzeptiert",
|
||||||
|
columnRejected: "Abgelehnt",
|
||||||
|
rejectedOn: (date) => `Abgelehnt am ${date}`,
|
||||||
|
emptyColumn: "Keine Einträge.",
|
||||||
},
|
},
|
||||||
adminAuditLogPage: {
|
adminAuditLogPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
@@ -1102,6 +1110,10 @@ export const de: Dictionary = {
|
|||||||
"Angenommene, abgelehnte und erstattete Retouren landen nach einem Monat ohne Bearbeitung automatisch hier.",
|
"Angenommene, abgelehnte und erstattete Retouren landen nach einem Monat ohne Bearbeitung automatisch hier.",
|
||||||
autoRuleProducts:
|
autoRuleProducts:
|
||||||
"Produkte, die seit über einem Monat Bestand 0 haben, landen automatisch hier und kehren zurück, sobald wieder Bestand da ist. Im Shop bleiben sie unverändert sichtbar.",
|
"Produkte, die seit über einem Monat Bestand 0 haben, landen automatisch hier und kehren zurück, sobald wieder Bestand da ist. Im Shop bleiben sie unverändert sichtbar.",
|
||||||
|
autoRuleInquiries:
|
||||||
|
"Abgeschlossene und abgelehnte Anfragen landen sofort hier. Wird der Status zurückgesetzt, kehrt die Anfrage in die aktive Liste zurück.",
|
||||||
|
autoRuleReviews:
|
||||||
|
"Freigeschaltete und abgelehnte Bewertungen landen sofort hier. Die Entscheidung lässt sich jederzeit umkehren.",
|
||||||
emptyArchive: "Das Archiv ist leer.",
|
emptyArchive: "Das Archiv ist leer.",
|
||||||
},
|
},
|
||||||
errorPage: {
|
errorPage: {
|
||||||
@@ -1341,6 +1353,7 @@ export const de: Dictionary = {
|
|||||||
unitsSuffix: (quantity) => `${quantity} Stück`,
|
unitsSuffix: (quantity) => `${quantity} Stück`,
|
||||||
deliveryDateLabel: "Liefertermin",
|
deliveryDateLabel: "Liefertermin",
|
||||||
noInquiriesYet: "Noch keine Firmenanfragen eingegangen.",
|
noInquiriesYet: "Noch keine Firmenanfragen eingegangen.",
|
||||||
|
noOpenInquiries: "Keine offenen Firmenanfragen.",
|
||||||
},
|
},
|
||||||
adminOrderDetailPage: {
|
adminOrderDetailPage: {
|
||||||
postNumberLabel: "DHL-Postnummer",
|
postNumberLabel: "DHL-Postnummer",
|
||||||
@@ -1876,6 +1889,7 @@ export const de: Dictionary = {
|
|||||||
loginToReview: "um dieses Produkt zu bewerten.",
|
loginToReview: "um dieses Produkt zu bewerten.",
|
||||||
alreadyReviewed: "Du hast dieses Produkt bereits bewertet.",
|
alreadyReviewed: "Du hast dieses Produkt bereits bewertet.",
|
||||||
alreadyReviewedPending: "Du hast dieses Produkt bereits bewertet, deine Bewertung wird noch geprüft.",
|
alreadyReviewedPending: "Du hast dieses Produkt bereits bewertet, deine Bewertung wird noch geprüft.",
|
||||||
|
alreadyReviewedRejected: "Du hast dieses Produkt bereits bewertet. Deine Bewertung wurde nicht veröffentlicht.",
|
||||||
onlyBuyersCanReview: "Nur Käufer:innen können dieses Produkt bewerten.",
|
onlyBuyersCanReview: "Nur Käufer:innen können dieses Produkt bewerten.",
|
||||||
yourRating: "Deine Bewertung",
|
yourRating: "Deine Bewertung",
|
||||||
commentPlaceholder: "Was hat dir gefallen? (optional)",
|
commentPlaceholder: "Was hat dir gefallen? (optional)",
|
||||||
|
|||||||
@@ -811,6 +811,14 @@ export const en: Dictionary = {
|
|||||||
deleteConfirm: "Really delete this review?",
|
deleteConfirm: "Really delete this review?",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
noReviewsYet: "No reviews yet.",
|
noReviewsYet: "No reviews yet.",
|
||||||
|
statusRejected: "Rejected",
|
||||||
|
reject: "Reject",
|
||||||
|
rejectConfirm: "Reject this review? It won't be shown in the shop and moves to the archive.",
|
||||||
|
noPendingReviews: "No open reviews — everything has been decided.",
|
||||||
|
columnApproved: "Accepted",
|
||||||
|
columnRejected: "Rejected",
|
||||||
|
rejectedOn: (date) => `Rejected on ${date}`,
|
||||||
|
emptyColumn: "No entries.",
|
||||||
},
|
},
|
||||||
adminAuditLogPage: {
|
adminAuditLogPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
@@ -1077,6 +1085,10 @@ export const en: Dictionary = {
|
|||||||
autoRuleReturns: "Accepted, rejected and refunded returns move here automatically after one month without changes.",
|
autoRuleReturns: "Accepted, rejected and refunded returns move here automatically after one month without changes.",
|
||||||
autoRuleProducts:
|
autoRuleProducts:
|
||||||
"Products with zero stock for more than a month move here automatically and return once they are back in stock. They stay visible in the shop.",
|
"Products with zero stock for more than a month move here automatically and return once they are back in stock. They stay visible in the shop.",
|
||||||
|
autoRuleInquiries:
|
||||||
|
"Completed and rejected inquiries move here right away. Resetting the status brings an inquiry back to the active list.",
|
||||||
|
autoRuleReviews:
|
||||||
|
"Approved and rejected reviews move here right away. The decision can be reversed at any time.",
|
||||||
emptyArchive: "The archive is empty.",
|
emptyArchive: "The archive is empty.",
|
||||||
},
|
},
|
||||||
errorPage: {
|
errorPage: {
|
||||||
@@ -1315,6 +1327,7 @@ export const en: Dictionary = {
|
|||||||
unitsSuffix: (quantity) => `${quantity} units`,
|
unitsSuffix: (quantity) => `${quantity} units`,
|
||||||
deliveryDateLabel: "Delivery date",
|
deliveryDateLabel: "Delivery date",
|
||||||
noInquiriesYet: "No business inquiries yet.",
|
noInquiriesYet: "No business inquiries yet.",
|
||||||
|
noOpenInquiries: "No open business inquiries.",
|
||||||
},
|
},
|
||||||
adminOrderDetailPage: {
|
adminOrderDetailPage: {
|
||||||
postNumberLabel: "DHL Post Number",
|
postNumberLabel: "DHL Post Number",
|
||||||
@@ -1850,6 +1863,7 @@ export const en: Dictionary = {
|
|||||||
loginToReview: "to review this product.",
|
loginToReview: "to review this product.",
|
||||||
alreadyReviewed: "You've already reviewed this product.",
|
alreadyReviewed: "You've already reviewed this product.",
|
||||||
alreadyReviewedPending: "You've already reviewed this product. Your review is still being checked.",
|
alreadyReviewedPending: "You've already reviewed this product. Your review is still being checked.",
|
||||||
|
alreadyReviewedRejected: "You've already reviewed this product. Your review was not published.",
|
||||||
onlyBuyersCanReview: "Only customers who purchased this product can review it.",
|
onlyBuyersCanReview: "Only customers who purchased this product can review it.",
|
||||||
yourRating: "Your rating",
|
yourRating: "Your rating",
|
||||||
commentPlaceholder: "What did you like? (optional)",
|
commentPlaceholder: "What did you like? (optional)",
|
||||||
|
|||||||
@@ -403,6 +403,14 @@ export interface Dictionary {
|
|||||||
deleteConfirm: string;
|
deleteConfirm: string;
|
||||||
delete: string;
|
delete: string;
|
||||||
noReviewsYet: string;
|
noReviewsYet: string;
|
||||||
|
statusRejected: string;
|
||||||
|
reject: string;
|
||||||
|
rejectConfirm: string;
|
||||||
|
noPendingReviews: string;
|
||||||
|
columnApproved: string;
|
||||||
|
columnRejected: string;
|
||||||
|
rejectedOn: (date: string) => string;
|
||||||
|
emptyColumn: string;
|
||||||
};
|
};
|
||||||
adminAuditLogPage: {
|
adminAuditLogPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
@@ -664,6 +672,8 @@ export interface Dictionary {
|
|||||||
autoRuleOrders: string;
|
autoRuleOrders: string;
|
||||||
autoRuleReturns: string;
|
autoRuleReturns: string;
|
||||||
autoRuleProducts: string;
|
autoRuleProducts: string;
|
||||||
|
autoRuleInquiries: string;
|
||||||
|
autoRuleReviews: string;
|
||||||
emptyArchive: string;
|
emptyArchive: string;
|
||||||
};
|
};
|
||||||
errorPage: {
|
errorPage: {
|
||||||
@@ -895,6 +905,7 @@ export interface Dictionary {
|
|||||||
unitsSuffix: (quantity: number) => string;
|
unitsSuffix: (quantity: number) => string;
|
||||||
deliveryDateLabel: string;
|
deliveryDateLabel: string;
|
||||||
noInquiriesYet: string;
|
noInquiriesYet: string;
|
||||||
|
noOpenInquiries: string;
|
||||||
};
|
};
|
||||||
adminOrderDetailPage: {
|
adminOrderDetailPage: {
|
||||||
postNumberLabel: string;
|
postNumberLabel: string;
|
||||||
@@ -1382,6 +1393,7 @@ export interface Dictionary {
|
|||||||
loginToReview: string;
|
loginToReview: string;
|
||||||
alreadyReviewed: string;
|
alreadyReviewed: string;
|
||||||
alreadyReviewedPending: string;
|
alreadyReviewedPending: string;
|
||||||
|
alreadyReviewedRejected: string;
|
||||||
onlyBuyersCanReview: string;
|
onlyBuyersCanReview: string;
|
||||||
yourRating: string;
|
yourRating: string;
|
||||||
commentPlaceholder: string;
|
commentPlaceholder: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
import { describe, test, expect } from "vitest";
|
||||||
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client";
|
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client";
|
||||||
import {
|
import {
|
||||||
slugForApiCoreSource,
|
hasLegacyApiCoreSlug,
|
||||||
buildHarvestDescription,
|
buildHarvestDescription,
|
||||||
buildProductDescription,
|
buildProductDescription,
|
||||||
deriveWeightGrams,
|
deriveWeightGrams,
|
||||||
@@ -40,25 +40,13 @@ function makeHarvest(overrides: Partial<ApiCoreHarvest> = {}): ApiCoreHarvest {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("slugForApiCoreSource", () => {
|
describe("hasLegacyApiCoreSlug", () => {
|
||||||
test("leitet den Slug ausschliesslich aus der ApiCore-ID ab", () => {
|
test("erkennt das alte Slug-Format 'apicore-<ID>'", () => {
|
||||||
// Arrange
|
expect(hasLegacyApiCoreSlug("apicore-cmtx1ri7x001j012bvcqh9tpt")).toBe(true);
|
||||||
const sourceId = "cm-product-1";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const slug = slugForApiCoreSource(sourceId);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(slug).toBe("apicore-cm-product-1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bleibt stabil, wenn sich in ApiCore nur der Name aendert", () => {
|
test("laesst lesbare Adressen in Ruhe", () => {
|
||||||
// Arrange: derselbe Datensatz, in ApiCore umbenannt.
|
expect(hasLegacyApiCoreSlug("bluetenhonig-2026")).toBe(false);
|
||||||
const before = makeProduct({ name: "Sommertracht 500 g" });
|
|
||||||
const after = makeProduct({ name: "Sommertracht cremig, 500 g" });
|
|
||||||
|
|
||||||
// Act & Assert: sonst wuerde der Sync ein zweites Produkt anlegen.
|
|
||||||
expect(slugForApiCoreSource(after.id)).toBe(slugForApiCoreSource(before.id));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+10
-16
@@ -1,4 +1,5 @@
|
|||||||
import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client";
|
import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client";
|
||||||
|
import { slugify } from "./slugify";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von
|
* Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von
|
||||||
@@ -10,15 +11,15 @@ import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stabiler, eindeutiger Slug aus der ApiCore-ID — zugleich der
|
* Frueher war der Harvest.slug "apicore-<ApiCore-ID>" zugleich der
|
||||||
* Idempotenz-Schluessel des Syncs: ein wiederholter Lauf erkennt daran, ob
|
* Idempotenz-Schluessel des Syncs. Seit der Slug die oeffentliche Adresse ist
|
||||||
* ein ApiCore-Datensatz schon einmal importiert wurde, statt bei jedem Lauf
|
* (/ernten/<slug>), traegt Harvest.apicoreHarvestId diese Aufgabe; Ernten mit
|
||||||
* Duplikate anzulegen. Bewusst NICHT aus Name/Jahr abgeleitet (wie bei den
|
* dem alten Slug-Format bekommen beim naechsten Sync eine lesbare Adresse.
|
||||||
* manuellen Anlege-Formularen): ein umbenannter Artikel bekaeme sonst einen
|
|
||||||
* neuen Slug und wuerde ein zweites Mal importiert.
|
|
||||||
*/
|
*/
|
||||||
export function slugForApiCoreSource(sourceId: string): string {
|
export const LEGACY_APICORE_SLUG_PREFIX = "apicore-";
|
||||||
return `apicore-${sourceId}`;
|
|
||||||
|
export function hasLegacyApiCoreSlug(slug: string): boolean {
|
||||||
|
return slug.startsWith(LEGACY_APICORE_SLUG_PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,14 +105,7 @@ export function deriveJarWeightGrams(
|
|||||||
|
|
||||||
/** Aus "Waldhonig, cremig" wird "waldhonig-cremig". */
|
/** Aus "Waldhonig, cremig" wird "waldhonig-cremig". */
|
||||||
export function slugifyProductName(name: string): string {
|
export function slugifyProductName(name: string): string {
|
||||||
return name
|
return slugify(name);
|
||||||
.toLowerCase()
|
|
||||||
.replace(/ä/g, "ae")
|
|
||||||
.replace(/ö/g, "oe")
|
|
||||||
.replace(/ü/g, "ue")
|
|
||||||
.replace(/ß/g, "ss")
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+18
-4
@@ -132,9 +132,8 @@ export async function getHarvests(): Promise<(HarvestWithImages & { batches: Bat
|
|||||||
* ist, damit unveroeffentlichte (z.B. frisch aus ApiCore synchronisierte)
|
* ist, damit unveroeffentlichte (z.B. frisch aus ApiCore synchronisierte)
|
||||||
* Ernten ueber ihre Shop-URL nicht erratbar sind.
|
* Ernten ueber ihre Shop-URL nicht erratbar sind.
|
||||||
*/
|
*/
|
||||||
export async function getHarvestByYearType(
|
export async function getHarvestBySlug(
|
||||||
year: number,
|
slug: string
|
||||||
type: string
|
|
||||||
): Promise<
|
): Promise<
|
||||||
| (HarvestWithImages & {
|
| (HarvestWithImages & {
|
||||||
batches: Batch[];
|
batches: Batch[];
|
||||||
@@ -143,13 +142,28 @@ export async function getHarvestByYearType(
|
|||||||
| null
|
| null
|
||||||
> {
|
> {
|
||||||
const harvest = await prisma.harvest.findUnique({
|
const harvest = await prisma.harvest.findUnique({
|
||||||
where: { year_type: { year, type } },
|
where: { slug },
|
||||||
include: { batches: true, products: true },
|
include: { batches: true, products: true },
|
||||||
});
|
});
|
||||||
if (!harvest || !harvest.isPublished) return null;
|
if (!harvest || !harvest.isPublished) return null;
|
||||||
return { ...withImages(harvest), products: harvest.products.map(withImages) };
|
return { ...withImages(harvest), products: harvest.products.map(withImages) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuer die Weiterleitung alter Adressen /ernten/<jahr>/<sorte>, die vor den
|
||||||
|
* Slug-Adressen verlinkt und indexiert wurden. Damals gab es je Jahr und
|
||||||
|
* Sorte hoechstens eine Ernte; gibt es inzwischen mehrere, fuehrt die alte
|
||||||
|
* Adresse zur fruehesten — der, die sie damals bezeichnete.
|
||||||
|
*/
|
||||||
|
export async function getPublishedHarvestSlugByYearType(year: number, type: string): Promise<string | null> {
|
||||||
|
const harvest = await prisma.harvest.findFirst({
|
||||||
|
where: { year, type, isPublished: true },
|
||||||
|
orderBy: { harvestDate: "asc" },
|
||||||
|
select: { slug: true },
|
||||||
|
});
|
||||||
|
return harvest?.slug ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBatchByCode(
|
export async function getBatchByCode(
|
||||||
code: string
|
code: string
|
||||||
): Promise<
|
): Promise<
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche der vorgeschlagenen Ernten-Slugs schon vergeben sind — fuer
|
||||||
|
* writeWithFreeSlug (lib/harvest-slug.ts). Getrennt von dort, damit jene
|
||||||
|
* Datei ohne Datenbank testbar bleibt.
|
||||||
|
*/
|
||||||
|
export async function findTakenHarvestSlugs(candidates: string[]): Promise<string[]> {
|
||||||
|
const rows = await prisma.harvest.findMany({
|
||||||
|
where: { slug: { in: candidates } },
|
||||||
|
select: { slug: true },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.slug);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { harvestSlugCandidates, writeWithFreeSlug, isUniqueConstraintError, MAX_SLUG_SUFFIX } from "./harvest-slug";
|
||||||
|
import { slugify } from "./slugify";
|
||||||
|
|
||||||
|
const JULY_14 = new Date("2026-07-14T00:00:00.000Z");
|
||||||
|
|
||||||
|
describe("slugify", () => {
|
||||||
|
it("schreibt Umlaute aus", () => {
|
||||||
|
expect(slugify("Blütenhonig")).toBe("bluetenhonig");
|
||||||
|
expect(slugify("Süßer Honig")).toBe("suesser-honig");
|
||||||
|
expect(slugify("FRÜHTRACHT")).toBe("fruehtracht");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behandelt zerlegte Umlaute (u + Trema) wie fertige", () => {
|
||||||
|
const decomposed = "Blütenhonig";
|
||||||
|
expect(decomposed).not.toBe("Blütenhonig");
|
||||||
|
expect(slugify(decomposed)).toBe("bluetenhonig");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entfernt sonstige Akzente und Sonderzeichen", () => {
|
||||||
|
expect(slugify("Crème brûlée & Akazie (fein)")).toBe("creme-brulee-akazie-fein");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("harvestSlugCandidates", () => {
|
||||||
|
it("bevorzugt Sorte und Jahr, dann das Erntedatum", () => {
|
||||||
|
const [first, second, third] = harvestSlugCandidates("Blütenhonig", JULY_14);
|
||||||
|
expect(first).toBe("bluetenhonig-2026");
|
||||||
|
expect(second).toBe("bluetenhonig-2026-07-14");
|
||||||
|
expect(third).toBe("bluetenhonig-2026-07-14-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unterscheidet zwei Ernten derselben Sorte im selben Jahr", () => {
|
||||||
|
const june = harvestSlugCandidates("Blütenhonig", new Date("2026-06-02T00:00:00.000Z"));
|
||||||
|
expect(june[1]).not.toBe(harvestSlugCandidates("Blütenhonig", JULY_14)[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faellt ohne verwertbare Sorte auf 'ernte' zurueck", () => {
|
||||||
|
expect(harvestSlugCandidates("???", JULY_14)[0]).toBe("ernte-2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert eine begrenzte Zahl Vorschlaege", () => {
|
||||||
|
expect(harvestSlugCandidates("Waldhonig", JULY_14)).toHaveLength(MAX_SLUG_SUFFIX + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeWithFreeSlug", () => {
|
||||||
|
const conflict = Object.assign(new Error("Unique constraint failed"), { code: "P2002" });
|
||||||
|
|
||||||
|
it("nimmt den ersten Vorschlag, wenn er frei ist", async () => {
|
||||||
|
const write = vi.fn(async (slug: string) => slug);
|
||||||
|
await expect(writeWithFreeSlug(["a", "b"], write)).resolves.toBe("a");
|
||||||
|
expect(write).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weicht bei belegtem Slug auf den naechsten aus", async () => {
|
||||||
|
const write = vi.fn(async (slug: string) => {
|
||||||
|
if (slug === "a") throw conflict;
|
||||||
|
return slug;
|
||||||
|
});
|
||||||
|
await expect(writeWithFreeSlug(["a", "b"], write)).resolves.toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft andere Fehler sofort weiter", async () => {
|
||||||
|
const write = vi.fn(async () => {
|
||||||
|
throw new Error("Verbindung verloren");
|
||||||
|
});
|
||||||
|
await expect(writeWithFreeSlug(["a", "b"], write)).rejects.toThrow("Verbindung verloren");
|
||||||
|
expect(write).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ueberspringt vorab bekannte Belegungen ohne Schreibversuch", async () => {
|
||||||
|
const write = vi.fn(async (slug: string) => slug);
|
||||||
|
await expect(writeWithFreeSlug(["a", "b"], write, async () => ["a"])).resolves.toBe("b");
|
||||||
|
expect(write).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet, wenn alle Vorschlaege belegt sind", async () => {
|
||||||
|
const write = vi.fn(async () => {
|
||||||
|
throw conflict;
|
||||||
|
});
|
||||||
|
await expect(writeWithFreeSlug(["a", "b"], write)).rejects.toThrow(/belegt/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isUniqueConstraintError", () => {
|
||||||
|
it("erkennt nur P2002", () => {
|
||||||
|
expect(isUniqueConstraintError({ code: "P2002" })).toBe(true);
|
||||||
|
expect(isUniqueConstraintError({ code: "P2025" })).toBe(false);
|
||||||
|
expect(isUniqueConstraintError(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { slugify } from "./slugify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oeffentliche Adresse einer Ernte (/ernten/<slug>).
|
||||||
|
*
|
||||||
|
* Eine Imkerei erntet dieselbe Sorte mehrmals im Jahr (z.B. zwei
|
||||||
|
* Schleuderungen Bluetenhonig im Juni und Juli). Sorte und Jahr allein
|
||||||
|
* unterscheiden Ernten deshalb nicht — eindeutig ist nur der Slug.
|
||||||
|
*
|
||||||
|
* Vorschlaege in absteigender Lesbarkeit:
|
||||||
|
* 1. "bluetenhonig-2026" — die erste Ernte einer Sorte im Jahr
|
||||||
|
* 2. "bluetenhonig-2026-07-14" — jede weitere, am Erntedatum erkennbar
|
||||||
|
* 3. "bluetenhonig-2026-07-14-2", "-3", … — gleicher Tag (selten, aber moeglich)
|
||||||
|
*
|
||||||
|
* Das Datum wird in UTC gelesen: ApiCore und das Admin-Formular liefern
|
||||||
|
* reine Kalendertage als Mitternacht UTC.
|
||||||
|
*/
|
||||||
|
export const MAX_SLUG_SUFFIX = 20;
|
||||||
|
|
||||||
|
export function harvestSlugCandidates(type: string, harvestDate: Date): string[] {
|
||||||
|
const base = slugify(type) || "ernte";
|
||||||
|
const year = harvestDate.getUTCFullYear();
|
||||||
|
const day = harvestDate.toISOString().slice(0, 10);
|
||||||
|
const withDay = `${base}-${day}`;
|
||||||
|
const numbered = Array.from({ length: MAX_SLUG_SUFFIX - 1 }, (_, i) => `${withDay}-${i + 2}`);
|
||||||
|
return [`${base}-${year}`, withDay, ...numbered];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prisma-Fehler "Unique constraint failed" — ohne Bindung an EINEN der beiden generierten Clients. */
|
||||||
|
export function isUniqueConstraintError(error: unknown): boolean {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error && error.code === "P2002";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt `write` mit dem ersten noch freien Slug aus.
|
||||||
|
*
|
||||||
|
* Bewusst KEIN "erst pruefen, dann schreiben": zwischen beiden Schritten
|
||||||
|
* koennte ein zweiter Admin-Tab oder ein paralleler Sync denselben Slug
|
||||||
|
* belegen. Stattdessen entscheidet der Unique-Index der Datenbank — schlaegt
|
||||||
|
* das Schreiben daran fehl, wird der naechste Vorschlag versucht.
|
||||||
|
*
|
||||||
|
* Voraussetzung: der einzige Unique-Index, den `write` verletzen kann, ist
|
||||||
|
* der auf `slug`. Andere Fehler werden unveraendert weitergeworfen.
|
||||||
|
*
|
||||||
|
* `findTaken` filtert die schon bekannten Belegungen vorab heraus. Das ist
|
||||||
|
* nur Komfort: Prisma protokolliert jeden abgefangenen Konflikt trotzdem als
|
||||||
|
* "prisma:error", und ein Log voller scheinbarer Fehler fuehrt in die Irre.
|
||||||
|
* Die Sicherheit kommt weiterhin allein vom Unique-Index.
|
||||||
|
*/
|
||||||
|
export async function writeWithFreeSlug<T>(
|
||||||
|
candidates: string[],
|
||||||
|
write: (slug: string) => Promise<T>,
|
||||||
|
findTaken: (candidates: string[]) => Promise<string[]> = async () => []
|
||||||
|
): Promise<T> {
|
||||||
|
const taken = new Set(await findTaken(candidates));
|
||||||
|
const free = candidates.filter((slug) => !taken.has(slug));
|
||||||
|
for (const slug of free) {
|
||||||
|
try {
|
||||||
|
return await write(slug);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isUniqueConstraintError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Alle ${candidates.length} Adressvorschläge sind belegt (zuletzt "${candidates.at(-1)}").`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Aktiv/Archiv fuer Firmenanfragen.
|
||||||
|
*
|
||||||
|
* Abgeschlossene und abgelehnte Anfragen wandern ins Archiv, sobald der
|
||||||
|
* Status gesetzt ist. Wird der Status wieder auf "Neu" oder "In Bearbeitung"
|
||||||
|
* gestellt, steht die Anfrage automatisch wieder in der aktiven Liste — es
|
||||||
|
* gibt keine eigene Archiv-Markierung, die man vergessen koennte.
|
||||||
|
*/
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
export const INQUIRY_STATUSES = ["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"] as const;
|
||||||
|
export type InquiryStatusValue = (typeof INQUIRY_STATUSES)[number];
|
||||||
|
|
||||||
|
export const INQUIRY_DONE_STATUSES = ["ABGESCHLOSSEN", "ABGELEHNT"] as const satisfies readonly InquiryStatusValue[];
|
||||||
|
|
||||||
|
export function isInquiryStatus(value: string): value is InquiryStatusValue {
|
||||||
|
return (INQUIRY_STATUSES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inquiryViewWhere(isArchive: boolean) {
|
||||||
|
return {
|
||||||
|
status: isArchive ? { in: [...INQUIRY_DONE_STATUSES] } : { notIn: [...INQUIRY_DONE_STATUSES] },
|
||||||
|
} satisfies Prisma.BusinessInquiryWhereInput;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
reviewDecision,
|
||||||
|
pendingReviewWhere,
|
||||||
|
approvedReviewWhere,
|
||||||
|
rejectedReviewWhere,
|
||||||
|
} from "./review-status";
|
||||||
|
import { inquiryViewWhere, isInquiryStatus } from "./inquiry-status";
|
||||||
|
|
||||||
|
describe("reviewDecision", () => {
|
||||||
|
it("ist offen, solange weder freigeschaltet noch abgelehnt", () => {
|
||||||
|
expect(reviewDecision({ isApproved: false, rejectedAt: null })).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist abgelehnt, wenn rejectedAt gesetzt ist", () => {
|
||||||
|
expect(reviewDecision({ isApproved: false, rejectedAt: new Date("2026-10-01T00:00:00Z") })).toBe("rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Freischaltung schlaegt eine fruehere Ablehnung", () => {
|
||||||
|
expect(reviewDecision({ isApproved: true, rejectedAt: new Date("2026-10-01T00:00:00Z") })).toBe("approved");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bewertungs-Filter", () => {
|
||||||
|
it("offene Bewertungen: weder freigeschaltet noch abgelehnt", () => {
|
||||||
|
expect(pendingReviewWhere()).toEqual({ isApproved: false, rejectedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("akzeptiert und abgelehnt ueberschneiden sich nicht", () => {
|
||||||
|
expect(approvedReviewWhere()).toEqual({ isApproved: true });
|
||||||
|
expect(rejectedReviewWhere()).toEqual({ isApproved: false, rejectedAt: { not: null } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inquiryViewWhere", () => {
|
||||||
|
it("aktive Liste: alles ausser abgeschlossen und abgelehnt", () => {
|
||||||
|
expect(inquiryViewWhere(false)).toEqual({ status: { notIn: ["ABGESCHLOSSEN", "ABGELEHNT"] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Archiv: abgeschlossen und abgelehnt", () => {
|
||||||
|
expect(inquiryViewWhere(true)).toEqual({ status: { in: ["ABGESCHLOSSEN", "ABGELEHNT"] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isInquiryStatus", () => {
|
||||||
|
it("akzeptiert nur die vier bekannten Status", () => {
|
||||||
|
expect(isInquiryStatus("NEU")).toBe(true);
|
||||||
|
expect(isInquiryStatus("ABGELEHNT")).toBe(true);
|
||||||
|
expect(isInquiryStatus("GELOESCHT")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Bearbeitungsstand einer Bewertung im Admin.
|
||||||
|
*
|
||||||
|
* Eine Bewertung ist offen, bis der Admin sie freischaltet (isApproved) oder
|
||||||
|
* ablehnt (rejectedAt). Offene stehen in der aktiven Liste, entschiedene im
|
||||||
|
* Archiv — dort getrennt nach "Akzeptiert" und "Abgelehnt". Der Shop zeigt
|
||||||
|
* weiterhin ausschliesslich isApproved-Bewertungen (lib/reviews.ts).
|
||||||
|
*
|
||||||
|
* Anders als Bestellungen und Retouren (lib/archive.ts) gibt es hier keine
|
||||||
|
* Monatsfrist: eine entschiedene Bewertung braucht keine weitere Arbeit, sie
|
||||||
|
* gehoert sofort ins Archiv.
|
||||||
|
*/
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
export type ReviewDecision = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
export function reviewDecision(review: { isApproved: boolean; rejectedAt: Date | null }): ReviewDecision {
|
||||||
|
if (review.isApproved) return "approved";
|
||||||
|
if (review.rejectedAt) return "rejected";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pendingReviewWhere() {
|
||||||
|
return { isApproved: false, rejectedAt: null } satisfies Prisma.ReviewWhereInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approvedReviewWhere() {
|
||||||
|
return { isApproved: true } satisfies Prisma.ReviewWhereInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectedReviewWhere() {
|
||||||
|
return { isApproved: false, rejectedAt: { not: null } } satisfies Prisma.ReviewWhereInput;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Lesbarer URL-Teil aus beliebigem Text: "Blütenhonig, cremig" wird zu
|
||||||
|
* "bluetenhonig-cremig".
|
||||||
|
*
|
||||||
|
* Zuerst NFC: ein "ü" kann als ein Zeichen (U+00FC) oder als "u" plus
|
||||||
|
* Trema (U+0308) ankommen — je nachdem, ob der Text aus einem Formular, aus
|
||||||
|
* ApiCore oder aus einer kopierten macOS-Datei stammt. Ohne Normalisierung
|
||||||
|
* wuerde die zweite Form zu "u-" statt zu "ue". Deutsche Umlaute werden
|
||||||
|
* ausgeschrieben, uebrige Akzente (é, à, …) entfernt.
|
||||||
|
*/
|
||||||
|
export function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize("NFC")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, "ae")
|
||||||
|
.replace(/ö/g, "oe")
|
||||||
|
.replace(/ü/g, "ue")
|
||||||
|
.replace(/ß/g, "ss")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user