157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
import type { CampaignInput, TierInput } from "./api";
|
|
import type { TierType } from "./types";
|
|
|
|
/**
|
|
* Parsing helpers shared by the admin form and its server action. Kept apart
|
|
* from the action file so they can be read without "use server" in the way.
|
|
*/
|
|
|
|
export const CATEGORIES = [
|
|
"Közösségi tér",
|
|
"Oktatás",
|
|
"Sport",
|
|
"Kultúra & Művészet",
|
|
"Környezet",
|
|
"Egészség",
|
|
] as const;
|
|
|
|
export const ART_KEYS = ["kert", "hang", "bicikli", "szinhaz"] as const;
|
|
|
|
export const STATUSES = [
|
|
{ value: "DRAFT", label: "Piszkozat" },
|
|
{ value: "BEADVA", label: "Beadva" },
|
|
{ value: "AKTIV", label: "Aktív" },
|
|
{ value: "FINANSZIROZOTT", label: "Finanszírozott" },
|
|
{ value: "LEZART", label: "Lezárt" },
|
|
{ value: "ELUTASITVA", label: "Elutasítva" },
|
|
] as const;
|
|
|
|
export const TIER_TYPES: { value: Uppercase<TierType>; label: string }[] = [
|
|
{ value: "DONATION", label: "Adomány — azonnal terhelünk" },
|
|
{ value: "DIGITAL", label: "Digitális — azonnal terhelünk" },
|
|
{ value: "PHYSICAL", label: "Tárgyi — csak a cél elérésekor terhelünk" },
|
|
];
|
|
|
|
export interface StoryDraft {
|
|
heading: string;
|
|
body: string[];
|
|
}
|
|
|
|
/**
|
|
* The story is edited as one textarea. A line starting with "## " opens a new
|
|
* section; blank lines separate paragraphs. Minimal, and readable as plain text.
|
|
*/
|
|
export function parseStory(raw: string): StoryDraft[] {
|
|
const sections: StoryDraft[] = [];
|
|
let current: StoryDraft | null = null;
|
|
let paragraph: string[] = [];
|
|
|
|
const flushParagraph = () => {
|
|
const text = paragraph.join(" ").trim();
|
|
if (text && current) current.body.push(text);
|
|
paragraph = [];
|
|
};
|
|
|
|
for (const line of raw.replace(/\r\n/g, "\n").split("\n")) {
|
|
if (line.startsWith("## ")) {
|
|
flushParagraph();
|
|
if (current) sections.push(current);
|
|
current = { heading: line.slice(3).trim(), body: [] };
|
|
} else if (line.trim() === "") {
|
|
flushParagraph();
|
|
} else {
|
|
paragraph.push(line.trim());
|
|
}
|
|
}
|
|
flushParagraph();
|
|
if (current) sections.push(current);
|
|
return sections;
|
|
}
|
|
|
|
export function serializeStory(sections: StoryDraft[]): string {
|
|
return sections
|
|
.map((s) => `## ${s.heading}\n\n${s.body.join("\n\n")}`)
|
|
.join("\n\n");
|
|
}
|
|
|
|
/** "1 200 000" / "1200000 Ft" -> 1200000. Empty or unparseable -> null. */
|
|
export function parseAmount(raw: FormDataEntryValue | null): number | null {
|
|
if (typeof raw !== "string") return null;
|
|
const digits = raw.replace(/[^\d]/g, "");
|
|
if (!digits) return null;
|
|
return Number.parseInt(digits, 10);
|
|
}
|
|
|
|
export function slugify(input: string): string {
|
|
const map: Record<string, string> = {
|
|
á: "a", é: "e", í: "i", ó: "o", ö: "o", ő: "o",
|
|
ú: "u", ü: "u", ű: "u",
|
|
};
|
|
return input
|
|
.toLowerCase()
|
|
.replace(/[áéíóöőúüű]/g, (c) => map[c] ?? c)
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 80);
|
|
}
|
|
|
|
/**
|
|
* Reads the tier editor back out of a submitted form.
|
|
*
|
|
* The reconciliation this feeds used to live in a server action: rows carrying
|
|
* an id are updates, rows without one are new, and a row ticked for deletion
|
|
* is simply left out — the API deletes whatever is missing, unless pledges
|
|
* reference it. Empty rows are ignored so the always-present blank row does
|
|
* not create a tier.
|
|
*/
|
|
export function readTierRows(form: FormData, rowCount: number): TierInput[] {
|
|
const tiers: TierInput[] = [];
|
|
|
|
for (let i = 0; i < rowCount; i++) {
|
|
const field = (key: string) => form.get(`tier-${i}-${key}`);
|
|
const id = String(field("id") ?? "");
|
|
|
|
if (field("delete") === "on") continue;
|
|
|
|
const title = String(field("title") ?? "").trim();
|
|
const price = parseAmount(field("price"));
|
|
if (!title || !price) continue;
|
|
|
|
tiers.push({
|
|
id,
|
|
title,
|
|
price,
|
|
listPrice: parseAmount(field("listPrice")),
|
|
type: String(field("type") ?? "DONATION"),
|
|
includes: String(field("includes") ?? "")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean),
|
|
inventoryLimit: parseAmount(field("limit")),
|
|
estimatedDelivery: String(field("delivery") ?? ""),
|
|
featured: field("featured") === "on",
|
|
});
|
|
}
|
|
|
|
return tiers;
|
|
}
|
|
|
|
/** Reads the whole editor, ready to PUT. */
|
|
export function readCampaignForm(form: FormData, rowCount: number): CampaignInput {
|
|
const title = String(form.get("title") ?? "").trim();
|
|
return {
|
|
id: String(form.get("campaignId") ?? ""),
|
|
title,
|
|
slug: slugify(String(form.get("slug") ?? "") || title),
|
|
category: String(form.get("category") ?? ""),
|
|
summary: String(form.get("summary") ?? "").trim(),
|
|
art: String(form.get("art") ?? "kert"),
|
|
goalAmount: parseAmount(form.get("goalAmount")) ?? 0,
|
|
endsAt: String(form.get("endsAt") ?? ""),
|
|
status: String(form.get("status") ?? "DRAFT"),
|
|
ownerId: String(form.get("ownerId") ?? ""),
|
|
story: parseStory(String(form.get("story") ?? "")),
|
|
tiers: readTierRows(form, rowCount),
|
|
};
|
|
}
|