szabadpalya/web/src/lib/campaign-form.ts

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),
};
}