From 94ef7901e29c69e47ec9f4cb1cbb697dd2ad445a Mon Sep 17 00:00:00 2001 From: dimitar Date: Wed, 29 Jul 2026 18:58:18 +0200 Subject: [PATCH] feat(code-gating): add code validation and enforce code usage for memorial creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /api/validate-code endpoint: validates code, marks it as used by the current Clerk user, prevents reuse - Add 'Код' step to onboarding wizard (step 0): user must enter and validate a code before proceeding to fill memorial details - Protect /api/publish: reject with 403 if user has not consumed a valid code - Code input auto-capitalizes on the onboarding page --- src/app/api/publish/route.ts | 7 +++ src/app/api/validate-code/route.ts | 44 ++++++++++++++++ src/app/onboarding/page.tsx | 82 ++++++++++++++++++++++++++---- 3 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 src/app/api/validate-code/route.ts diff --git a/src/app/api/publish/route.ts b/src/app/api/publish/route.ts index b8449ef..ea4556a 100644 --- a/src/app/api/publish/route.ts +++ b/src/app/api/publish/route.ts @@ -11,6 +11,13 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); } + const hasCode = await prisma.code.findFirst({ + where: { usedByUserId: userId }, + }); + if (!hasCode) { + return NextResponse.json({ error: "Потребен е валиден код за креирање спомен страница" }, { status: 403 }); + } + try { const body = await req.json(); const { title, description, bornDate, passedDate, subdomain, templateId, images } = body; diff --git a/src/app/api/validate-code/route.ts b/src/app/api/validate-code/route.ts new file mode 100644 index 0000000..77e0248 --- /dev/null +++ b/src/app/api/validate-code/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { prisma } from "@/lib/prisma"; + +export async function POST(req: NextRequest) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); + } + + try { + const { code } = await req.json(); + if (!code?.trim()) { + return NextResponse.json({ error: "Кодот е задолжителен" }, { status: 400 }); + } + + const normalizedCode = code.trim().toUpperCase(); + + const existing = await prisma.code.findUnique({ where: { code: normalizedCode } }); + if (!existing) { + return NextResponse.json({ valid: false, error: "Невалиден код" }); + } + if (existing.usedByUserId) { + return NextResponse.json({ valid: false, error: "Кодот е веќе искористен" }); + } + + const alreadyUsed = await prisma.code.findFirst({ + where: { usedByUserId: userId }, + }); + if (alreadyUsed) { + return NextResponse.json({ valid: false, error: "Веќе имате искористено код" }); + } + + await prisma.code.update({ + where: { id: existing.id }, + data: { usedByUserId: userId, usedAt: new Date() }, + }); + + return NextResponse.json({ valid: true }); + } catch (error) { + console.error("Validate code error:", error); + return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); + } +} diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 9c6ae46..1a18178 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -6,7 +6,7 @@ import ImageUploader from "@/components/ImageUploader"; import SubdomainPicker from "@/components/SubdomainPicker"; import TemplatePicker from "@/components/TemplatePicker"; -const STEPS = ["Податоци", "Датуми", "Фотографии", "Поддомен", "Шаблон"] as const; +const STEPS = ["Код", "Податоци", "Датуми", "Фотографии", "Поддомен", "Шаблон"] as const; export default function OnboardingWizard() { const router = useRouter(); @@ -14,6 +14,10 @@ export default function OnboardingWizard() { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [code, setCode] = useState(""); + const [codeValidated, setCodeValidated] = useState(false); + const [codeValidating, setCodeValidating] = useState(false); + const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [bornDate, setBornDate] = useState(""); @@ -24,15 +28,37 @@ export default function OnboardingWizard() { const canProceed = () => { switch (step) { - case 0: return title.trim().length > 0; - case 1: return true; - case 2: return images.length > 0; - case 3: return subdomain.length >= 3; - case 4: return templateId >= 1 && templateId <= 3; + case 0: return codeValidated; + case 1: return title.trim().length > 0; + case 2: return true; + case 3: return images.length > 0; + case 4: return subdomain.length >= 3; + case 5: return templateId >= 1 && templateId <= 3; default: return false; } }; + const handleValidateCode = async () => { + setCodeValidating(true); + setError(""); + try { + const res = await fetch("/api/validate-code", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }); + const data = await res.json(); + if (!data.valid) { + throw new Error(data.error || "Невалиден код"); + } + setCodeValidated(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Грешка при валидација"); + } finally { + setCodeValidating(false); + } + }; + const handlePublish = async () => { setLoading(true); setError(""); @@ -100,6 +126,42 @@ export default function OnboardingWizard() { )} {step === 0 && ( +
+

+ Внесете го кодот што го добивте за да креирате спомен страница. +

+
+ { + setCode(e.target.value.toUpperCase()); + setCodeValidated(false); + }} + placeholder="Внесете код" + className="mt-1 block flex-1 rounded-lg border border-stone-200 px-3 py-2.5 font-mono text-lg uppercase tracking-widest text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + maxLength={20} + disabled={codeValidated} + /> + {!codeValidated ? ( + + ) : ( +
+ Потврден +
+ )} +
+
+ )} + + {step === 1 && (
)} - {step === 1 && ( + {step === 2 && (

Овие се опционални. Можете да внесете точни датуми, приближни години, или да ги оставите празни. @@ -168,18 +230,18 @@ export default function OnboardingWizard() {

)} - {step === 2 && ( + {step === 3 && ( setImages(newImages as typeof images)} /> )} - {step === 3 && ( + {step === 4 && ( )} - {step === 4 && ( + {step === 5 && (