Files
TX/artifacts/tx-os/src/pages/protocol-request.tsx
T
Replit Agent 62c38a508f Add public interface for submitting room booking requests
Introduces a new public-facing API endpoint and UI for submitting room booking requests without authentication, including rate limiting and input validation.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 5352d582-4c15-426c-84ce-4ba3fa0d5cd8
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/OsTuUKE
Replit-Helium-Checkpoint-Created: true
2026-07-06 11:18:14 +00:00

346 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CalendarClock, CheckCircle2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const API = `${import.meta.env.BASE_URL}api`;
type PublicRoom = { id: number; nameAr: string; nameEn: string };
type FormState = {
requesterName: string;
title: string;
requesterPhone: string;
requesterOrg: string;
roomId: string;
startsAt: string;
endsAt: string;
notes: string;
website: string; // honeypot
};
const EMPTY: FormState = {
requesterName: "",
title: "",
requesterPhone: "",
requesterOrg: "",
roomId: "",
startsAt: "",
endsAt: "",
notes: "",
website: "",
};
function localToIso(v: string): string {
return new Date(v).toISOString();
}
export default function ProtocolRequestPage() {
// This page is public (no auth). Force RTL Arabic regardless of any
// stored language preference so a guest always sees the intended form.
useEffect(() => {
const prevDir = document.documentElement.dir;
const prevLang = document.documentElement.lang;
document.documentElement.dir = "rtl";
document.documentElement.lang = "ar";
return () => {
// Restore the app's direction/language so navigating away from this
// public page doesn't leave the rest of the app forced into RTL Arabic.
document.documentElement.dir = prevDir;
document.documentElement.lang = prevLang;
};
}, []);
const rooms = useQuery<PublicRoom[]>({
queryKey: ["protocol-public", "rooms"],
queryFn: async () => {
const r = await fetch(`${API}/protocol/public/rooms`);
if (!r.ok) throw new Error("rooms");
return r.json();
},
});
const [form, setForm] = useState<FormState>(EMPTY);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const canSubmit = useMemo(() => {
return (
form.requesterName.trim().length > 0 &&
form.title.trim().length > 0 &&
form.requesterPhone.trim().length >= 3 &&
form.roomId !== "" &&
form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) < new Date(form.endsAt)
);
}, [form]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!canSubmit || submitting) return;
setSubmitting(true);
try {
const res = await fetch(`${API}/protocol/public/booking-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: Number(form.roomId),
title: form.title.trim(),
requesterName: form.requesterName.trim(),
requesterPhone: form.requesterPhone.trim(),
requesterOrg: form.requesterOrg.trim() || null,
startsAt: localToIso(form.startsAt),
endsAt: localToIso(form.endsAt),
notes: form.notes.trim() || null,
website: form.website,
}),
});
if (res.ok) {
setDone(true);
return;
}
let msg = "تعذّر إرسال الطلب، يرجى المحاولة مرة أخرى.";
try {
const data = await res.json();
if (data?.error && typeof data.error === "string") msg = data.error;
} catch {
// keep default message
}
if (res.status === 429) {
msg = "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.";
}
setError(msg);
} catch {
setError("تعذّر الاتصال بالخادم، تحقق من الاتصال وحاول مجدداً.");
} finally {
setSubmitting(false);
}
};
if (done) {
return (
<div
dir="rtl"
className="min-h-screen bg-slate-50 flex items-center justify-center p-4"
>
<div className="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-sm p-8 text-center">
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-slate-800 mb-2">
تم استلام طلبك
</h1>
<p className="text-slate-500 leading-relaxed">
سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف
المُدخل لتأكيد الموعد.
</p>
<Button
className="mt-6"
variant="outline"
onClick={() => {
setForm(EMPTY);
setDone(false);
}}
>
إرسال طلب آخر
</Button>
</div>
</div>
);
}
return (
<div dir="rtl" className="min-h-screen bg-slate-50 py-8 px-4">
<div className="w-full max-w-xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<div className="w-11 h-11 rounded-xl bg-sky-500/10 text-sky-600 flex items-center justify-center">
<CalendarClock className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-800">طلب حجز قاعة</h1>
<p className="text-sm text-slate-500">
يرجى تعبئة البيانات وسيتواصل معك فريق المراسم للتأكيد.
</p>
</div>
</div>
<form
onSubmit={onSubmit}
className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 space-y-5"
>
{/* Honeypot: hidden from real users, catches bots. */}
<div className="hidden" aria-hidden="true">
<label>
Website
<input
type="text"
tabIndex={-1}
autoComplete="off"
value={form.website}
onChange={(e) => set("website", e.target.value)}
/>
</label>
</div>
<div className="space-y-2">
<Label htmlFor="requesterName">
الاسم الكامل <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterName"
value={form.requesterName}
onChange={(e) => set("requesterName", e.target.value)}
placeholder="الاسم الثلاثي"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">
عنوان الاجتماع <span className="text-rose-500">*</span>
</Label>
<Input
id="title"
value={form.title}
onChange={(e) => set("title", e.target.value)}
placeholder="موضوع أو عنوان الاجتماع"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="requesterPhone">
رقم الهاتف للتواصل <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterPhone"
type="tel"
inputMode="tel"
dir="ltr"
className="text-right"
value={form.requesterPhone}
onChange={(e) => set("requesterPhone", e.target.value)}
placeholder="05xxxxxxxx"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="requesterOrg">الجهة / المؤسسة</Label>
<Input
id="requesterOrg"
value={form.requesterOrg}
onChange={(e) => set("requesterOrg", e.target.value)}
placeholder="اسم الجهة أو المؤسسة (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="room">
القاعة <span className="text-rose-500">*</span>
</Label>
<Select
value={form.roomId}
onValueChange={(v) => set("roomId", v)}
>
<SelectTrigger id="room">
<SelectValue
placeholder={
rooms.isLoading ? "جارٍ التحميل…" : "اختر القاعة"
}
/>
</SelectTrigger>
<SelectContent>
{(rooms.data ?? []).map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.nameAr || r.nameEn}
</SelectItem>
))}
</SelectContent>
</Select>
{rooms.isError && (
<p className="text-xs text-rose-500">
تعذّر تحميل قائمة القاعات، يرجى تحديث الصفحة.
</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startsAt">
من <span className="text-rose-500">*</span>
</Label>
<Input
id="startsAt"
type="datetime-local"
value={form.startsAt}
onChange={(e) => set("startsAt", e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="endsAt">
إلى <span className="text-rose-500">*</span>
</Label>
<Input
id="endsAt"
type="datetime-local"
value={form.endsAt}
onChange={(e) => set("endsAt", e.target.value)}
required
/>
</div>
</div>
{form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) >= new Date(form.endsAt) && (
<p className="text-xs text-rose-500 -mt-2">
يجب أن يكون وقت البداية قبل وقت النهاية.
</p>
)}
<div className="space-y-2">
<Label htmlFor="notes">ملاحظات</Label>
<Textarea
id="notes"
value={form.notes}
onChange={(e) => set("notes", e.target.value)}
placeholder="أي تفاصيل إضافية (اختياري)"
rows={3}
/>
</div>
{error && (
<div className="rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2">
{error}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={!canSubmit || submitting}
>
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
إرسال الطلب
</Button>
</form>
</div>
</div>
);
}