here. The colocated `loading.tsx` already provides the
+// streaming fallback for this segment; adding a redundant Suspense boundary
+// triggers Next 16 Turbopack's "$RS parentNode null" hydration race.
export default async function HomePage({
params,
}: {
@@ -37,15 +39,5 @@ export default async function HomePage({
const { lang } = await params;
const listings = await getPublishedListings();
- return (
-
-
-
- }
- >
-
-
- );
+ return ;
}
diff --git a/src/app/error.tsx b/src/app/error.tsx
index 39e69bb..5d67890 100644
--- a/src/app/error.tsx
+++ b/src/app/error.tsx
@@ -1,8 +1,30 @@
'use client';
import { useEffect } from 'react';
-import { Button } from '@/components/ui/button';
-import { AlertTriangle } from 'lucide-react';
+
+// Inline SVG + native buttons instead of `lucide-react` / `@/components/ui/button`:
+// Turbopack's error.tsx boundary fires before lazy chunks finish loading, so any
+// external component import fails with "module factory is not available". Keep
+// this file dependency-light — only `react` is safe to import here.
+function AlertTriangle({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+ );
+}
export default function Error({
error,
@@ -60,19 +82,18 @@ export default function Error({
-
Try again
-
- window.location.href = '/'}
- variant="outline"
- size="lg"
+
+ (window.location.href = '/')}
+ className="inline-flex items-center justify-center rounded-md border border-input bg-background px-6 py-2.5 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
Go home
-
+
diff --git a/src/components/report-issue.tsx b/src/components/report-issue.tsx
deleted file mode 100644
index ab08cf5..0000000
--- a/src/components/report-issue.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-"use client"
-
-import { useState } from "react"
-import { usePathname } from "next/navigation"
-import { Bug } from "lucide-react"
-
-import { reportIssue } from "@/lib/actions/report-issue"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-
-const translations = {
- en: {
- link: "Report an issue",
- title: "Report an issue",
- placeholder: "Describe the issue...",
- submit: "Submit",
- submitting: "Submitting...",
- success: "Submitted. Thank you!",
- error: "Something went wrong. Try again.",
- },
- ar: {
- link: "\u0627\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646 \u0645\u0634\u0643\u0644\u0629",
- title: "\u0627\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646 \u0645\u0634\u0643\u0644\u0629",
- placeholder: "\u0635\u0641 \u0627\u0644\u0645\u0634\u0643\u0644\u0629...",
- submit: "\u0625\u0631\u0633\u0627\u0644",
- submitting: "\u062c\u0627\u0631\u064a \u0627\u0644\u0625\u0631\u0633\u0627\u0644...",
- success: "\u062a\u0645 \u0627\u0644\u0625\u0631\u0633\u0627\u0644. \u0634\u0643\u0631\u0627\u064b \u0644\u0643!",
- error: "\u062d\u062f\u062b \u062e\u0637\u0623. \u062d\u0627\u0648\u0644 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.",
- },
-} as const
-
-interface ReportIssueProps {
- variant?: "text" | "icon"
-}
-
-function parseBrowser(ua: string): string {
- if (ua.includes("Firefox/")) return `Firefox / ${getOS(ua)}`
- if (ua.includes("Edg/")) return `Edge / ${getOS(ua)}`
- if (ua.includes("Chrome/")) return `Chrome / ${getOS(ua)}`
- if (ua.includes("Safari/")) return `Safari / ${getOS(ua)}`
- return ua.slice(0, 50)
-}
-
-function getOS(ua: string): string {
- if (ua.includes("Mac OS")) return "macOS"
- if (ua.includes("Windows")) return "Windows"
- if (ua.includes("Android")) return "Android"
- if (ua.includes("iPhone") || ua.includes("iPad")) return "iOS"
- if (ua.includes("Linux")) return "Linux"
- return "Unknown"
-}
-
-export function ReportIssue({ variant = "text" }: ReportIssueProps) {
- const [open, setOpen] = useState(false)
- const [description, setDescription] = useState("")
- const [status, setStatus] = useState<
- "idle" | "loading" | "success" | "error"
- >("idle")
- const pathname = usePathname()
- const t = translations[pathname?.startsWith("/ar") ? "ar" : "en"]
-
- async function handleSubmit() {
- if (!description.trim()) return
- setStatus("loading")
- try {
- await reportIssue({
- description,
- pageUrl: window.location.href,
- viewport: `${window.innerWidth}x${window.innerHeight}`,
- direction: document.documentElement.dir || "ltr",
- browser: parseBrowser(navigator.userAgent),
- })
- setStatus("success")
- setDescription("")
- setTimeout(() => {
- setOpen(false)
- setStatus("idle")
- }, 1500)
- } catch {
- setStatus("error")
- }
- }
-
- return (
- {
- setOpen(v)
- if (!v) setStatus("idle")
- }}
- >
-
- {variant === "icon" ? (
-
-
-
- ) : (
-
- {t.link}
-
- )}
-
-
-
- {t.title}
-
-
-
- )
-}
diff --git a/src/components/report-issue/dialog.tsx b/src/components/report-issue/dialog.tsx
new file mode 100644
index 0000000..73359e3
--- /dev/null
+++ b/src/components/report-issue/dialog.tsx
@@ -0,0 +1,401 @@
+"use client";
+
+/**
+ * Canonical Report Issue dialog.
+ *
+ * Designed to work across hogwarts, mkan, kun without requiring any shadcn
+ * primitive beyond Button + Dialog (the universal pair). Select / textarea /
+ * collapsible are native HTML, styled to match shadcn-rendered inputs.
+ *
+ * Symmetric success: every accepted submission shows the same success toast
+ * regardless of which bucket it landed in. Only verified-bucket results
+ * surface the issue number (when the server action chooses to return it).
+ *
+ * Anti-abuse client-side mirror:
+ * - Description must be ≥30 chars and ≤2000 (HF1/HF2).
+ * - 60s cooldown after submit (HF9 — prevents the triple-click case).
+ * - Turnstile widget required when no session (HF3).
+ *
+ * Two render variants ("text" and "icon") preserved for parity with the existing
+ * hogwarts ReportIssue, which uses the icon variant inside the configuration
+ * wizard footer.
+ */
+
+import { Bug } from "lucide-react";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+import { REPORT_CATEGORY_LABELS, REPORT_DICTIONARY, type ReportLang } from "./dictionary";
+
+const REPORT_CATEGORIES = [
+ "visual",
+ "broken",
+ "data",
+ "slow",
+ "confusing",
+ "auth",
+ "i18n",
+ "other",
+] as const;
+
+const SEVERITIES = ["low", "medium", "high", "critical"] as const;
+
+const MIN_DESCRIPTION = 30;
+const MAX_DESCRIPTION = 2000;
+const COOLDOWN_MS = 60_000;
+
+export interface ReportIssueSubmitInput {
+ description: string;
+ pageUrl: string;
+ category: (typeof REPORT_CATEGORIES)[number];
+ reproSteps?: string;
+ expected?: string;
+ actual?: string;
+ severityHint?: (typeof SEVERITIES)[number];
+ viewport: string;
+ direction: "ltr" | "rtl";
+ browser: string;
+ hasScreenshot: false;
+ captchaToken?: string;
+}
+
+export interface ReportIssueSubmitResult {
+ ok: boolean;
+ issueNumber?: number;
+}
+
+export interface ReportIssueDialogProps {
+ /** "text" = underlined link, "icon" = bug icon button. Default "text". */
+ variant?: "text" | "icon";
+ /** Active language. Default detected from `` attr or "en". */
+ lang?: ReportLang;
+ /** True when the visitor is signed in. Controls captcha visibility. */
+ hasSession: boolean;
+ /** Server action invoked on submit. Should call runReportPipeline. */
+ onSubmit: (input: ReportIssueSubmitInput) => Promise;
+ /** Turnstile site key. When absent the captcha block is hidden. */
+ turnstileSiteKey?: string | undefined;
+ /** Sign-in link href used when prompting anonymous users. */
+ signInHref?: string;
+}
+
+const inputClass =
+ "border-input placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border bg-transparent px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50";
+
+export function ReportIssueDialog({
+ variant = "text",
+ lang,
+ hasSession,
+ onSubmit,
+ turnstileSiteKey,
+ signInHref = "/login",
+}: ReportIssueDialogProps): React.JSX.Element {
+ const effectiveLang = lang ?? detectLang();
+ const t = REPORT_DICTIONARY[effectiveLang];
+ const cats = REPORT_CATEGORY_LABELS[effectiveLang];
+
+ const [open, setOpen] = React.useState(false);
+ const [category, setCategory] = React.useState<(typeof REPORT_CATEGORIES)[number]>("other");
+ const [description, setDescription] = React.useState("");
+ const [showDetails, setShowDetails] = React.useState(false);
+ const [reproSteps, setReproSteps] = React.useState("");
+ const [expected, setExpected] = React.useState("");
+ const [actual, setActual] = React.useState("");
+ const [severity, setSeverity] = React.useState<(typeof SEVERITIES)[number] | "">("");
+ const [captchaToken, setCaptchaToken] = React.useState(null);
+ const [status, setStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
+ const [issueNumber, setIssueNumber] = React.useState(undefined);
+ const [lastSubmitAt, setLastSubmitAt] = React.useState(null);
+
+ const cooldownActive = lastSubmitAt !== null && Date.now() - lastSubmitAt < COOLDOWN_MS;
+ const needsCaptcha = !hasSession && Boolean(turnstileSiteKey);
+ const charCount = description.trim().length;
+ const minMet = charCount >= MIN_DESCRIPTION;
+
+ async function handleSubmit() {
+ if (!minMet || cooldownActive) return;
+ if (needsCaptcha && !captchaToken) return;
+ setStatus("loading");
+
+ const payload: ReportIssueSubmitInput = {
+ description,
+ pageUrl: typeof window !== "undefined" ? window.location.href : "",
+ category,
+ reproSteps: reproSteps.trim() || undefined,
+ expected: expected.trim() || undefined,
+ actual: actual.trim() || undefined,
+ severityHint: severity || undefined,
+ viewport:
+ typeof window !== "undefined"
+ ? `${window.innerWidth}x${window.innerHeight}`
+ : "0x0",
+ direction:
+ typeof document !== "undefined" && document.documentElement.dir === "rtl"
+ ? "rtl"
+ : "ltr",
+ browser: typeof navigator !== "undefined" ? navigator.userAgent : "",
+ hasScreenshot: false,
+ captchaToken: captchaToken ?? undefined,
+ };
+
+ try {
+ const res = await onSubmit(payload);
+ if (res.ok) {
+ setStatus("success");
+ setIssueNumber(res.issueNumber);
+ setLastSubmitAt(Date.now());
+ setDescription("");
+ setReproSteps("");
+ setExpected("");
+ setActual("");
+ setCategory("other");
+ setSeverity("");
+ setCaptchaToken(null);
+ setShowDetails(false);
+ setTimeout(() => {
+ setOpen(false);
+ setStatus("idle");
+ setIssueNumber(undefined);
+ }, 1500);
+ } else {
+ setStatus("error");
+ }
+ } catch {
+ setStatus("error");
+ }
+ }
+
+ const successMessage = issueNumber
+ ? t.successWithId.replace("{id}", String(issueNumber))
+ : t.success;
+
+ return (
+ <>
+ setOpen(true)}
+ />
+
+ {
+ setOpen(v);
+ if (!v) {
+ setStatus("idle");
+ setIssueNumber(undefined);
+ }
+ }}
+ >
+
+
+ {t.title}
+
+
+ setCategory(e.target.value as (typeof REPORT_CATEGORIES)[number])}
+ aria-label={t.categoryPlaceholder}
+ >
+ {REPORT_CATEGORIES.map((key) => (
+
+ {cats[key]}
+
+ ))}
+
+
+
+
+ >
+ );
+}
+
+// ─── internals ─────────────────────────────────────────────────────────────
+
+function TriggerButton({
+ variant,
+ label,
+ ariaLabel,
+ onClick,
+}: {
+ variant: "text" | "icon";
+ label: string;
+ ariaLabel: string;
+ onClick: () => void;
+}) {
+ if (variant === "icon") {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {label}
+
+ );
+}
+
+interface TurnstileSlotProps {
+ siteKey: string;
+ onSuccess: (token: string) => void;
+ hint: string;
+ linkText: string;
+ linkHref: string;
+}
+
+/**
+ * Turnstile widget mounts inside this slot. The @marsidev/react-turnstile
+ * package is lazy-imported so the marketing footer's bundle stays slim for
+ * visitors who never open the dialog.
+ */
+function TurnstileSlot({
+ siteKey,
+ onSuccess,
+ hint,
+ linkText,
+ linkHref,
+}: TurnstileSlotProps) {
+ const Turnstile = React.useMemo(
+ () =>
+ React.lazy(async () => {
+ const mod = await import("@marsidev/react-turnstile");
+ return { default: mod.Turnstile };
+ }),
+ []
+ );
+ return (
+
+ );
+}
+
+function detectLang(): ReportLang {
+ if (typeof document === "undefined") return "en";
+ const htmlLang = document.documentElement.lang?.toLowerCase();
+ return htmlLang?.startsWith("ar") ? "ar" : "en";
+}
diff --git a/src/components/report-issue/dictionary.ts b/src/components/report-issue/dictionary.ts
new file mode 100644
index 0000000..7d676de
--- /dev/null
+++ b/src/components/report-issue/dictionary.ts
@@ -0,0 +1,86 @@
+/**
+ * Bilingual strings for the canonical Report Issue dialog.
+ * Repos can override per-language by extending this map.
+ */
+
+export type ReportLang = "en" | "ar";
+
+export const REPORT_CATEGORY_LABELS = {
+ en: {
+ visual: "Visual / Layout",
+ broken: "Broken / Not Working",
+ data: "Wrong Data",
+ slow: "Slow / Performance",
+ confusing: "Confusing / UX",
+ auth: "Sign in / Permissions",
+ i18n: "Translation / Language",
+ other: "Other",
+ },
+ ar: {
+ visual: "مظهر / تخطيط",
+ broken: "معطل / لا يعمل",
+ data: "بيانات خاطئة",
+ slow: "بطيء / أداء",
+ confusing: "مربك / تجربة المستخدم",
+ auth: "تسجيل الدخول / الصلاحيات",
+ i18n: "ترجمة / لغة",
+ other: "أخرى",
+ },
+} as const;
+
+export const REPORT_DICTIONARY = {
+ en: {
+ triggerText: "Report an issue",
+ triggerAriaLabel: "Report an issue",
+ title: "Report an issue",
+ categoryPlaceholder: "Category",
+ descriptionPlaceholder: "Describe the issue in detail (minimum 30 characters)…",
+ descriptionHint: "{count}/30+ chars",
+ addDetails: "Add steps and expected behavior (optional)",
+ reproPlaceholder: "Steps to reproduce: 1. … 2. … 3. …",
+ expectedPlaceholder: "What did you expect to happen?",
+ actualPlaceholder: "What actually happened?",
+ severityLabel: "Severity",
+ severityLow: "Low — cosmetic",
+ severityMedium: "Medium — noticeable",
+ severityHigh: "High — blocks me",
+ severityCritical: "Critical — data loss / outage",
+ captchaHint: "Reports from signed-in users are processed faster.",
+ captchaLink: "Sign in",
+ submit: "Submit",
+ submitting: "Submitting…",
+ success: "Submitted. Thank you!",
+ successWithId: "Submitted. Tracked as #{id}.",
+ error: "Something went wrong. Try again.",
+ cooldown: "Please wait a moment before submitting another report.",
+ severityCritical_hint: "Reports flagged critical are escalated immediately.",
+ },
+ ar: {
+ triggerText: "الإبلاغ عن مشكلة",
+ triggerAriaLabel: "الإبلاغ عن مشكلة",
+ title: "الإبلاغ عن مشكلة",
+ categoryPlaceholder: "التصنيف",
+ descriptionPlaceholder: "صف المشكلة بالتفصيل (30 حرفاً على الأقل)…",
+ descriptionHint: "{count}/30+ حرف",
+ addDetails: "أضف الخطوات والسلوك المتوقع (اختياري)",
+ reproPlaceholder: "خطوات إعادة الإنتاج: 1. … 2. … 3. …",
+ expectedPlaceholder: "ما الذي توقعت حدوثه؟",
+ actualPlaceholder: "ما الذي حدث فعلياً؟",
+ severityLabel: "الخطورة",
+ severityLow: "منخفضة — مظهر فقط",
+ severityMedium: "متوسطة — ملحوظة",
+ severityHigh: "عالية — تعيقني",
+ severityCritical: "حرجة — فقدان بيانات / تعطل",
+ captchaHint: "البلاغات من المستخدمين المسجلين تُعالج أسرع.",
+ captchaLink: "تسجيل الدخول",
+ submit: "إرسال",
+ submitting: "جاري الإرسال…",
+ success: "تم الإرسال. شكراً لك!",
+ successWithId: "تم الإرسال. رقم البلاغ #{id}.",
+ error: "حدث خطأ. حاول مرة أخرى.",
+ cooldown: "يرجى الانتظار لحظة قبل إرسال بلاغ آخر.",
+ severityCritical_hint: "البلاغات الحرجة تُصعّد فوراً.",
+ },
+} as const;
+
+export type ReportDict = (typeof REPORT_DICTIONARY)[ReportLang];
diff --git a/src/components/report-issue/index.tsx b/src/components/report-issue/index.tsx
new file mode 100644
index 0000000..e5f32ba
--- /dev/null
+++ b/src/components/report-issue/index.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+/**
+ * Client wrapper. Resolves the session via `useSession()` instead of `auth()`
+ * so this module is safe to import from client components (e.g. site-footer).
+ * Importing the server `auth()` here transitively pulled `@/lib/db` and the
+ * `pg` driver into the client bundle, which broke the Vercel build because
+ * `pg/lib/connection-parameters.js` requires Node's `dns` module.
+ */
+
+import { useSession } from "next-auth/react";
+
+import { reportIssue } from "@/lib/actions/report-issue";
+import { ReportIssueDialog } from "./dialog";
+
+export interface ReportIssueProps {
+ variant?: "text" | "icon";
+}
+
+export function ReportIssue({ variant }: ReportIssueProps = {}) {
+ const { data: session } = useSession();
+ const hasSession = Boolean(session?.user);
+
+ return (
+
+ );
+}
+
+export type {
+ ReportIssueDialogProps,
+ ReportIssueSubmitInput,
+ ReportIssueSubmitResult,
+} from "./dialog";
diff --git a/src/components/template/search/big-search-date-picker.tsx b/src/components/template/search/big-search-date-picker.tsx
index a715edb..3e51a0a 100644
--- a/src/components/template/search/big-search-date-picker.tsx
+++ b/src/components/template/search/big-search-date-picker.tsx
@@ -6,34 +6,81 @@ import { type DateRange } from "react-day-picker"
import { Calendar } from "@/components/ui/calendar"
import { useLocale } from "@/components/internationalization/use-locale"
+type ActiveDateField = "checkin" | "checkout"
+
interface BigSearchDatePickerProps {
dateRange: {
from: Date | undefined
to: Date | undefined
}
+ /** Which endpoint the user is currently editing. */
+ activeField: ActiveDateField
+ /** Called with the *full* new range after applying field-aware logic. */
onDateChange: (from: Date | undefined, to: Date | undefined) => void
+ /**
+ * Called when the picker has produced a value that warrants moving focus
+ * (e.g. user picked check-in → auto-advance to check-out, or both set →
+ * close). Parent decides what to do with focus.
+ */
+ onAdvance?: (next: ActiveDateField | null) => void
}
export default function BigSearchDatePicker({
dateRange,
+ activeField,
onDateChange,
+ onAdvance,
}: BigSearchDatePickerProps) {
const { locale } = useLocale()
- const handleDateSelect = (range: DateRange | undefined) => {
- if (range) {
- onDateChange(range.from, range.to)
+ /**
+ * Field-aware select handler — mirrors VerticalSearch.
+ * See vertical-search.tsx for the rationale; in short, we override
+ * react-day-picker's range heuristic so the user's clicked field decides
+ * which endpoint moves.
+ */
+ const handleSelect = (
+ _range: DateRange | undefined,
+ triggerDate: Date | undefined,
+ ) => {
+ if (!triggerDate) return
+ const clicked = triggerDate
+
+ if (activeField === "checkout") {
+ if (!dateRange.from) {
+ onDateChange(clicked, undefined)
+ return
+ }
+ if (clicked.getTime() <= dateRange.from.getTime()) {
+ onDateChange(clicked, dateRange.from)
+ } else {
+ onDateChange(dateRange.from, clicked)
+ }
+ onAdvance?.(null)
+ return
+ }
+
+ // activeField === "checkin"
+ if (dateRange.to && clicked.getTime() >= dateRange.to.getTime()) {
+ onDateChange(clicked, undefined)
+ onAdvance?.("checkout")
+ } else {
+ onDateChange(clicked, dateRange.to)
+ onAdvance?.(dateRange.to ? null : "checkout")
}
}
return (
+ {/* Calendar uses the shadcn radix-nova mirror in
+ `src/components/ui/calendar.tsx` — 28px cells with a muted
+ range-middle bridge. No size override needed here. */}
diff --git a/src/components/template/search/big-search.tsx b/src/components/template/search/big-search.tsx
index 2b78ae9..9520e6f 100644
--- a/src/components/template/search/big-search.tsx
+++ b/src/components/template/search/big-search.tsx
@@ -97,13 +97,16 @@ export default function BigSearch({ onClose, isActive = true }: BigSearchProps =
}
};
- // Handle date range change
+ // Handle date range change — the picker now does field-aware resolution
+ // internally, so we just trust the (from, to) it gives us and update state.
+ // Focus advancement is handled separately via `onAdvance` so we can decide
+ // which button to focus next (checkin → checkout → close).
const handleDateChange = (from: Date | undefined, to: Date | undefined) => {
setDateRange({ from, to });
- // Close the dropdown when both dates are selected
- if (from && to) {
- setActiveButton(null);
- }
+ };
+
+ const handleDateAdvance = (next: "checkin" | "checkout" | null) => {
+ setActiveButton(next);
};
// Format date for display
@@ -506,7 +509,9 @@ export default function BigSearch({ onClose, isActive = true }: BigSearchProps =
>
)}
@@ -521,7 +526,9 @@ export default function BigSearch({ onClose, isActive = true }: BigSearchProps =
>
)}
diff --git a/src/components/template/search/guest-selector.tsx b/src/components/template/search/guest-selector.tsx
index 1e5b99d..746c79a 100644
--- a/src/components/template/search/guest-selector.tsx
+++ b/src/components/template/search/guest-selector.tsx
@@ -1,73 +1,108 @@
-"use client"
+"use client";
-import { Counter } from "@/components/atom/counter"
-import { GUEST_LIMITS } from "./constant"
-import { useDictionary } from "@/components/internationalization/dictionary-context"
+import { Counter } from "@/components/atom/counter";
+import { GUEST_LIMITS } from "./constant";
+import { useDictionary } from "@/components/internationalization/dictionary-context";
interface GuestSelectorProps {
guests: {
- adults: number
- children: number
- infants: number
- }
- onGuestChange: (type: 'adults' | 'children' | 'infants', operation: 'increment' | 'decrement') => void
+ adults: number;
+ children: number;
+ infants: number;
+ };
+ onGuestChange: (
+ type: "adults" | "children" | "infants",
+ operation: "increment" | "decrement"
+ ) => void;
}
export default function GuestSelectorDropdown({
guests,
- onGuestChange
+ onGuestChange,
}: GuestSelectorProps) {
- const dict = useDictionary()
+ const dict = useDictionary();
+
+ // Children and infants need at least one adult — disable their + when
+ // adults is zero. This matches Airbnb's host-side capacity rules and stops
+ // users from sending "0 adults + 2 children" filters that the listings
+ // search will silently treat as "0 guests."
+ const requiresAdult = guests.adults === 0;
return (
- <>
- {dict.search?.whosComing ?? "Who's coming?"}
+
+
+ {dict.search?.whosComing ?? "Who's coming?"}
+
-
-
-
{dict.search?.adultsLabel ?? "Adults"}
-
{dict.search?.adultsAge ?? "Ages 13 or above"}
+
+
+
+ {dict.search?.adultsLabel ?? "Adults"}
+
+
+ {dict.search?.adultsAge ?? "Ages 13 or above"}
+
onGuestChange('adults', 'increment')}
- onDecrement={() => onGuestChange('adults', 'decrement')}
+ onIncrement={() => onGuestChange("adults", "increment")}
+ onDecrement={() => onGuestChange("adults", "decrement")}
min={GUEST_LIMITS.adults.min}
max={GUEST_LIMITS.adults.max}
sm={true}
/>
-
-
-
-
{dict.search?.childrenLabel ?? "Children"}
-
{dict.search?.childrenAge ?? "Ages 2-12"}
+
+
+
+
+ {dict.search?.childrenLabel ?? "Children"}
+
+
+ {dict.search?.childrenAge ?? "Ages 2-12"}
+
onGuestChange('children', 'increment')}
- onDecrement={() => onGuestChange('children', 'decrement')}
+ onIncrement={() => onGuestChange("children", "increment")}
+ onDecrement={() => onGuestChange("children", "decrement")}
min={GUEST_LIMITS.children.min}
- max={GUEST_LIMITS.children.max}
+ // Block + on children if no adult is set yet — Counter renders a
+ // disabled state when value >= max.
+ max={requiresAdult ? 0 : GUEST_LIMITS.children.max}
sm={true}
/>
-
-
-
-
{dict.search?.infantsLabel ?? "Infants"}
-
{dict.search?.infantsAge ?? "Under 2"}
+
+
+
+
+ {dict.search?.infantsLabel ?? "Infants"}
+
+
+ {dict.search?.infantsAge ?? "Under 2"}
+
onGuestChange('infants', 'increment')}
- onDecrement={() => onGuestChange('infants', 'decrement')}
+ onIncrement={() => onGuestChange("infants", "increment")}
+ onDecrement={() => onGuestChange("infants", "decrement")}
min={GUEST_LIMITS.infants.min}
- max={GUEST_LIMITS.infants.max}
+ max={requiresAdult ? 0 : GUEST_LIMITS.infants.max}
sm={true}
/>
- >
- )
-}
\ No newline at end of file
+
+ {requiresAdult && (guests.children > 0 || guests.infants > 0) && (
+
+ {/* `needAdult` is a freshly-added string; widen the lookup so the
+ dictionary type doesn't need a coordinated change to en.json
+ and ar.json in this PR. Strings still fall back gracefully. */}
+ {(dict.search as unknown as Record)
+ ?.needAdult ?? "At least one adult is required."}
+
+ )}
+
+ );
+}
diff --git a/src/components/template/search/location.tsx b/src/components/template/search/location.tsx
index a90a132..524ce57 100644
--- a/src/components/template/search/location.tsx
+++ b/src/components/template/search/location.tsx
@@ -1,7 +1,8 @@
"use client";
import { Input } from "@/components/ui/input";
-import { Loader2, MapPin } from "lucide-react";
+import { Loader2, MapPin, X } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type LocationSuggestion } from "@/lib/schemas/search-schema";
import { FALLBACK_RECOMMENDATIONS } from "./constant";
import { useDictionary } from "@/components/internationalization/dictionary-context";
@@ -26,12 +27,20 @@ export default function LocationDropdown({
onLocationSelect,
}: LocationProps) {
const dict = useDictionary();
+ const inputRef = useRef
(null);
+ // Tracks which suggestion is highlighted by keyboard. -1 = none.
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const optionsRef = useRef>([]);
- const displayLocations = searchQuery.trim()
- ? suggestions
- : popularLocations.length > 0
- ? popularLocations
- : FALLBACK_RECOMMENDATIONS;
+ const displayLocations = useMemo(
+ () =>
+ searchQuery.trim()
+ ? suggestions
+ : popularLocations.length > 0
+ ? popularLocations
+ : ([...FALLBACK_RECOMMENDATIONS] as LocationSuggestion[]),
+ [searchQuery, suggestions, popularLocations]
+ );
const title = searchQuery.trim()
? (dict.search?.searchResults ?? "Search results")
@@ -39,7 +48,46 @@ export default function LocationDropdown({
? (dict.search?.popularDestinations ?? "Popular destinations")
: (dict.search?.recommendedDestinations ?? "Recommended destinations");
- const handleKeyDown = (
+ // Reset highlight when the option list changes
+ useEffect(() => {
+ setActiveIndex(-1);
+ }, [displayLocations]);
+
+ const scrollOptionIntoView = useCallback((idx: number) => {
+ const node = optionsRef.current[idx];
+ if (node) {
+ node.scrollIntoView({ block: "nearest" });
+ }
+ }, []);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (displayLocations.length === 0) return;
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActiveIndex((prev) => {
+ const next = prev < displayLocations.length - 1 ? prev + 1 : 0;
+ scrollOptionIntoView(next);
+ return next;
+ });
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIndex((prev) => {
+ const next = prev > 0 ? prev - 1 : displayLocations.length - 1;
+ scrollOptionIntoView(next);
+ return next;
+ });
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ // Default to the first result if user just pressed Enter without
+ // arrowing; otherwise pick the highlighted item.
+ const idx = activeIndex >= 0 ? activeIndex : 0;
+ const target = displayLocations[idx];
+ if (target) onLocationSelect(target);
+ }
+ };
+
+ const handleOptionKeyDown = (
e: React.KeyboardEvent,
location: LocationSuggestion
) => {
@@ -49,25 +97,54 @@ export default function LocationDropdown({
}
};
+ const clearInput = () => {
+ onSearchQueryChange("");
+ inputRef.current?.focus();
+ };
+
return (
-
-
{dict.search?.whereTo ?? "Where to?"}
+
= 0 ? `location-option-${activeIndex}` : undefined
+ }
+ >
+
+ {dict.search?.whereTo ?? "Where to?"}
+
{/* Search input */}
onSearchQueryChange(e.target.value)}
+ onKeyDown={handleKeyDown}
className="w-full h-10 border-0 border-none rounded-lg focus:outline-none focus:border-0 shadow-none text-black caret-black pe-10"
autoFocus
aria-label={dict.search?.searchLocation ?? "Search for a location"}
aria-autocomplete="list"
aria-controls="location-listbox"
/>
- {isLoading && (
-
- )}
+ {isLoading ? (
+
+ ) : searchQuery ? (
+ )
+ ?.clear ?? "Clear"
+ }
+ >
+
+
+ ) : null}
{/* Error message */}
@@ -92,30 +169,40 @@ export default function LocationDropdown({
{title}
- {displayLocations.map((location, index) => (
-
onLocationSelect(location)}
- role="option"
- aria-selected="false"
- tabIndex={0}
- onKeyDown={(e) => handleKeyDown(e, location)}
- >
-
-
-
-
-
- {location.city}
+ {displayLocations.map((location, index) => {
+ const isActive = index === activeIndex;
+ return (
+
{
+ optionsRef.current[index] = el;
+ }}
+ className={`py-1 px-2 -mx-2 rounded-lg cursor-pointer flex items-center gap-3 transition-colors ${
+ isActive ? "bg-gray-100" : "hover:bg-gray-50"
+ }`}
+ onClick={() => onLocationSelect(location)}
+ onMouseEnter={() => setActiveIndex(index)}
+ role="option"
+ aria-selected={isActive}
+ tabIndex={0}
+ onKeyDown={(e) => handleOptionKeyDown(e, location)}
+ >
+
+
+
+
-
- ))}
+ );
+ })}
>
) : searchQuery && !isLoading ? (
- {(dict.search?.noDestinationsFound ?? "No destinations found for \"{query}\"").replace("{query}", searchQuery)}
+ {(dict.search?.noDestinationsFound ??
+ 'No destinations found for "{query}"'
+ ).replace("{query}", searchQuery)}
) : null}
diff --git a/src/components/template/search/vertical-search.tsx b/src/components/template/search/vertical-search.tsx
index de34428..2ca172f 100644
--- a/src/components/template/search/vertical-search.tsx
+++ b/src/components/template/search/vertical-search.tsx
@@ -1,11 +1,20 @@
"use client";
import { Button } from "@/components/ui/button";
-import { useState, useEffect, useRef } from "react";
+import {
+ useState,
+ useEffect,
+ useRef,
+ useCallback,
+ useMemo,
+ useTransition,
+} from "react";
import { useRouter, usePathname } from "next/navigation";
import { Label } from "@/components/ui/label";
-import { Counter } from "@/components/atom/counter";
import { format, addDays } from "date-fns";
+import { ar, enUS } from "date-fns/locale";
+import { AnimatePresence, motion } from "framer-motion";
+import { X } from "lucide-react";
import { useClickOutside } from "./use-click";
import { GUEST_LIMITS, MOBILE_BREAKPOINT } from "./constant";
import LocationDropdown from "./location";
@@ -30,6 +39,8 @@ const searchTranslations = {
addGuests: "Add guests",
back: "Back",
search: "Search",
+ clear: "Clear",
+ clearAll: "Clear all",
adult: "adult",
adults: "adults",
child: "child",
@@ -38,6 +49,8 @@ const searchTranslations = {
infants: "infants",
selectCheckIn: "Select check-in date",
selectCheckOut: "Select check-out date",
+ nights: "nights",
+ night: "night",
},
ar: {
heading: "احجز أماكن\nإقامة وأنشطة\nفريدة.",
@@ -50,6 +63,8 @@ const searchTranslations = {
addGuests: "أضف ضيوف",
back: "رجوع",
search: "بحث",
+ clear: "مسح",
+ clearAll: "مسح الكل",
adult: "بالغ",
adults: "بالغين",
child: "طفل",
@@ -58,6 +73,8 @@ const searchTranslations = {
infants: "رضع",
selectCheckIn: "اختر تاريخ الوصول",
selectCheckOut: "اختر تاريخ المغادرة",
+ nights: "ليالٍ",
+ night: "ليلة",
},
} as const;
@@ -67,19 +84,34 @@ interface VerticalSearchProps {
onSearch?: () => void;
}
+// Dropdown enter/exit motion. Short, slightly easing curve — feels responsive
+// without drawing attention to itself when the user is rapidly tabbing through
+// fields ("flipping").
+const DROPDOWN_TRANSITION = { duration: 0.16, ease: [0.22, 1, 0.36, 1] as const };
+const dropdownMotion = {
+ initial: { opacity: 0, y: -4, scale: 0.98 },
+ animate: { opacity: 1, y: 0, scale: 1 },
+ exit: { opacity: 0, y: -4, scale: 0.98 },
+};
+
export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
const router = useRouter();
const pathname = usePathname();
const { locale, isRTL } = useLocale();
- const t = searchTranslations[locale as 'en' | 'ar'] || searchTranslations.en;
+ const t = searchTranslations[locale as "en" | "ar"] || searchTranslations.en;
const [activeField, setActiveField] = useState
(null);
const [isMobile, setIsMobile] = useState(false);
+ // Tracks whether the viewport is wide enough to show a 2-month calendar
+ // side-by-side next to the form (lg+, 1024px+). Below this we fall back to
+ // a 1-month calendar so the side dropdown doesn't overflow the viewport.
+ const [isWide, setIsWide] = useState(false);
+ // useTransition gives us a proper pending flag during navigation — the
+ // button stays in its loading state until the listings route is mounted.
+ const [isPending, startTransition] = useTransition();
const formRef = useRef(null);
const [formData, setFormData] = useState({
location: "",
- checkIn: "",
- checkOut: "",
guests: {
adults: 0,
children: 0,
@@ -99,15 +131,6 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
to: undefined,
});
- // Track previous date range for detecting changes
- const [prevDateRange, setPrevDateRange] = useState<{
- from: Date | undefined;
- to: Date | undefined;
- }>({
- from: undefined,
- to: undefined,
- });
-
// Use the location suggestions hook
const {
suggestions,
@@ -119,18 +142,24 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
} = useLocationSuggestions();
// Use the search validation hook
- const { isValid: isDateValid, errors: dateErrors } =
+ const { isValid: isDateValid, errors: dateErrors, nights } =
useSearchValidation(dateRange);
- // Check if mobile on mount and resize
+ // Check mobile + wide breakpoints on mount and resize. We track both
+ // because the layout differs at three tiers:
+ // - <768px (mobile): inline dropdown inside the form
+ // - 768-1023 (tablet): side dropdown, 1-month calendar
+ // - 1024+ (desktop): side dropdown, 2-month calendar
useEffect(() => {
- const checkMobile = () => {
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+ const check = () => {
+ const w = window.innerWidth;
+ setIsMobile(w < MOBILE_BREAKPOINT);
+ setIsWide(w >= 1024);
};
- checkMobile();
- window.addEventListener("resize", checkMobile);
- return () => window.removeEventListener("resize", checkMobile);
+ check();
+ window.addEventListener("resize", check);
+ return () => window.removeEventListener("resize", check);
}, []);
// Track search form height dynamically for desktop dropdowns
@@ -161,38 +190,74 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
return undefined;
}, [isMobile, activeField]); // Re-measure when active field changes
+ // Close active dropdown on Escape
+ useEffect(() => {
+ if (!activeField) return undefined;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ setActiveField(null);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [activeField]);
+
const handleFieldClick = (field: ActiveField) => {
setActiveField(activeField === field ? null : field);
};
- const handleDateRangeChange = (
- from: Date | undefined,
- to: Date | undefined
- ) => {
- // Detect which date was just selected
- const fromChanged = from?.getTime() !== prevDateRange.from?.getTime();
- const toChanged = to?.getTime() !== prevDateRange.to?.getTime();
+ /**
+ * Field-aware date selection.
+ *
+ * react-day-picker's default `mode="range"` heuristic breaks down once the
+ * user has both endpoints set and wants to *change check-in* — the library
+ * either resets to a new single-day range or mutates check-out instead,
+ * leaving the user confused. We override that by routing every click through
+ * `activeField`: whichever endpoint the user is editing is the one we set,
+ * and the other endpoint adjusts only when the click would invalidate it.
+ *
+ * v9 of react-day-picker passes the clicked date as the 2nd arg of
+ * `onSelect` (`triggerDate`), so we can act on the literal click instead of
+ * diffing the resulting range.
+ */
+ const handleCalendarSelect = useCallback(
+ (_range: DateRange | undefined, triggerDate: Date | undefined) => {
+ if (!triggerDate) return;
+ const clicked = triggerDate;
- // Update state
- setDateRange({ from, to });
- setFormData((prev) => ({
- ...prev,
- checkIn: from ? format(from, "yyyy-MM-dd") : "",
- checkOut: to ? format(to, "yyyy-MM-dd") : "",
- }));
-
- // AUTO-ADVANCE & AUTO-CLOSE LOGIC:
- if (fromChanged && from && !to) {
- // User just selected check-in → auto-switch to check-out field
- setActiveField("checkout");
- } else if (toChanged && from && to) {
- // User just selected check-out → auto-close dropdown
- setActiveField(null);
- }
+ if (activeField === "checkout") {
+ // No check-in yet → treat click as check-in, keep focus on checkout
+ if (!dateRange.from) {
+ setDateRange({ from: clicked, to: undefined });
+ return;
+ }
+ // Click at-or-before existing check-in → swap so check-in remains the
+ // earlier date. The previous check-in becomes check-out.
+ if (clicked.getTime() <= dateRange.from.getTime()) {
+ setDateRange({ from: clicked, to: dateRange.from });
+ } else {
+ setDateRange({ from: dateRange.from, to: clicked });
+ }
+ setActiveField(null);
+ return;
+ }
- // Track for next comparison
- setPrevDateRange({ from, to });
- };
+ // Default: editing check-in (covers activeField === "checkin" and the
+ // first-ever click when no field is explicitly focused).
+ if (dateRange.to && clicked.getTime() >= dateRange.to.getTime()) {
+ // Picking a check-in at-or-after current check-out invalidates the
+ // check-out — clear it and ask user to pick a new one.
+ setDateRange({ from: clicked, to: undefined });
+ setActiveField("checkout");
+ } else {
+ setDateRange({ from: clicked, to: dateRange.to });
+ // Auto-advance only if check-out isn't already set, otherwise the
+ // user is just adjusting check-in and a close is the kinder exit.
+ setActiveField(dateRange.to ? null : "checkout");
+ }
+ },
+ [activeField, dateRange.from, dateRange.to]
+ );
// Handle location selection
const selectLocation = (location: LocationSuggestion) => {
@@ -200,6 +265,33 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
setActiveField("checkin");
};
+ const clearLocation = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setFormData((prev) => ({ ...prev, location: "" }));
+ };
+
+ const clearDates = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setDateRange({ from: undefined, to: undefined });
+ };
+
+ const clearGuests = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setFormData((prev) => ({
+ ...prev,
+ guests: { adults: 0, children: 0, infants: 0 },
+ }));
+ };
+
+ const clearAll = () => {
+ setFormData({
+ location: "",
+ guests: { adults: 0, children: 0, infants: 0 },
+ });
+ setDateRange({ from: undefined, to: undefined });
+ setActiveField(null);
+ };
+
// Add guest counter handlers
const handleGuestChange = (
type: "adults" | "children" | "infants",
@@ -217,29 +309,28 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
}));
};
- // Helper function to get total guests
- const getTotalGuests = () => {
- return (
+ // Helper function to get total guests (infants don't count toward room
+ // capacity per Airbnb convention)
+ const getTotalGuests = () =>
+ formData.guests.adults + formData.guests.children;
+
+ const totalIncludingInfants = useMemo(
+ () =>
formData.guests.adults +
formData.guests.children +
- formData.guests.infants
- );
- };
+ formData.guests.infants,
+ [formData.guests]
+ );
// Helper function to get guest display text
const getGuestDisplayText = () => {
- const total = getTotalGuests();
- if (total === 0) return t.addGuests;
+ if (totalIncludingInfants === 0) return t.addGuests;
- const parts = [];
- if (formData.guests.adults > 0) {
- parts.push(
- `${formData.guests.adults} ${formData.guests.adults > 1 ? t.adults : t.adult}`
- );
- }
- if (formData.guests.children > 0) {
+ const parts: string[] = [];
+ const guestCount = formData.guests.adults + formData.guests.children;
+ if (guestCount > 0) {
parts.push(
- `${formData.guests.children} ${formData.guests.children > 1 ? t.children : t.child}`
+ `${guestCount} ${guestCount > 1 ? t.adults : t.adult}`
);
}
if (formData.guests.infants > 0) {
@@ -251,6 +342,18 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
return parts.join(", ");
};
+ // Display text helpers
+ const checkInLabel = dateRange.from
+ ? format(dateRange.from, "MMM dd")
+ : t.addDate;
+ const checkOutLabel = dateRange.to
+ ? format(dateRange.to, "MMM dd")
+ : t.addDate;
+ const nightsLabel =
+ nights && nights > 0
+ ? `${nights} ${nights === 1 ? t.night : t.nights}`
+ : null;
+
// Use click outside hook
useClickOutside(formRef, () => setActiveField(null));
@@ -267,13 +370,13 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
if (formData.location) {
searchParams.set("location", formData.location);
}
- if (formData.checkIn) {
- searchParams.set("checkIn", formData.checkIn);
+ if (dateRange.from) {
+ searchParams.set("checkIn", format(dateRange.from, "yyyy-MM-dd"));
}
- if (formData.checkOut) {
- searchParams.set("checkOut", formData.checkOut);
+ if (dateRange.to) {
+ searchParams.set("checkOut", format(dateRange.to, "yyyy-MM-dd"));
}
- if (getTotalGuests() > 0) {
+ if (totalIncludingInfants > 0) {
searchParams.set("guests", getTotalGuests().toString());
searchParams.set("adults", formData.guests.adults.toString());
searchParams.set("children", formData.guests.children.toString());
@@ -282,16 +385,17 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
// Get current locale from pathname
const pathParts = pathname.split("/");
- const locale = pathParts[1] || "en";
+ const langSegment = pathParts[1] || "en";
// Always navigate to listings page
- const searchUrl = `/${locale}/listings${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
- router.push(searchUrl);
+ const searchUrl = `/${langSegment}/listings${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
- // Call onSearch callback if provided (for analytics or other side effects)
- if (onSearch) {
- onSearch();
- }
+ startTransition(() => {
+ router.push(searchUrl);
+ // Side-effect callback (analytics, scroll-to-results) — fires alongside
+ // the navigation so the host page can react before the route mounts.
+ if (onSearch) onSearch();
+ });
};
// Helper function to get field styling
@@ -301,7 +405,7 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
let styleClass = "bg-transparent";
if (isActive) {
- styleClass = "bg-white !border-gray-400";
+ styleClass = "bg-white !border-gray-400 shadow-[0_0_0_1px_rgba(0,0,0,0.04)]";
} else if (hasActiveField) {
styleClass = "bg-gray-50";
}
@@ -309,6 +413,65 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
return `${styleClass} transition-all duration-200`;
};
+ // Disabled-date predicate shared by every calendar instance below.
+ // Memoized so the calendar doesn't re-render on every keystroke.
+ const isDateDisabled = useCallback(
+ (date: Date) => {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ if (date < today) return true;
+ const maxDate = addDays(today, SEARCH_CONFIG.DEFAULT_MAX_NIGHTS);
+ if (date > maxDate) return true;
+ // When picking check-out, cap by max-nights from check-in
+ if (activeField === "checkout" && dateRange.from) {
+ const maxCheckout = addDays(
+ dateRange.from,
+ SEARCH_CONFIG.DEFAULT_MAX_NIGHTS
+ );
+ if (date > maxCheckout) return true;
+ }
+ return false;
+ },
+ [activeField, dateRange.from]
+ );
+
+ // Shared calendar render. Keeps the date logic in one place across the
+ // mobile/desktop instances.
+ //
+ // The Calendar now mirrors shadcn radix-nova: 28px cells via
+ // `--cell-size:--spacing(7)` and a muted range-middle band bridged by
+ // ::after pseudo-elements (see `src/components/ui/calendar.tsx`). The
+ // container only needs `w-fit` to let the calendar render at its
+ // natural shadcn proportions — no per-consumer width override.
+ const renderCalendar = (months: 1 | 2) => (
+
+ );
+
+ // Any field set? Used to show the "Clear all" link.
+ const hasAnyFieldSet =
+ formData.location ||
+ dateRange.from ||
+ dateRange.to ||
+ totalIncludingInfants > 0;
+
+ // Validation error to surface near the Search button
+ const dateValidationMessage =
+ dateErrors.checkIn ||
+ dateErrors.checkOut ||
+ dateErrors.dateRange ||
+ null;
+
+ // ============================================================
+ // MOBILE LAYOUT
+ // ============================================================
if (isMobile) {
return (
- {t.heading}
-
+
+
+ {t.heading}
+
+ {hasAnyFieldSet && (
+
+ {t.clearAll}
+
+ )}
+
) : (
/* Show back arrow when dropdown is active */
setActiveField(null)}
className="flex items-center text-[#6b6b6b] hover:text-black transition-colors"
+ aria-label={t.back}
>
handleFieldClick("location")}
+ aria-expanded={activeField === "location"}
>
{formData.location || t.anywhere}
+ {formData.location && (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ clearLocation(e as unknown as React.MouseEvent);
+ }
+ }}
+ >
+
+
+ )}
@@ -386,20 +579,42 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
handleFieldClick("checkin")}
+ aria-expanded={activeField === "checkin"}
>
- {dateRange.from ? format(dateRange.from, "MMM dd") : t.addDate}
+ {checkInLabel}
handleFieldClick("checkout")}
+ aria-expanded={activeField === "checkout"}
>
- {dateRange.to ? format(dateRange.to, "MMM dd") : t.addDate}
+ {checkOutLabel}
+ {(dateRange.from || dateRange.to) && (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ clearDates(e as unknown as React.MouseEvent);
+ }
+ }}
+ >
+
+
+ )}
+ {nightsLabel && (
+ {nightsLabel}
+ )}
{/* Guests field */}
@@ -408,127 +623,160 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
{t.guests}
handleFieldClick("guests")}
+ aria-expanded={activeField === "guests"}
>
0 ? "text-black" : "text-[#c0c0c0]"}`}
+ className={`text-sm truncate ${totalIncludingInfants > 0 ? "text-black" : "text-[#c0c0c0]"}`}
>
{getGuestDisplayText()}
+ {totalIncludingInfants > 0 && (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ clearGuests(e as unknown as React.MouseEvent);
+ }
+ }}
+ >
+
+
+ )}
) : (
/* Show dropdown content when a field is active */
- {activeField === "location" && (
-
{
- if (location) {
- selectLocation(location);
- } else {
- setActiveField(null);
- }
- }}
- />
- )}
-
- {(activeField === "checkin" || activeField === "checkout") && (
-
- {/* FIELD SWITCHER TABS - Mobile only */}
-
-
- setActiveField("checkin")}
- className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
- activeField === "checkin"
- ? "bg-gray-900 text-white"
- : "bg-gray-100 text-gray-700 hover:bg-gray-200"
- }`}
- >
- {t.checkIn}
-
- setActiveField("checkout")}
- disabled={!dateRange.from}
- className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
- activeField === "checkout"
- ? "bg-gray-900 text-white"
- : "bg-gray-100 text-gray-700 hover:bg-gray-200"
- } disabled:opacity-50 disabled:cursor-not-allowed`}
- >
- {t.checkOut}
-
-
-
- {/* Hint below tabs */}
-
- {activeField === "checkin" ? t.selectCheckIn : t.selectCheckOut}
-
-
-
-
-
{
- if (range) {
- handleDateRangeChange(range.from, range.to);
+
+ {activeField === "location" && (
+
+ {
+ if (location) {
+ selectLocation(location);
} else {
- handleDateRangeChange(undefined, undefined);
- }
- }}
- numberOfMonths={1}
- className="[--cell-size:2rem] p-0 text-sm"
- classNames={{
- months: "gap-0",
- month: "gap-1",
- nav: "gap-0.5",
- week: "mt-0",
- weekday: "text-[10px] font-normal",
- month_caption: "h-8",
- }}
- disabled={(date) => {
- const today = new Date();
- today.setHours(0, 0, 0, 0);
- if (date < today) return true;
- const maxDate = addDays(today, SEARCH_CONFIG.DEFAULT_MAX_NIGHTS);
- if (date > maxDate) return true;
- if (dateRange.from && !dateRange.to) {
- const maxCheckout = addDays(dateRange.from, SEARCH_CONFIG.DEFAULT_MAX_NIGHTS);
- if (date > maxCheckout) return true;
+ setActiveField(null);
}
- return false;
}}
/>
-
-
- )}
+
+ )}
- {activeField === "guests" && (
-
- )}
+ {(activeField === "checkin" || activeField === "checkout") && (
+
+ {/* FIELD SWITCHER TABS - Mobile only */}
+
+
+ setActiveField("checkin")}
+ className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
+ activeField === "checkin"
+ ? "bg-gray-900 text-white"
+ : "bg-gray-100 text-gray-700 hover:bg-gray-200"
+ }`}
+ >
+ {t.checkIn}
+ {dateRange.from && (
+
+ {format(dateRange.from, "MMM dd")}
+
+ )}
+
+ setActiveField("checkout")}
+ className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
+ activeField === "checkout"
+ ? "bg-gray-900 text-white"
+ : "bg-gray-100 text-gray-700 hover:bg-gray-200"
+ }`}
+ >
+ {t.checkOut}
+ {dateRange.to && (
+
+ {format(dateRange.to, "MMM dd")}
+
+ )}
+
+
+
+
+
+ {renderCalendar(1)}
+
+
+ {nightsLabel && (
+
+ {nightsLabel}
+
+ )}
+
+ )}
+
+ {activeField === "guests" && (
+
+
+
+ )}
+
)}
+ {/* Validation error */}
+ {dateValidationMessage && (
+
+ {dateValidationMessage}
+
+ )}
+
{/* Fixed Search button - always visible */}
- {t.search}
+ {isPending ? (
+
+ ) : (
+ t.search
+ )}
@@ -536,7 +784,9 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
);
}
- // Desktop version
+ // ============================================================
+ // DESKTOP LAYOUT
+ // ============================================================
return (
- {/* Main heading */}
-
- {t.heading}
-
+ {/* Header with optional Clear all */}
+
+
+ {t.heading}
+
+ {hasAnyFieldSet && (
+
+ {t.clearAll}
+
+ )}
+
{/* Desktop: All fields visible */}
@@ -560,14 +821,32 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
{t.where}
handleFieldClick("location")}
+ aria-expanded={activeField === "location"}
>
{formData.location || t.anywhere}
+ {formData.location && (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ clearLocation(e as unknown as React.MouseEvent);
+ }
+ }}
+ >
+
+
+ )}
@@ -589,20 +868,42 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
handleFieldClick("checkin")}
+ aria-expanded={activeField === "checkin"}
>
- {dateRange.from ? format(dateRange.from, "MMM dd") : t.addDate}
+ {checkInLabel}
handleFieldClick("checkout")}
+ aria-expanded={activeField === "checkout"}
>
- {dateRange.to ? format(dateRange.to, "MMM dd") : t.addDate}
+ {checkOutLabel}
+ {(dateRange.from || dateRange.to) && (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ clearDates(e as unknown as React.MouseEvent);
+ }
+ }}
+ >
+
+
+ )}
+ {nightsLabel && (
+
{nightsLabel}
+ )}
{/* Travelers field */}
@@ -611,20 +912,81 @@ export default function VerticalSearch({ onSearch }: VerticalSearchProps) {
{t.guests}
handleFieldClick("guests")}
+ aria-expanded={activeField === "guests"}
>
0 ? "text-black" : "text-[#c0c0c0]"}`}
+ className={`text-sm truncate ${totalIncludingInfants > 0 ? "text-black" : "text-[#c0c0c0]"}`}
>
{getGuestDisplayText()}
+ {totalIncludingInfants > 0 && (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ clearGuests(e as unknown as React.MouseEvent);
+ }
+ }}
+ >
+
+
+ )}
- {/* Mobile-only Inline Dropdowns - appear below fields when active */}
- {activeField === "location" && (
-
+ {/* Validation error */}
+ {dateValidationMessage && (
+
+ {dateValidationMessage}
+
+ )}
+
+ {/* Search button */}
+
+
+ {isPending ? (
+
+ ) : (
+ t.search
+ )}
+
+
+
+
+
+ {/* Desktop-only Side Dropdowns - Positioned beside form */}
+
+ {activeField === "location" && (
+ setActiveField(null)}
+ >
+
- )}
+
+ )}
- {(activeField === "checkin" || activeField === "checkout") && (
-
-
-
{
- if (range) {
- handleDateRangeChange(range.from, range.to);
- } else {
- handleDateRangeChange(undefined, undefined);
- }
- }}
- numberOfMonths={1}
- className="[--cell-size:2rem] p-0 text-sm"
- classNames={{
- months: "gap-0",
- month: "gap-1",
- nav: "gap-0.5",
- week: "mt-0",
- weekday: "text-[10px] font-normal",
- month_caption: "h-8",
- }}
- disabled={(date) => {
- const today = new Date();
- today.setHours(0, 0, 0, 0);
- if (date < today) return true;
- const maxDate = addDays(today, SEARCH_CONFIG.DEFAULT_MAX_NIGHTS);
- if (date > maxDate) return true;
- if (dateRange.from && !dateRange.to) {
- const maxCheckout = addDays(dateRange.from, SEARCH_CONFIG.DEFAULT_MAX_NIGHTS);
- if (date > maxCheckout) return true;
- }
- return false;
- }}
- />
+ {(activeField === "checkin" || activeField === "checkout") && (
+ setActiveField(null)}
+ >
+
+ {/* Field indicator — shows which endpoint is currently being
+ edited, mirrors the highlighted form field for clarity. */}
+
+ setActiveField("checkin")}
+ className={`flex-1 max-w-[160px] px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
+ activeField === "checkin"
+ ? "bg-gray-900 text-white"
+ : "bg-gray-100 text-gray-700 hover:bg-gray-200"
+ }`}
+ >
+ {t.checkIn}
+ {dateRange.from && (
+
+ {format(dateRange.from, "MMM dd")}
+
+ )}
+
+ setActiveField("checkout")}
+ className={`flex-1 max-w-[160px] px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
+ activeField === "checkout"
+ ? "bg-gray-900 text-white"
+ : "bg-gray-100 text-gray-700 hover:bg-gray-200"
+ }`}
+ >
+ {t.checkOut}
+ {dateRange.to && (
+
+ {format(dateRange.to, "MMM dd")}
+
+ )}
+
+ {renderCalendar(isWide ? 2 : 1)}
+ {nightsLabel && (
+
{nightsLabel}
+ )}
- )}
+
+ )}
- {activeField === "guests" && (
-
+ {activeField === "guests" && (
+
setActiveField(null)}
+ >
+
- )}
-
- {/* Search button */}
-
-
- {t.search}
-
-
-
-
-
- {/* Desktop-only Side Dropdowns - Positioned beside form */}
- {activeField === "location" && (
-
setActiveField(null)}
- >
-
- {
- if (location) {
- selectLocation(location);
- } else {
- setActiveField(null);
- }
- }}
- />
-
-
- )}
-
- {(activeField === "checkin" || activeField === "checkout") && (
-
setActiveField(null)}
- >
- {/* HINT TEXT */}
-
-
- {activeField === "checkin" ? t.selectCheckIn : t.selectCheckOut}
-
-
-
-
- {
- if (range) {
- handleDateRangeChange(range.from, range.to);
- } else {
- handleDateRangeChange(undefined, undefined);
- }
- }}
- numberOfMonths={2}
- className="[--cell-size:2rem] p-0 text-sm"
- classNames={{
- months: "gap-4",
- month: "gap-1",
- nav: "gap-0.5",
- week: "mt-0",
- weekday: "text-[10px] font-normal",
- month_caption: "h-8",
- }}
- disabled={(date) => {
- const today = new Date();
- today.setHours(0, 0, 0, 0);
- if (date < today) return true;
- const maxDate = addDays(today, SEARCH_CONFIG.DEFAULT_MAX_NIGHTS);
- if (date > maxDate) return true;
- if (dateRange.from && !dateRange.to) {
- const maxCheckout = addDays(dateRange.from, SEARCH_CONFIG.DEFAULT_MAX_NIGHTS);
- if (date > maxCheckout) return true;
- }
- return false;
- }}
- />
-
-
- )}
-
- {activeField === "guests" && (
-
setActiveField(null)}
- >
-
-
-
-
- )}
+
+ )}
+
);
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx
index 4ff60c7..dd57c09 100644
--- a/src/components/ui/calendar.tsx
+++ b/src/components/ui/calendar.tsx
@@ -10,6 +10,7 @@ import {
DayPicker,
getDefaultClassNames,
type DayButton,
+ type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
@@ -21,6 +22,7 @@ function Calendar({
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
+ locale,
formatters,
components,
...props
@@ -33,91 +35,103 @@ function Calendar({
svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
+ locale={locale}
formatters={{
formatMonthDropdown: (date) =>
- date.toLocaleString("default", { month: "short" }),
+ date.toLocaleString(locale?.code ?? "default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
- "flex gap-4 flex-col md:flex-row relative",
+ "relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
- month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
- "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
- "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
- "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
- "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
- "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
- "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+ "relative rounded-(--cell-radius) border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root
),
dropdown: cn(
- "absolute bg-popover inset-0 opacity-0",
+ "absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
- "select-none font-medium",
+ "font-medium select-none",
captionLayout === "label"
? "text-sm"
- : "rounded-md ps-2 pe-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+ : "flex h-8 items-center gap-1 rounded-(--cell-radius) ps-2 pe-1 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
- "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
+ "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
- week: cn("flex w-full mt-2", defaultClassNames.week),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
- "select-none w-(--cell-size)",
+ "w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
- "text-[0.8rem] select-none text-muted-foreground",
+ "text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
- "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-e-md group/day aspect-square select-none",
+ // Logical (`rounded-s`/`rounded-e`) so the range pill caps the
+ // correct edge in RTL Arabic mode without flipping.
+ "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)",
props.showWeekNumber
- ? "[&:nth-child(2)[data-selected=true]_button]:rounded-s-md"
- : "[&:first-child[data-selected=true]_button]:rounded-s-md",
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)"
+ : "[&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)",
defaultClassNames.day
),
+ // The ::after pseudo-element bridges any sub-pixel gap between the
+ // primary-coloured endpoint pill and the muted range_middle band.
+ // `after:end-0` on start / `after:start-0` on end keeps the bridge
+ // pointing inward in both LTR and RTL.
range_start: cn(
- "rounded-s-md bg-accent",
+ "relative isolate z-0 rounded-s-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:end-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
- range_end: cn("rounded-e-md bg-accent", defaultClassNames.range_end),
+ range_end: cn(
+ "relative isolate z-0 rounded-e-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:start-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_end
+ ),
today: cn(
- "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
@@ -162,7 +176,9 @@ function Calendar({
)
},
- DayButton: CalendarDayButton,
+ DayButton: ({ ...props }) => (
+
+ ),
WeekNumber: ({ children, ...props }) => {
return (
@@ -183,8 +199,9 @@ function CalendarDayButton({
className,
day,
modifiers,
+ locale,
...props
-}: React.ComponentProps) {
+}: React.ComponentProps & { locale?: Partial }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef(null)
@@ -197,7 +214,7 @@ function CalendarDayButton({
ref={ref}
variant="ghost"
size="icon"
- data-day={day.date.toLocaleDateString()}
+ data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
@@ -208,7 +225,15 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
- "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-e-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-s-md [&>span]:text-xs [&>span]:opacity-70",
+ // `relative isolate z-10` lets this pill paint above the cell's
+ // ::after bridge so the primary-coloured endpoint stays crisp.
+ "relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal",
+ "group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50",
+ "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground",
+ "data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground",
+ "data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground",
+ "data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground",
+ "dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
diff --git a/src/lib/actions/report-issue.ts b/src/lib/actions/report-issue.ts
index b70e2bc..5ed2f65 100644
--- a/src/lib/actions/report-issue.ts
+++ b/src/lib/actions/report-issue.ts
@@ -1,88 +1,57 @@
-"use server"
-
-import { auth } from "@/lib/auth"
-
-export async function reportIssue(data: {
- description: string
- pageUrl: string
- viewport?: string
- direction?: string
- browser?: string
-}) {
- const token = process.env.GITHUB_PERSONAL_ACCESS_TOKEN
- const repo = process.env.GITHUB_REPO || "databayt/mkan"
-
- if (!token) throw new Error("Issue reporting is not configured")
-
- const desc = data.description
- const truncated =
- desc.length > 80 ? desc.slice(0, 77) + "..." : desc
- const title = truncated
-
- // Reporter from auth session
- const session = await auth().catch(() => null)
- const reporter = session?.user
- ? `${session.user.name} (${session.user.email})`
- : "Anonymous"
-
- const body = [
- data.description,
- "",
- "---",
- "",
- `**Reporter**: ${reporter}`,
- `**Page**: \`${data.pageUrl}\``,
- data.viewport ? `**Viewport**: ${data.viewport}` : null,
- data.direction ? `**Direction**: ${data.direction}` : null,
- data.browser ? `**Browser**: ${data.browser}` : null,
- `**Time**: ${new Date().toISOString()}`,
- ]
- .filter(Boolean)
- .join("\n")
-
- // Try with label first, fall back without if label doesn't exist
- const payload: Record = { title, body, labels: ["report"] }
-
- let response = await fetch(`https://api.github.com/repos/${repo}/issues`, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${token}`,
- Accept: "application/vnd.github+json",
+"use server";
+
+/**
+ * Report-issue server action — thin wrapper around the shared pipeline.
+ *
+ * All quality gating (Zod, hard filters, captcha, dedup, AI triage, scoring)
+ * lives in {@link runReportPipeline}. The client receives a symmetric success
+ * shape so spammers can't probe the filter.
+ */
+
+import { headers } from "next/headers";
+
+import { runReportPipeline } from "@/lib/report";
+import { mkanReportAdapter } from "@/lib/report/adapter";
+
+import type {
+ ReportIssueSubmitInput,
+ ReportIssueSubmitResult,
+} from "@/components/report-issue/dialog";
+
+export async function reportIssue(
+ data: ReportIssueSubmitInput
+): Promise {
+ const h = await headers();
+ const ip =
+ h.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ h.get("x-real-ip") ||
+ h.get("cf-connecting-ip") ||
+ "0.0.0.0";
+
+ const result = await runReportPipeline(
+ {
+ description: data.description,
+ pageUrl: data.pageUrl,
+ category: data.category,
+ reproSteps: data.reproSteps,
+ expected: data.expected,
+ actual: data.actual,
+ severityHint: data.severityHint,
+ viewport: data.viewport,
+ direction: data.direction,
+ browser: data.browser,
+ hasScreenshot: data.hasScreenshot,
+ captchaToken: data.captchaToken,
},
- body: JSON.stringify(payload),
- })
+ mkanReportAdapter,
+ { ip }
+ );
- // If 422 (label doesn't exist), retry without labels
- if (response.status === 422) {
- delete payload.labels
- response = await fetch(`https://api.github.com/repos/${repo}/issues`, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${token}`,
- Accept: "application/vnd.github+json",
- },
- body: JSON.stringify(payload),
- })
+ if (result.ok && result.bucket === "verified-report" && result.issueNumber) {
+ return { ok: true, issueNumber: result.issueNumber };
}
-
- if (!response.ok) {
- const text = await response.text().catch(() => "")
- console.error(`[report-issue] GitHub API ${response.status}: ${text}`)
- throw new Error(`GitHub API error: ${response.status}`)
- }
-
- // Acknowledgment comment (fire-and-forget)
- const issueData = await response.json().catch(() => null)
- if (issueData?.comments_url) {
- fetch(issueData.comments_url, {
- method: "POST",
- headers: {
- Authorization: `Bearer ${token}`,
- Accept: "application/vnd.github+json",
- },
- body: JSON.stringify({
- body: "Received. This report is queued for automated review and fix. You'll be notified here when resolved.",
- }),
- }).catch(() => {})
+ if (result.ok) {
+ return { ok: true };
}
+ return { ok: false };
}
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
index 7f28123..68fe2c5 100644
--- a/src/lib/rate-limit.ts
+++ b/src/lib/rate-limit.ts
@@ -59,6 +59,24 @@ export const rateLimiters = {
analytics: true,
prefix: "@upstash/ratelimit/mutation",
}) : null,
+
+ // Report-an-issue submissions: 5 per 10 minutes per reporter (user or IP).
+ // Tight enough to defeat flood spam, loose enough that a frustrated
+ // legitimate user can file a few related reports back to back.
+ report: redis ? new Ratelimit({
+ redis,
+ limiter: Ratelimit.slidingWindow(5, "10 m"),
+ analytics: true,
+ prefix: "@upstash/ratelimit/report",
+ }) : null,
+
+ // Per-tenant aggregate to catch coordinated abuse across many reporters.
+ "report-tenant": redis ? new Ratelimit({
+ redis,
+ limiter: Ratelimit.slidingWindow(30, "1 h"),
+ analytics: true,
+ prefix: "@upstash/ratelimit/report-tenant",
+ }) : null,
};
// Get client identifier for rate limiting
diff --git a/src/lib/report/README.md b/src/lib/report/README.md
new file mode 100644
index 0000000..d2f1a2d
--- /dev/null
+++ b/src/lib/report/README.md
@@ -0,0 +1,95 @@
+# Report Pipeline — Credibility Scoring
+
+Shared scoring infrastructure for the "Report an Issue" feature across all
+databayt product repos. Filters out nonsense and destructive submissions
+**before** they reach the auto-fix queue, while respecting wisdom-of-the-crowd
+corroboration signals.
+
+## Contract
+
+Each repo (hogwarts, mkan, kun) copies this directory to `src/lib/report/`
+and writes a thin **adapter** that wires its repo-specific concerns:
+
+- `auth()` shape
+- Rate-limit store (Upstash; mkan already has the pattern)
+- Recent-submissions ledger (KV or DB)
+- Corroboration counter (KV or DB)
+- Repo path (e.g. `databayt/hogwarts`)
+- Host allowlist (`*.databayt.org`, `localhost`, etc.)
+
+## Pipeline
+
+```
+reportSchema (Zod) → reporter → hard-filters → captcha →
+ dedup → AI triage (Haiku) → score → bucket → GitHub issue
+```
+
+## Buckets (strict thresholds)
+
+| Score | Bucket | Action |
+|---|---|---|
+| `<30` | `silent-reject` | No issue created. UI returns success. |
+| `30-54` | `low-confidence` | Issue + `low-confidence` label. Agent skips. 14d auto-close. |
+| `55-74` | `needs-human` | Issue + `needs-human` label. Human review. |
+| `≥75` | `verified-report` | Issue + `verified-report` label. Agent auto-fixes. |
+
+Overrides:
+- `destructive` classification → forced `needs-human`
+- 3 corroborations on same URL → upgrades existing issue to `verified-report`
+- AI failure → bucket capped at `needs-human`
+- `severityHint=critical` and score≥60 → bucket promoted to `verified-report`
+
+## Files
+
+| File | What |
+|---|---|
+| `types.ts` | All shared types (`ReportInput`, `ReporterContext`, `ScoringResult`, …) |
+| `schema.ts` | Zod schema (`reportSchema`, `REPORT_CATEGORIES`) |
+| `labels.ts` | GitHub label specs (`REPORT_LABELS`, severity/language helpers) |
+| `hard-filters.ts` | HF1–HF10 silent-reject triggers |
+| `score.ts` | Pure scoring function `computeScore()` |
+| `triage.ts` | Claude Haiku 4.5 call with forced tool-use |
+| `dedup.ts` | Jaccard-similarity duplicate detection |
+| `corroboration.ts` | 3-reporter upgrade check |
+| `turnstile.ts` | Cloudflare captcha verification |
+| `github.ts` | GitHub REST helpers (createIssue, addLabels, search) |
+| `pipeline.ts` | Orchestrator — `runReportPipeline()` |
+| `adapters/adapter.ts` | `ReportAdapter` interface |
+| `index.ts` | Public surface |
+
+## Usage from a server action
+
+```ts
+"use server";
+import { runReportPipeline } from "@/lib/report";
+import { hogwartsAdapter } from "@/lib/report/adapter";
+import { headers } from "next/headers";
+
+export async function reportIssue(raw: unknown) {
+ const h = await headers();
+ const ip = h.get("x-forwarded-for")?.split(",")[0] ?? "0.0.0.0";
+ return await runReportPipeline(raw, hogwartsAdapter, { ip });
+}
+```
+
+The pipeline always returns symmetric success to the caller. The UI shows the
+same "Submitted, thank you" toast for silent-reject and verified-report. Only
+verified-bucket results include an `issueNumber` field.
+
+## Required env
+
+```
+GITHUB_PERSONAL_ACCESS_TOKEN # PAT with issues:write on the repo
+GITHUB_REPO # e.g. databayt/hogwarts (or default in adapter)
+ANTHROPIC_API_KEY # Haiku 4.5 access
+UPSTASH_REDIS_REST_URL # rate-limit + recent ledger
+UPSTASH_REDIS_REST_TOKEN
+TURNSTILE_SECRET_KEY # captcha for anonymous submissions
+NEXT_PUBLIC_TURNSTILE_SITE_KEY # client-side widget
+```
+
+## See also
+
+- Plan: `/Users/abdout/.claude/plans/read-report-an-issue-glistening-wave.md`
+- Agent: `/Users/abdout/kun/.claude/agents/report.md` — bucket-aware VALIDATE
+- Session hook: `/Users/abdout/.claude/settings.json` — surfaces only `verified-report`
diff --git a/src/lib/report/adapter.ts b/src/lib/report/adapter.ts
new file mode 100644
index 0000000..ee428c7
--- /dev/null
+++ b/src/lib/report/adapter.ts
@@ -0,0 +1,155 @@
+/**
+ * Mkan-specific adapter for the shared report pipeline.
+ *
+ * Mkan has rich auth (UserRole enum: SUPER_ADMIN, ADMIN, HOST, GUEST, ...) and
+ * a Prisma User model. Phase 1 reads role + ipHash; Phase 2 will add
+ * priorAccepted/priorRejected from the upcoming Report Prisma model.
+ *
+ * Rate-limit + dedup + corroboration use Upstash (the existing mkan pattern).
+ */
+
+import { createHash } from "crypto";
+
+import { auth } from "@/lib/auth";
+import { assertRateLimit, RateLimitError as MkanRateLimitError } from "@/lib/rate-limit";
+
+import {
+ RateLimitError,
+ type ReportAdapter,
+} from "./adapters/adapter";
+import type { PipelineEvent, ReporterContext, ReportInput } from "./types";
+
+import { Redis } from "@upstash/redis";
+import { headers } from "next/headers";
+
+const REPO = process.env.GITHUB_REPO || "databayt/mkan";
+const SALT = process.env.REPORT_IP_SALT || "mkan-default-salt";
+
+const redis =
+ process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
+ ? Redis.fromEnv()
+ : null;
+
+export const mkanReportAdapter: ReportAdapter = {
+ repo: REPO,
+ hostAllowlist: [
+ "mkan.databayt.org",
+ "mkan.com.sa",
+ "*.mkan.com.sa",
+ "*.databayt.org",
+ "localhost",
+ "127.0.0.1",
+ ],
+
+ async getReporter(_input: ReportInput): Promise {
+ const ip = await getClientIpFromHeaders();
+ const ipHash = hashIp(ip);
+
+ const session = await auth().catch(() => null);
+ if (session?.user?.id) {
+ const role = String(session.user.role ?? "USER");
+ return {
+ kind: "authenticated",
+ userId: session.user.id,
+ role,
+ emailVerified: Boolean(session.user.email),
+ accountAgeDays: 30, // Phase 1: rough constant; Phase 2 reads from DB
+ isSuspended: false,
+ ipHash,
+ };
+ }
+ return { kind: "anonymous", ipHash };
+ },
+
+ async checkRateLimit(identifier: string): Promise {
+ try {
+ await assertRateLimit("report", identifier);
+ await assertRateLimit("report-tenant", "mkan");
+ } catch (err) {
+ if (err instanceof MkanRateLimitError) {
+ throw new RateLimitError(err.message);
+ }
+ throw err;
+ }
+ },
+
+ async getRecentSelfSubmissions(identifier: string, withinSec: number): Promise {
+ if (!redis) return [];
+ const key = `report:dedup:${identifier}`;
+ const raw = (await redis.lrange(key, 0, 19).catch(() => null)) ?? [];
+ const cutoff = Date.now() - withinSec * 1000;
+ return raw
+ .map((s) => {
+ const idx = s.indexOf("|");
+ if (idx < 0) return null;
+ const ts = Number(s.slice(0, idx));
+ const head = s.slice(idx + 1);
+ return ts >= cutoff ? head : null;
+ })
+ .filter((v): v is string => v !== null);
+ },
+
+ async getCorroborationCount(host: string, path: string, withinDays: number): Promise {
+ if (!redis) return 0;
+ const key = `report:page:${host}:${normalizedPath(path)}`;
+ const count = await redis.get(key).catch(() => null);
+ void withinDays;
+ return count == null ? 0 : Number(count);
+ },
+
+ async isBanned(identifier: string): Promise {
+ if (!redis) return false;
+ const banned = await redis.sismember("report:banned", identifier).catch(() => 0);
+ return banned === 1;
+ },
+
+ async recordPipelineEvent(event: PipelineEvent): Promise {
+ console.info("[report]", JSON.stringify(event));
+
+ if (!redis) return;
+
+ if (event.outcome !== "silent-reject" && event.outcome !== "duplicate-corroborated") {
+ const id =
+ event.reporterKind === "authenticated"
+ ? `user:${event.ipHash}`
+ : `ip:${event.ipHash}`;
+ const key = `report:dedup:${id}`;
+ const entry = `${Date.now()}|${event.path.slice(0, 60)}`;
+ await redis.lpush(key, entry).catch(() => {});
+ await redis.ltrim(key, 0, 19).catch(() => {});
+ await redis.expire(key, 60).catch(() => {});
+ }
+
+ if (event.outcome === "verified-report" && event.host && event.path) {
+ const key = `report:page:${event.host}:${normalizedPath(event.path)}`;
+ await redis.incr(key).catch(() => {});
+ await redis.expire(key, 60 * 60 * 24 * 7).catch(() => {});
+ }
+ },
+
+ async findExistingForUrl(host: string, path: string): Promise<{ issueNumber: number } | null> {
+ if (!redis) return null;
+ const key = `report:issue:${host}:${normalizedPath(path)}`;
+ const num = await redis.get(key).catch(() => null);
+ return num ? { issueNumber: Number(num) } : null;
+ },
+};
+
+async function getClientIpFromHeaders(): Promise {
+ const h = await headers();
+ return (
+ h.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ h.get("x-real-ip") ||
+ h.get("cf-connecting-ip") ||
+ "0.0.0.0"
+ );
+}
+
+function hashIp(ip: string): string {
+ return createHash("sha256").update(`${ip}:${SALT}`).digest("hex").slice(0, 16);
+}
+
+function normalizedPath(path: string): string {
+ const beforeQuery = path.split("?")[0] ?? path;
+ return beforeQuery.replace(/\/$/, "") || "/";
+}
diff --git a/src/lib/report/adapters/adapter.ts b/src/lib/report/adapters/adapter.ts
new file mode 100644
index 0000000..f4c80fb
--- /dev/null
+++ b/src/lib/report/adapters/adapter.ts
@@ -0,0 +1,70 @@
+/**
+ * Adapter contract — every product repo writes one of these to wire its own
+ * auth, rate-limit, and history into the shared pipeline.
+ *
+ * Repos as of Phase 1:
+ * hogwarts → @/auth + Upstash (DB-less for this phase)
+ * mkan → @/lib/auth + Upstash
+ * kun → no auth, Upstash-only, captcha always required
+ */
+
+import type {
+ PipelineEvent,
+ ReporterContext,
+ ReportInput,
+} from "../types";
+
+export interface ReportAdapter {
+ /** GitHub repo path, e.g. "databayt/hogwarts". */
+ readonly repo: string;
+
+ /** Host allowlist for HF5. Entries: exact ("localhost") or "*.suffix" wildcards. */
+ readonly hostAllowlist: readonly string[];
+
+ /**
+ * Resolve the current request's reporter. Anonymous result is acceptable
+ * (will be subject to captcha + low base reputation).
+ */
+ getReporter(req: ReportInput): Promise;
+
+ /**
+ * Apply rate-limiting. Throws RateLimitError on breach; the pipeline
+ * catches it and returns a silent-reject (HF8).
+ */
+ checkRateLimit(identifier: string): Promise;
+
+ /**
+ * Return any first-60-char heads from this reporter's submissions in the
+ * last {withinSec} seconds. Used by HF9 to catch triple-click duplicates.
+ */
+ getRecentSelfSubmissions(identifier: string, withinSec: number): Promise;
+
+ /**
+ * Count *verified-report* issues for this page (host + path, query stripped)
+ * from independent reporters in the last {withinDays} days. Used by signal P
+ * for wisdom-of-the-crowd corroboration.
+ */
+ getCorroborationCount(host: string, path: string, withinDays: number): Promise;
+
+ /** True if the identifier is on the permanent ban list (HF10). */
+ isBanned(identifier: string): Promise;
+
+ /**
+ * Persist a pipeline event. Phase 1: log to console + Upstash. Phase 2:
+ * write to the Report Prisma model.
+ */
+ recordPipelineEvent(event: PipelineEvent): Promise;
+
+ /**
+ * Look up the existing verified report for this URL, if any, so that on the
+ * 3rd corroboration we can find and label the original issue.
+ */
+ findExistingForUrl(host: string, path: string): Promise<{ issueNumber: number } | null>;
+}
+
+export class RateLimitError extends Error {
+ constructor(message = "Report rate limit exceeded") {
+ super(message);
+ this.name = "RateLimitError";
+ }
+}
diff --git a/src/lib/report/corroboration.ts b/src/lib/report/corroboration.ts
new file mode 100644
index 0000000..c89e056
--- /dev/null
+++ b/src/lib/report/corroboration.ts
@@ -0,0 +1,65 @@
+/**
+ * Wisdom-of-the-crowd: when independent reporters hit the same page bug,
+ * upgrade the existing issue to verified-report regardless of the original
+ * individual scores.
+ *
+ * Phase 1 implements this via:
+ * - adapter.getCorroborationCount(host, path, days): how many distinct
+ * reporters have submitted *bug* classifications for this URL recently
+ * - adapter.findExistingForUrl(host, path): the canonical issue number
+ *
+ * When a new report scores bug + corroborationCount >= 2 (so this is the 3rd),
+ * we upgrade the existing issue's labels.
+ */
+
+import type { ReportAdapter } from "./adapters/adapter";
+import { REPORT_LABELS } from "./labels";
+import { addLabels } from "./github";
+
+export interface CorroborationCheck {
+ /** Number of distinct reporters who hit this URL in the corroboration window. */
+ count: number;
+ /** The existing issue to upgrade, if any. */
+ existingIssue: number | null;
+ /** True if this submission triggers the upgrade. */
+ shouldUpgrade: boolean;
+}
+
+const WINDOW_DAYS = 7;
+const UPGRADE_THRESHOLD = 2; // this report + 2 prior = 3 total
+
+export async function checkCorroboration(
+ pageUrl: string,
+ adapter: ReportAdapter
+): Promise {
+ const url = new URL(pageUrl);
+ const host = url.host;
+ const path = url.pathname;
+
+ const [count, existingIssue] = await Promise.all([
+ adapter.getCorroborationCount(host, path, WINDOW_DAYS),
+ adapter.findExistingForUrl(host, path),
+ ]);
+
+ return {
+ count,
+ existingIssue: existingIssue?.issueNumber ?? null,
+ shouldUpgrade: count >= UPGRADE_THRESHOLD && existingIssue !== null,
+ };
+}
+
+/**
+ * Add verified-report + corroborated labels to an existing issue.
+ */
+export async function upgradeExisting(
+ issueNumber: number,
+ ctx: { repo: string; token: string }
+): Promise {
+ await addLabels({
+ ...ctx,
+ issueNumber,
+ labels: [REPORT_LABELS.verified.name, REPORT_LABELS.corroborated.name],
+ }).catch((err) => {
+ console.warn("[corroboration] failed to upgrade existing issue:", err);
+ });
+}
diff --git a/src/lib/report/dedup.ts b/src/lib/report/dedup.ts
new file mode 100644
index 0000000..e57fd3c
--- /dev/null
+++ b/src/lib/report/dedup.ts
@@ -0,0 +1,81 @@
+/**
+ * Duplicate detection.
+ *
+ * Two layers:
+ * 1. Adapter recent-list (Upstash KV) — cheap, ~10ms, fires for the
+ * hogwarts #302/#303/#304 case where the same user double-clicks Submit.
+ * 2. GitHub issue search (optional) — slower, runs only when KV misses.
+ * Caps at top 5 candidates.
+ *
+ * If similarity > 0.8 → return found.
+ */
+
+import type { IssueSearchHit } from "./github";
+import { searchIssues } from "./github";
+import type { ReportInputParsed } from "./schema";
+import type { DuplicateMatch } from "./types";
+
+const SIM_THRESHOLD = 0.8;
+
+export interface DedupContext {
+ repo: string;
+ token: string;
+}
+
+export async function findDuplicateOnGitHub(
+ input: ReportInputParsed,
+ ctx: DedupContext
+): Promise {
+ const url = new URL(input.pageUrl);
+ const path = url.pathname;
+ // Cheap heuristic — search by leading 5 words of description and the page path.
+ const firstWords = input.description
+ .trim()
+ .split(/\s+/)
+ .slice(0, 5)
+ .join(" ")
+ .replace(/[^\p{L}\p{N}\s]/gu, "")
+ .slice(0, 60);
+
+ const query = `${firstWords} in:title label:report state:open`;
+ let hits: IssueSearchHit[] = [];
+ try {
+ hits = await searchIssues({ ...ctx, query, limit: 5 });
+ } catch {
+ return { found: false };
+ }
+
+ for (const hit of hits) {
+ if (!hit.body.includes(path)) continue;
+ const sim = jaccardSimilarity(input.description, hit.body);
+ if (sim >= SIM_THRESHOLD) {
+ return { found: true, issueNumber: hit.number, similarity: sim };
+ }
+ }
+ return { found: false };
+}
+
+/**
+ * Jaccard similarity on word sets — fast, no embedding model needed.
+ * Good enough to catch verbatim re-submissions and minor edits.
+ */
+export function jaccardSimilarity(a: string, b: string): number {
+ const setA = wordSet(a);
+ const setB = wordSet(b);
+ if (setA.size === 0 || setB.size === 0) return 0;
+
+ let intersection = 0;
+ for (const w of setA) if (setB.has(w)) intersection++;
+
+ return intersection / (setA.size + setB.size - intersection);
+}
+
+function wordSet(text: string): Set {
+ return new Set(
+ text
+ .toLowerCase()
+ .replace(/[ً-ٰٟ]/g, "")
+ .split(/[\s\p{P}]+/u)
+ .filter((t) => t.length >= 3)
+ );
+}
diff --git a/src/lib/report/github.ts b/src/lib/report/github.ts
new file mode 100644
index 0000000..f155e45
--- /dev/null
+++ b/src/lib/report/github.ts
@@ -0,0 +1,190 @@
+/**
+ * GitHub REST helpers — create issue, add comment, ensure labels, search.
+ *
+ * Uses the GitHub Personal Access Token from env. Each repo has its own
+ * token configured at deploy time; the same token authors the issue.
+ */
+
+import { ALL_REPORT_LABELS, type LabelSpec } from "./labels";
+
+const API = "https://api.github.com";
+
+interface GitHubAuth {
+ repo: string; // "databayt/hogwarts"
+ token: string;
+}
+
+function authHeaders(token: string) {
+ return {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ };
+}
+
+export interface CreateIssueArgs extends GitHubAuth {
+ title: string;
+ body: string;
+ labels: string[];
+}
+
+export interface CreateIssueResult {
+ issueNumber: number;
+ htmlUrl: string;
+ commentsUrl: string;
+}
+
+/**
+ * Create an issue, self-healing missing labels on 422. Generalizes the existing
+ * kun pattern (kun/src/actions/report-issue.ts:49-65) to handle all label specs.
+ */
+export async function createIssue(args: CreateIssueArgs): Promise {
+ const headers = authHeaders(args.token);
+ const payload = {
+ title: args.title.slice(0, 256),
+ body: args.body,
+ labels: args.labels,
+ };
+
+ let res = await fetch(`${API}/repos/${args.repo}/issues`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(payload),
+ });
+
+ if (res.status === 422) {
+ // Labels likely missing — ensure all known labels exist, then retry once.
+ await ensureLabels({ repo: args.repo, token: args.token });
+ res = await fetch(`${API}/repos/${args.repo}/issues`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(payload),
+ });
+ }
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`GitHub createIssue ${res.status}: ${text}`);
+ }
+
+ const data = (await res.json()) as {
+ number: number;
+ html_url: string;
+ comments_url: string;
+ };
+ return {
+ issueNumber: data.number,
+ htmlUrl: data.html_url,
+ commentsUrl: data.comments_url,
+ };
+}
+
+/**
+ * POST a comment to an existing issue. Fire-and-forget OK — caller wraps
+ * with .catch() to absorb network noise.
+ */
+export async function postComment(
+ args: GitHubAuth & { issueNumber: number; body: string }
+): Promise {
+ const res = await fetch(
+ `${API}/repos/${args.repo}/issues/${args.issueNumber}/comments`,
+ {
+ method: "POST",
+ headers: authHeaders(args.token),
+ body: JSON.stringify({ body: args.body }),
+ }
+ );
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`GitHub postComment ${res.status}: ${text}`);
+ }
+}
+
+/**
+ * Add a label to an existing issue (no replace, additive). Used when a
+ * corroboration count reaches 3 and we want to upgrade an existing issue
+ * from low-confidence/needs-human to verified-report.
+ */
+export async function addLabels(
+ args: GitHubAuth & { issueNumber: number; labels: string[] }
+): Promise {
+ const res = await fetch(
+ `${API}/repos/${args.repo}/issues/${args.issueNumber}/labels`,
+ {
+ method: "POST",
+ headers: authHeaders(args.token),
+ body: JSON.stringify({ labels: args.labels }),
+ }
+ );
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`GitHub addLabels ${res.status}: ${text}`);
+ }
+}
+
+/**
+ * Create all known labels in the repo (idempotent — 422 means "already exists").
+ */
+export async function ensureLabels(args: GitHubAuth): Promise {
+ const headers = authHeaders(args.token);
+ await Promise.all(
+ ALL_REPORT_LABELS.map((spec) => createOneLabel(args.repo, headers, spec))
+ );
+}
+
+async function createOneLabel(
+ repo: string,
+ headers: HeadersInit,
+ spec: LabelSpec
+): Promise {
+ await fetch(`${API}/repos/${repo}/labels`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ name: spec.name,
+ color: spec.color,
+ description: spec.description,
+ }),
+ }).catch(() => {});
+}
+
+export interface IssueSearchHit {
+ number: number;
+ title: string;
+ body: string;
+ state: "open" | "closed";
+ labels: string[];
+ htmlUrl: string;
+}
+
+/**
+ * Search issues by query. Returns at most {limit} hits. Used by dedup to find
+ * possible duplicate reports for the same page.
+ */
+export async function searchIssues(
+ args: GitHubAuth & { query: string; limit?: number }
+): Promise {
+ const q = encodeURIComponent(`repo:${args.repo} is:issue ${args.query}`);
+ const res = await fetch(`${API}/search/issues?q=${q}&per_page=${args.limit ?? 5}`, {
+ headers: authHeaders(args.token),
+ });
+ if (!res.ok) return [];
+ const data = (await res.json()) as {
+ items?: Array<{
+ number: number;
+ title: string;
+ body: string;
+ state: "open" | "closed";
+ labels: Array<{ name: string }>;
+ html_url: string;
+ }>;
+ };
+ return (data.items ?? []).map((i) => ({
+ number: i.number,
+ title: i.title,
+ body: i.body ?? "",
+ state: i.state,
+ labels: i.labels.map((l) => l.name),
+ htmlUrl: i.html_url,
+ }));
+}
diff --git a/src/lib/report/hard-filters.ts b/src/lib/report/hard-filters.ts
new file mode 100644
index 0000000..3b050e2
--- /dev/null
+++ b/src/lib/report/hard-filters.ts
@@ -0,0 +1,174 @@
+/**
+ * Hard filters — instant silent-reject triggers that run BEFORE scoring and AI.
+ * Each filter is cheap (no network) and fires before the more expensive Haiku call.
+ *
+ * The pipeline always returns symmetric success to the client (see plan §10),
+ * so a silent-reject from here is indistinguishable from a real success to the user.
+ *
+ * Triggers (plan §1.2):
+ * HF1 description < 30 chars ("test", "asdf", "doesn't work")
+ * HF2 description > 2000 chars (paste-bomb)
+ * HF3 anonymous + invalid Turnstile (bot defense)
+ * HF4 reporter.isSuspended (banned user)
+ * HF5 host not in repo allowlist (spoofed payload)
+ * HF6 unique meaningful tokens < 5 ("asdf asdf asdf asdf")
+ * HF7 letters < 40% OR non-alpha > 70% (keyboard mashing)
+ * HF8 Upstash rate-limit bucket says no (flood defense; checked in adapter)
+ * HF9 self-duplicate within 60 seconds (the #302/#303/#304 triple-click)
+ * HF10 banned IP/user (permanent ban; checked in adapter)
+ */
+
+import type { ReportInputParsed } from "./schema";
+import type {
+ RejectReason,
+ ReporterContext,
+} from "./types";
+
+export interface HardFilterContext {
+ hostAllowlist: string[];
+ recentSelfSubmissions: string[]; // first-60-chars of descriptions in last 60s for this reporter
+ captchaValid: boolean | null; // null if not needed, true/false otherwise
+ isBanned: boolean;
+}
+
+/**
+ * Runs all hard filters except HF8 (which lives in the adapter's checkRateLimit
+ * because it needs Upstash). Returns the first failing reason, or null if all pass.
+ */
+export function runHardFilters(
+ input: ReportInputParsed,
+ reporter: ReporterContext,
+ ctx: HardFilterContext
+): RejectReason | null {
+ // HF10 — banned identifier first (cheapest, ends conversation)
+ if (ctx.isBanned) {
+ return {
+ code: "HF10_banned",
+ detail: "Identifier is on the report ban list.",
+ };
+ }
+
+ // HF4 — suspended account
+ if (reporter.kind === "authenticated" && reporter.isSuspended) {
+ return {
+ code: "HF4_suspended",
+ detail: "Reporter account is suspended.",
+ };
+ }
+
+ const trimmed = input.description.trim();
+
+ // HF1 — too short. (Zod also rejects but we re-check after trim.)
+ if (trimmed.length < 30) {
+ return {
+ code: "HF1_too_short",
+ detail: `Description is ${trimmed.length} chars; minimum is 30.`,
+ };
+ }
+
+ // HF2 — too long
+ if (input.description.length > 2000) {
+ return {
+ code: "HF2_too_long",
+ detail: `Description is ${input.description.length} chars; maximum is 2000.`,
+ };
+ }
+
+ // HF3 — anonymous needs valid captcha. (When ctx.captchaValid is null, captcha
+ // wasn't required — e.g. authenticated user on a non-anon page.)
+ if (reporter.kind === "anonymous" && ctx.captchaValid !== true) {
+ return {
+ code: "HF3_no_captcha",
+ detail: "Anonymous reports require a valid Turnstile token.",
+ };
+ }
+
+ // HF5 — host allowlist
+ let host = "";
+ try {
+ host = new URL(input.pageUrl).host.toLowerCase();
+ } catch {
+ return {
+ code: "HF5_host_mismatch",
+ detail: "Page URL did not parse.",
+ };
+ }
+ if (!hostMatches(host, ctx.hostAllowlist)) {
+ return {
+ code: "HF5_host_mismatch",
+ detail: `Host "${host}" is not in the allowlist.`,
+ };
+ }
+
+ // HF6 — unique meaningful tokens < 5. Catches "asdf asdf asdf asdf".
+ const meaningfulTokens = uniqueMeaningfulTokens(trimmed);
+ if (meaningfulTokens < 5) {
+ return {
+ code: "HF6_few_tokens",
+ detail: `Only ${meaningfulTokens} unique meaningful tokens.`,
+ };
+ }
+
+ // HF7 — character-class ratio. Counts Arabic + Latin letters as letters.
+ const ratio = letterRatio(trimmed);
+ if (ratio.letters < 0.4 || ratio.nonAlpha > 0.7) {
+ return {
+ code: "HF7_gibberish",
+ detail: `letters=${ratio.letters.toFixed(2)}, nonAlpha=${ratio.nonAlpha.toFixed(2)}`,
+ };
+ }
+
+ // HF9 — same reporter, similar description, in last 60 seconds. This is the
+ // hogwarts #302/#303/#304 case where the user double/triple-clicks Submit.
+ const head = trimmed.slice(0, 60).toLowerCase();
+ if (ctx.recentSelfSubmissions.some((prev) => prev.toLowerCase() === head)) {
+ return {
+ code: "HF9_self_duplicate",
+ detail: "Same first-60-chars of description submitted within last 60s.",
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Match host against allowlist entries. Entries can be exact (`localhost`) or
+ * wildcard (`*.databayt.org`).
+ */
+export function hostMatches(host: string, allowlist: readonly string[]): boolean {
+ return allowlist.some((entry) => {
+ const normalized = entry.toLowerCase();
+ if (normalized.startsWith("*.")) {
+ const suffix = normalized.slice(1); // ".databayt.org"
+ return host === suffix.slice(1) || host.endsWith(suffix);
+ }
+ return host === normalized;
+ });
+}
+
+/**
+ * Count tokens that look like real words. Drops < 2 char tokens, dedups,
+ * normalizes case + Arabic diacritics.
+ */
+export function uniqueMeaningfulTokens(text: string): number {
+ const tokens = text
+ .toLowerCase()
+ .replace(/[ً-ٰٟ]/g, "") // Arabic diacritics
+ .split(/[\s\p{P}]+/u) // unicode whitespace + punctuation
+ .filter((t) => t.length >= 2);
+ return new Set(tokens).size;
+}
+
+/**
+ * Ratio of letters (any Unicode letter incl. Arabic) vs total non-space chars.
+ */
+export function letterRatio(text: string): { letters: number; nonAlpha: number } {
+ const nonSpace = text.replace(/\s/g, "");
+ if (nonSpace.length === 0) return { letters: 0, nonAlpha: 1 };
+ const letters = nonSpace.match(/\p{L}/gu)?.length ?? 0;
+ const digits = nonSpace.match(/\p{N}/gu)?.length ?? 0;
+ return {
+ letters: letters / nonSpace.length,
+ nonAlpha: (nonSpace.length - letters - digits) / nonSpace.length,
+ };
+}
diff --git a/src/lib/report/index.ts b/src/lib/report/index.ts
new file mode 100644
index 0000000..a400e58
--- /dev/null
+++ b/src/lib/report/index.ts
@@ -0,0 +1,29 @@
+/**
+ * Public surface of the report pipeline. Repo-specific adapters import from
+ * here; everything else is internal.
+ */
+
+export { runReportPipeline } from "./pipeline";
+export { reportSchema, REPORT_CATEGORIES } from "./schema";
+export { computeScore, THRESHOLDS, bucketFor } from "./score";
+export { RateLimitError } from "./adapters/adapter";
+export type { ReportAdapter } from "./adapters/adapter";
+export {
+ REPORT_LABELS,
+ ALL_REPORT_LABELS,
+ severityLabel,
+ languageLabel,
+} from "./labels";
+export { ensureLabels } from "./github";
+export type {
+ Bucket,
+ Classification,
+ Language,
+ PipelineResult,
+ PipelineEvent,
+ ReportCategory,
+ ReportInput,
+ ReporterContext,
+ ScoringResult,
+ Severity,
+} from "./types";
diff --git a/src/lib/report/labels.ts b/src/lib/report/labels.ts
new file mode 100644
index 0000000..3a36caf
--- /dev/null
+++ b/src/lib/report/labels.ts
@@ -0,0 +1,97 @@
+/**
+ * GitHub label taxonomy. Source of truth.
+ *
+ * The bootstrap script /Users/abdout/codebase/scripts/bootstrap-report-labels.sh
+ * creates these in every product repo. The pipeline's github.ts::ensureLabels
+ * also creates them lazily on first 422 from issue creation.
+ */
+
+export interface LabelSpec {
+ name: string;
+ color: string; // hex without leading #
+ description: string;
+}
+
+export const REPORT_LABELS = {
+ /** Base label — every dialog-created issue carries this. Hook + agent query on it. */
+ report: {
+ name: "report",
+ color: "d93f0b",
+ description: "User-reported issue via Report an Issue dialog",
+ },
+ /** Score >= 75 AND classification = bug. Auto-fix candidates. */
+ verified: {
+ name: "verified-report",
+ color: "0e8a16",
+ description: "Pre-validated bug, eligible for auto-fix",
+ },
+ /** Score 30..54. Created but agent skips. 14d auto-close unless human overrides. */
+ lowConfidence: {
+ name: "low-confidence",
+ color: "fbca04",
+ description: "Borderline report, not auto-processed",
+ },
+ /** Score 55..74 OR classification ∈ {feature, destructive}. Human review required. */
+ needsHuman: {
+ name: "needs-human",
+ color: "b60205",
+ description: "Requires human triage before auto-fix",
+ },
+ /** Existing issue reached 3 +1 confirmations from independent reporters. */
+ corroborated: {
+ name: "corroborated",
+ color: "5319e7",
+ description: "Multiple independent reports on same page",
+ },
+ severityCritical: {
+ name: "severity/critical",
+ color: "b60205",
+ description: "Data loss, security, total outage",
+ },
+ severityHigh: {
+ name: "severity/high",
+ color: "d93f0b",
+ description: "Core feature broken for many users",
+ },
+ severityMedium: {
+ name: "severity/medium",
+ color: "fbca04",
+ description: "Noticeable bug, workaround exists",
+ },
+ severityLow: {
+ name: "severity/low",
+ color: "c2e0c6",
+ description: "Cosmetic, edge case",
+ },
+ langAr: {
+ name: "lang/ar",
+ color: "1d76db",
+ description: "Report written in Arabic",
+ },
+ langEn: {
+ name: "lang/en",
+ color: "0052cc",
+ description: "Report written in English",
+ },
+} as const satisfies Record;
+
+export const ALL_REPORT_LABELS: readonly LabelSpec[] = Object.values(REPORT_LABELS);
+
+export function severityLabel(sev: "critical" | "high" | "medium" | "low"): string {
+ switch (sev) {
+ case "critical":
+ return REPORT_LABELS.severityCritical.name;
+ case "high":
+ return REPORT_LABELS.severityHigh.name;
+ case "medium":
+ return REPORT_LABELS.severityMedium.name;
+ case "low":
+ return REPORT_LABELS.severityLow.name;
+ }
+}
+
+export function languageLabel(lang: "ar" | "en" | "mixed" | "other"): string | null {
+ if (lang === "ar") return REPORT_LABELS.langAr.name;
+ if (lang === "en") return REPORT_LABELS.langEn.name;
+ return null;
+}
diff --git a/src/lib/report/pipeline.ts b/src/lib/report/pipeline.ts
new file mode 100644
index 0000000..38b7162
--- /dev/null
+++ b/src/lib/report/pipeline.ts
@@ -0,0 +1,358 @@
+/**
+ * Pipeline orchestrator — wires schema, adapter, hard-filters, captcha, dedup,
+ * triage, score, GitHub.
+ *
+ * Caller (repo's server action) does:
+ *
+ * const result = await runReportPipeline(rawInput, adapter);
+ * // result.ok is always true unless something unrecoverable broke.
+ *
+ * The pipeline returns symmetric success (plan §10): the client UI shows the
+ * same "Submitted, thank you" message whether we created an issue or silently
+ * rejected. Only verified-bucket results expose an issueNumber.
+ */
+
+import { checkCorroboration, upgradeExisting } from "./corroboration";
+import { findDuplicateOnGitHub } from "./dedup";
+import { createIssue, postComment } from "./github";
+import { hostMatches, runHardFilters } from "./hard-filters";
+import { reportSchema, type ReportInputParsed } from "./schema";
+import { computeScore } from "./score";
+import { classifyWithHaiku } from "./triage";
+import { verifyTurnstile } from "./turnstile";
+import { RateLimitError, type ReportAdapter } from "./adapters/adapter";
+import type {
+ AITriageResult,
+ PipelineEvent,
+ PipelineResult,
+ ReportInput,
+ ReporterContext,
+ ScoringResult,
+} from "./types";
+
+const RECENT_WINDOW_SEC = 60;
+
+export async function runReportPipeline(
+ raw: unknown,
+ adapter: ReportAdapter,
+ opts: { ip: string } = { ip: "0.0.0.0" }
+): Promise {
+ const token = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
+ if (!token) {
+ console.error("[report-pipeline] GITHUB_PERSONAL_ACCESS_TOKEN not configured");
+ return { ok: false, error: "config" };
+ }
+
+ // 1. Schema parse — wrong shape → return ok:true (denies info to client probing)
+ const parsed = reportSchema.safeParse(raw);
+ if (!parsed.success) {
+ await record(adapter, parsed.data, null, "silent-reject", "HF1_too_short", "0.0.0.0");
+ return { ok: true, bucket: "silent-reject" };
+ }
+ const input = parsed.data;
+
+ // 2. Reporter context
+ const reporter = await adapter.getReporter(input as ReportInput);
+ const identifier =
+ reporter.kind === "authenticated"
+ ? `user:${reporter.userId}`
+ : `ip:${reporter.ipHash}`;
+
+ // 3. Rate-limit (HF8). RateLimitError → silent-reject.
+ try {
+ await adapter.checkRateLimit(identifier);
+ } catch (err) {
+ if (err instanceof RateLimitError) {
+ await record(adapter, input, reporter, "silent-reject", "HF8_rate_limited", opts.ip);
+ return { ok: true, bucket: "silent-reject" };
+ }
+ throw err;
+ }
+
+ // 4. Captcha — required for anonymous, optional otherwise
+ let captchaValid: boolean | null = null;
+ if (reporter.kind === "anonymous") {
+ captchaValid = await verifyTurnstile(input.captchaToken, opts.ip);
+ }
+
+ // 5. Recent self-submissions (for HF9) + banned check (HF10)
+ const [recent, banned] = await Promise.all([
+ adapter.getRecentSelfSubmissions(identifier, RECENT_WINDOW_SEC),
+ adapter.isBanned(identifier),
+ ]);
+
+ // 6. Hard filters
+ const rejectReason = runHardFilters(input, reporter, {
+ hostAllowlist: adapter.hostAllowlist as string[],
+ recentSelfSubmissions: recent,
+ captchaValid,
+ isBanned: banned,
+ });
+ if (rejectReason) {
+ await record(adapter, input, reporter, "silent-reject", rejectReason.code, opts.ip);
+ return { ok: true, bucket: "silent-reject" };
+ }
+
+ // 7. Dedup against existing GitHub issues — if confident, +1 the existing
+ const dup = await findDuplicateOnGitHub(input, { repo: adapter.repo, token }).catch(
+ () => ({ found: false as const })
+ );
+ if (dup.found) {
+ await postComment({
+ repo: adapter.repo,
+ token,
+ issueNumber: dup.issueNumber,
+ body: corroborationComment(input, reporter),
+ }).catch(() => {});
+ // Trigger corroboration upgrade check on the existing issue
+ await maybeUpgradeOnCorroboration(input, adapter, token).catch(() => {});
+ await record(
+ adapter,
+ input,
+ reporter,
+ "duplicate-corroborated",
+ undefined,
+ opts.ip,
+ undefined,
+ dup.issueNumber
+ );
+ return { ok: true, bucket: "verified-report", issueNumber: dup.issueNumber };
+ }
+
+ // 8. AI triage (Haiku)
+ const triage: AITriageResult | null = await classifyWithHaiku(input, {
+ repo: adapter.repo,
+ reporter,
+ });
+
+ // 9. Pattern signal inputs
+ const url = new URL(input.pageUrl);
+ const hostIsProd = isProductionHost(url.host, adapter.hostAllowlist as string[]);
+ const corroborationCount =
+ triage?.classification === "bug"
+ ? await adapter.getCorroborationCount(url.host, url.pathname, 7).catch(() => 0)
+ : 0;
+ // ipDailyNoise is Phase 2; default to 0 in Phase 1.
+ const ipDailyNoise = 0;
+
+ // 10. Score and bucket
+ const result: ScoringResult = computeScore(input, {
+ reporter,
+ triage,
+ corroborationCount,
+ ipDailyNoise,
+ hostIsProd,
+ });
+
+ // 11. Silent-reject — no issue created
+ if (result.bucket === "silent-reject") {
+ await record(adapter, input, reporter, "silent-reject", undefined, opts.ip, result.score);
+ return { ok: true, bucket: "silent-reject" };
+ }
+
+ // 12. Create the GitHub issue
+ const issue = await createIssue({
+ repo: adapter.repo,
+ token,
+ title: buildTitle(input),
+ body: buildBody(input, reporter, triage, result),
+ labels: result.labels,
+ }).catch((err) => {
+ console.error("[report-pipeline] createIssue failed:", err);
+ return null;
+ });
+
+ if (!issue) {
+ return { ok: false, error: "internal" };
+ }
+
+ // 13. Optional auto-comment for verified-bucket — acknowledge to the user
+ if (result.bucket === "verified-report") {
+ await postComment({
+ repo: adapter.repo,
+ token,
+ issueNumber: issue.issueNumber,
+ body: ackComment(),
+ }).catch(() => {});
+ }
+
+ // 14. If corroborationCount was already at threshold, this newly-created
+ // verified-report stands on its own and the upgrade pass is a no-op.
+ await record(
+ adapter,
+ input,
+ reporter,
+ result.bucket,
+ undefined,
+ opts.ip,
+ result.score,
+ issue.issueNumber,
+ triage?.classification
+ );
+
+ return {
+ ok: true,
+ bucket: result.bucket,
+ issueNumber: issue.issueNumber,
+ score: result.score,
+ };
+}
+
+// ─── helpers ───────────────────────────────────────────────────────────────
+
+function isProductionHost(host: string, allowlist: readonly string[]): boolean {
+ // Prod = matches allowlist AND is not localhost/127.*/::1
+ if (/^localhost(?::\d+)?$/i.test(host)) return false;
+ if (/^127\./.test(host)) return false;
+ if (host === "::1") return false;
+ return hostMatches(host, allowlist);
+}
+
+function buildTitle(input: ReportInputParsed): string {
+ const prefix = input.category !== "other" ? `[${input.category}] ` : "";
+ const desc = input.description.trim();
+ const maxLen = 80 - prefix.length;
+ const truncated = desc.length > maxLen ? desc.slice(0, maxLen - 3) + "..." : desc;
+ return prefix + truncated;
+}
+
+function buildBody(
+ input: ReportInputParsed,
+ reporter: ReporterContext,
+ triage: AITriageResult | null,
+ result: ScoringResult
+): string {
+ const lines: string[] = [
+ input.description,
+ "",
+ "---",
+ "",
+ `**Page**: \`${input.pageUrl}\``,
+ `**Reporter**: ${reporterLabel(reporter)}`,
+ `**Time**: ${new Date().toISOString()}`,
+ `**Category**: ${input.category}`,
+ ];
+
+ if (input.viewport) lines.push(`**Viewport**: ${input.viewport}`);
+ if (input.direction) lines.push(`**Direction**: ${input.direction}`);
+ if (input.browser) lines.push(`**Browser**: ${input.browser}`);
+
+ if (input.reproSteps?.trim()) {
+ lines.push("", "**Steps to reproduce**:", input.reproSteps.trim());
+ }
+ if (input.expected?.trim()) {
+ lines.push("", "**Expected**:", input.expected.trim());
+ }
+ if (input.actual?.trim()) {
+ lines.push("", "**Actual**:", input.actual.trim());
+ }
+
+ // needs-human bucket needs the rationale visible above the fold
+ if (result.bucket === "needs-human" && triage) {
+ lines.push("", "---", "");
+ lines.push(`**Classification**: ${triage.classification}`);
+ if (triage.destructiveSignals.length > 0) {
+ lines.push(`**Destructive signals**: ${triage.destructiveSignals.join(", ")}`);
+ }
+ lines.push(`**AI rationale**: ${triage.rationale}`);
+ lines.push("");
+ lines.push("> This issue requires human review before any automated fix.");
+ lines.push("> Add the `verified-report` label to manually promote into the auto-fix queue.");
+ }
+
+ // Score block — machine-readable footer parsed by the /report agent
+ lines.push("", buildScoreBlock(result, triage));
+
+ return lines.join("\n");
+}
+
+function buildScoreBlock(result: ScoringResult, triage: AITriageResult | null): string {
+ const payload = {
+ score: result.score,
+ bucket: result.bucket,
+ classification: triage?.classification ?? "unknown",
+ severity: triage?.severity ?? "medium",
+ language: triage?.language ?? "other",
+ scores: result.breakdown,
+ rationale: triage?.rationale ?? "",
+ };
+ return ``;
+}
+
+function reporterLabel(reporter: ReporterContext): string {
+ if (reporter.kind === "anonymous") return "Anonymous";
+ return `${reporter.role} (id:${reporter.userId.slice(0, 8)}…)`;
+}
+
+function corroborationComment(
+ input: ReportInputParsed,
+ reporter: ReporterContext
+): string {
+ return [
+ "+1 corroborated by another reporter",
+ "",
+ "",
+ "New report on this page ",
+ "",
+ input.description,
+ "",
+ "---",
+ `Reporter: ${reporterLabel(reporter)}`,
+ `Time: ${new Date().toISOString()}`,
+ " ",
+ ].join("\n");
+}
+
+function ackComment(): string {
+ return "Received. This report passed automated triage and is queued for fix. You'll be notified here when resolved.";
+}
+
+async function maybeUpgradeOnCorroboration(
+ input: ReportInputParsed,
+ adapter: ReportAdapter,
+ token: string
+): Promise {
+ const check = await checkCorroboration(input.pageUrl, adapter);
+ if (check.shouldUpgrade && check.existingIssue) {
+ await upgradeExisting(check.existingIssue, { repo: adapter.repo, token });
+ }
+}
+
+async function record(
+ adapter: ReportAdapter,
+ input: ReportInputParsed | unknown,
+ reporter: ReporterContext | null,
+ outcome: PipelineEvent["outcome"],
+ rejectReason: PipelineEvent["rejectReason"],
+ ip: string,
+ score?: number,
+ issueNumber?: number,
+ classification?: PipelineEvent["classification"]
+): Promise {
+ let host = "";
+ let path = "";
+ try {
+ const u = new URL((input as ReportInputParsed)?.pageUrl ?? "");
+ host = u.host;
+ path = u.pathname;
+ } catch {
+ /* ignore */
+ }
+ const event: PipelineEvent = {
+ at: new Date().toISOString(),
+ repo: adapter.repo,
+ outcome,
+ rejectReason,
+ score,
+ classification,
+ issueNumber,
+ reporterKind: reporter?.kind ?? "anonymous",
+ reporterRole: reporter?.kind === "authenticated" ? reporter.role : undefined,
+ ipHash: reporter?.ipHash ?? "unknown",
+ host,
+ path,
+ };
+ await adapter.recordPipelineEvent(event).catch((err) => {
+ console.warn("[report-pipeline] recordPipelineEvent failed:", err);
+ });
+}
diff --git a/src/lib/report/schema.ts b/src/lib/report/schema.ts
new file mode 100644
index 0000000..8f34c95
--- /dev/null
+++ b/src/lib/report/schema.ts
@@ -0,0 +1,49 @@
+import { z } from "zod";
+
+import type { ReportCategory } from "./types";
+
+/**
+ * Canonical category list. Source of truth — repo-specific dictionaries
+ * translate these keys but cannot add new values.
+ */
+export const REPORT_CATEGORIES = [
+ "visual",
+ "broken",
+ "data",
+ "slow",
+ "confusing",
+ "auth",
+ "i18n",
+ "other",
+] as const satisfies readonly ReportCategory[];
+
+/**
+ * Bounds reasoned in plan §4:
+ * - 30 char min kills "test", "asdf", "doesn't work" (real spam patterns).
+ * - 2000 char max prevents paste-bomb / prompt-injection wall-of-text.
+ * - pageUrl is checked again later against the repo's host allowlist (HF5).
+ * - viewport regex bounds the existing client capture format (e.g. "1280x720").
+ */
+export const reportSchema = z.object({
+ description: z
+ .string()
+ .trim()
+ .min(30, "Please describe the issue in at least 30 characters")
+ .max(2000, "Description is too long"),
+ pageUrl: z.string().url().max(2048),
+ category: z.enum(REPORT_CATEGORIES).default("other"),
+ reproSteps: z.string().trim().max(1000).optional(),
+ expected: z.string().trim().max(500).optional(),
+ actual: z.string().trim().max(500).optional(),
+ severityHint: z.enum(["low", "medium", "high", "critical"]).optional(),
+ viewport: z
+ .string()
+ .regex(/^\d{2,5}x\d{2,5}$/, "Viewport must be WxH")
+ .optional(),
+ direction: z.enum(["ltr", "rtl"]).optional(),
+ browser: z.string().max(500).optional(),
+ hasScreenshot: z.boolean().default(false),
+ captchaToken: z.string().min(1).max(2048).optional(),
+});
+
+export type ReportInputParsed = z.infer;
diff --git a/src/lib/report/score.ts b/src/lib/report/score.ts
new file mode 100644
index 0000000..f289749
--- /dev/null
+++ b/src/lib/report/score.ts
@@ -0,0 +1,255 @@
+/**
+ * Pure scoring function — no I/O, deterministic, fully unit-testable.
+ *
+ * Composition (plan §1):
+ * score = clamp(R + Q + C + A + P, 0, 100)
+ *
+ * R 0..30 Reputation role base + account-age + prior history
+ * Q 0..25 Content length + structure + category + URL + screenshot
+ * C 0..10 Context viewport + dir/lang + UA + prod host
+ * A 0..35 AI triage quality*0.2 + clarity*0.1 + hasRepro*3 + hasExpected*2
+ * P -10..+10 Pattern corroboration bonus / coordinated-noise penalty
+ *
+ * Buckets (strict thresholds, locked in plan §0):
+ * <30 silent-reject no GitHub issue
+ * 30-54 low-confidence issue + label, agent skips, 14d auto-close
+ * 55-74 needs-human issue + label, human review
+ * ≥75 verified-report issue + label, auto-fix
+ *
+ * Overrides:
+ * classification = destructive → force needs-human + destructive label
+ * classification = feature → force at least needs-human
+ * classification = question → force at most low-confidence
+ * classification = spam → total × 0.2 (usually silent-rejects)
+ * classification = duplicate → handled in pipeline before score
+ * severityHint = critical && score ≥ 60 → force verified
+ * AI failure → caller passes triage=null → A and P dropped → cap at needs-human
+ */
+
+import {
+ REPORT_LABELS,
+ languageLabel,
+ severityLabel,
+} from "./labels";
+import type { ReportInputParsed } from "./schema";
+import type {
+ AITriageResult,
+ Bucket,
+ ReporterContext,
+ ScoringBreakdown,
+ ScoringResult,
+} from "./types";
+
+export const THRESHOLDS = {
+ verified: 75,
+ needsHuman: 55,
+ lowConfidence: 30,
+} as const;
+
+const ROLE_BASE: Record = {
+ DEVELOPER: 22,
+ ADMIN: 22,
+ TEACHER: 16,
+ STAFF: 16,
+ ACCOUNTANT: 16,
+ HOST: 14, // mkan host
+ GUEST: 10, // mkan guest
+ STUDENT: 10,
+ GUARDIAN: 10,
+ USER: 8,
+};
+
+export interface ScoreContext {
+ reporter: ReporterContext;
+ triage: AITriageResult | null; // null when AI call failed
+ corroborationCount: number; // distinct reporters on same URL within 7d
+ ipDailyNoise: number; // # of spam/feature/question reports from this IP in 24h
+ hostIsProd: boolean;
+}
+
+/**
+ * Pure scorer. Returns score + breakdown + bucket + labels to apply.
+ */
+export function computeScore(
+ input: ReportInputParsed,
+ ctx: ScoreContext
+): ScoringResult {
+ const R = reputationScore(ctx.reporter);
+ const Q = contentQualityScore(input, ctx.hostIsProd);
+ const C = contextScore(input, ctx.hostIsProd);
+ const A = ctx.triage ? aiScore(ctx.triage) : 0;
+ const P = ctx.triage ? patternScore(ctx) : 0;
+
+ // Spam classification gets heavily discounted before bucketing.
+ let total = R + Q + C + A + P;
+ if (ctx.triage?.classification === "spam") {
+ total = total * 0.2;
+ }
+ total = Math.max(0, Math.min(100, Math.round(total)));
+
+ const breakdown: ScoringBreakdown = { R, Q, C, A, P };
+ let bucket = bucketFor(total);
+
+ // Triage-driven overrides
+ if (ctx.triage?.classification === "destructive") {
+ bucket = "needs-human";
+ } else if (ctx.triage?.classification === "feature") {
+ if (bucket === "verified-report") bucket = "needs-human";
+ if (bucket === "silent-reject") bucket = "low-confidence";
+ } else if (ctx.triage?.classification === "question") {
+ if (bucket === "verified-report" || bucket === "needs-human") {
+ bucket = "low-confidence";
+ }
+ }
+
+ // AI failure cap
+ if (ctx.triage === null && bucket === "verified-report") {
+ bucket = "needs-human";
+ }
+
+ // Severity escalation
+ if (
+ input.severityHint === "critical" &&
+ total >= 60 &&
+ ctx.triage?.classification !== "destructive" &&
+ ctx.triage?.classification !== "feature" &&
+ ctx.triage?.classification !== "spam"
+ ) {
+ bucket = "verified-report";
+ }
+
+ return {
+ score: total,
+ breakdown,
+ bucket,
+ labels: labelsFor(bucket, ctx.triage),
+ };
+}
+
+function reputationScore(reporter: ReporterContext): number {
+ if (reporter.kind === "anonymous") {
+ return 4; // base, only present if captcha already validated
+ }
+
+ const base = ROLE_BASE[reporter.role.toUpperCase()] ?? ROLE_BASE.USER ?? 8;
+
+ let bonus = 0;
+ if (reporter.accountAgeDays >= 90) bonus += 3;
+ else if (reporter.accountAgeDays >= 14) bonus += 1;
+
+ if (!reporter.emailVerified) bonus -= 2;
+
+ if ((reporter.priorAccepted ?? 0) >= 3 && (reporter.priorRejected ?? 0) === 0) {
+ bonus += 5;
+ } else if ((reporter.priorRejected ?? 0) >= 3 && (reporter.priorAccepted ?? 0) <= 1) {
+ bonus -= 10; // shadow-ban the noisemaker
+ }
+
+ return clamp(base + bonus, 0, 30);
+}
+
+function contentQualityScore(input: ReportInputParsed, hostIsProd: boolean): number {
+ // length: reward up to ~110 chars (every 10 chars past 30 = 1 point, capped 8)
+ const len = input.description.trim().length;
+ const lenScore = clamp(Math.floor((len - 30) / 10), 0, 8);
+
+ let structureScore = 0;
+ if (input.reproSteps?.trim()) structureScore += 2;
+ if (input.expected?.trim()) structureScore += 2;
+ if (input.actual?.trim()) structureScore += 2;
+
+ const categoryScore = input.category === "other" ? 0 : 3;
+ const urlScore = hostIsProd ? 5 : 2; // give partial credit to localhost
+ const screenshotScore = input.hasScreenshot ? 3 : 0;
+
+ return clamp(
+ lenScore + structureScore + categoryScore + urlScore + screenshotScore,
+ 0,
+ 25
+ );
+}
+
+function contextScore(input: ReportInputParsed, hostIsProd: boolean): number {
+ let score = 0;
+
+ if (input.viewport && /^\d{2,5}x\d{2,5}$/.test(input.viewport)) {
+ const [wStr, hStr] = input.viewport.split("x");
+ const w = Number(wStr);
+ const h = Number(hStr);
+ if (Number.isFinite(w) && Number.isFinite(h) && w >= 320 && w <= 7680 && h >= 240 && h <= 4320) {
+ score += 3;
+ }
+ }
+
+ if (input.direction) {
+ const isArabic = /\p{Script=Arabic}/u.test(input.description);
+ if (
+ (isArabic && input.direction === "rtl") ||
+ (!isArabic && input.direction === "ltr")
+ ) {
+ score += 2;
+ }
+ }
+
+ if (input.browser && isPlausibleBrowser(input.browser)) score += 2;
+ if (hostIsProd) score += 3;
+
+ return clamp(score, 0, 10);
+}
+
+function aiScore(triage: AITriageResult): number {
+ const raw =
+ triage.qualityScore * 0.2 +
+ triage.clarity * 0.1 +
+ (triage.hasRepro ? 3 : 0) +
+ (triage.hasExpected ? 2 : 0);
+ return clamp(Math.round(raw), 0, 35);
+}
+
+function patternScore(ctx: ScoreContext): number {
+ let p = 0;
+ // Corroboration bonus — only for bug classification, and only if other
+ // distinct reporters have hit the same page recently.
+ if (ctx.triage?.classification === "bug" && ctx.corroborationCount >= 2) {
+ p += 10;
+ }
+ // Coordinated-noise penalty — IP is producing spam/feature/question repeatedly.
+ if (ctx.ipDailyNoise >= 5) {
+ p -= 10;
+ }
+ return clamp(p, -10, 10);
+}
+
+export function bucketFor(score: number): Bucket {
+ if (score >= THRESHOLDS.verified) return "verified-report";
+ if (score >= THRESHOLDS.needsHuman) return "needs-human";
+ if (score >= THRESHOLDS.lowConfidence) return "low-confidence";
+ return "silent-reject";
+}
+
+function labelsFor(bucket: Bucket, triage: AITriageResult | null): string[] {
+ if (bucket === "silent-reject") return [];
+
+ const labels: string[] = [REPORT_LABELS.report.name];
+ if (bucket === "verified-report") labels.push(REPORT_LABELS.verified.name);
+ else if (bucket === "needs-human") labels.push(REPORT_LABELS.needsHuman.name);
+ else if (bucket === "low-confidence") labels.push(REPORT_LABELS.lowConfidence.name);
+
+ if (triage) {
+ labels.push(severityLabel(triage.severity));
+ const lang = languageLabel(triage.language);
+ if (lang) labels.push(lang);
+ }
+
+ return labels;
+}
+
+function isPlausibleBrowser(ua: string): boolean {
+ // Major engine markers + non-empty version number. Bot UAs often lack version
+ // or use "curl/X". Real browsers have at least one of these tokens with a digit.
+ return /(Chrome|Firefox|Safari|Edge|OPR|Opera|Vivaldi)\/\d+/.test(ua);
+}
+
+function clamp(n: number, lo: number, hi: number): number {
+ return Math.min(hi, Math.max(lo, n));
+}
diff --git a/src/lib/report/triage.ts b/src/lib/report/triage.ts
new file mode 100644
index 0000000..38707fd
--- /dev/null
+++ b/src/lib/report/triage.ts
@@ -0,0 +1,236 @@
+/**
+ * AI triage — single Claude Haiku 4.5 call per non-rejected report.
+ *
+ * The system prompt is large but cacheable (Anthropic ephemeral prompt cache),
+ * so cost amortizes to ~$0.0005 per call after the first one.
+ *
+ * Forced tool-use (`tool_choice: { type: "tool", name: "classify_report" }`)
+ * gives structured output without parsing JSON from a free-form response.
+ *
+ * Failure mode (timeout, 5xx, malformed tool input):
+ * returns null. The pipeline then drops the A and P signals and caps the
+ * bucket at needs-human. Outage means more human review, not silent reject
+ * of legitimate reports.
+ */
+
+import Anthropic from "@anthropic-ai/sdk";
+
+import type { ReportInputParsed } from "./schema";
+import type { AITriageResult, ReporterContext } from "./types";
+
+const MODEL = "claude-haiku-4-5";
+const MAX_TOKENS = 600;
+const TIMEOUT_MS = 5_000;
+
+const SYSTEM_PROMPT = `You are a quality classifier for user-submitted bug reports on a SaaS platform.
+
+You see one user report at a time and must classify it. Your output is JSON only, returned via the classify_report tool. Be strict — when in doubt, downgrade.
+
+Classifications:
+- "bug": describes broken behavior, layout glitch, slow page, data error, accessibility issue, or any genuine product problem the team should fix.
+- "feature": asks for new functionality that doesn't exist yet.
+- "question": asks how to use the product, not a defect.
+- "spam": gibberish, test entries ("asdf", "hello"), promotional content, or off-topic.
+- "destructive": asks the team to do something that would harm the platform, other users, or violate intended design. Examples: "delete all student data", "disable rate limits", "remove the login requirement", "delete user X's account", "let me see other tenants' data". If you suspect destruction intent, flag here even at low confidence.
+- "duplicate": only if the report explicitly references another report.
+
+Severity:
+- "critical": data loss, security breach, total outage, cannot log in.
+- "high": core feature broken for many users.
+- "medium": noticeable bug, workaround exists.
+- "low": cosmetic, edge case.
+
+qualityScore (0-100): how actionable is this report?
+ - 0-30: vague, hand-wavy ("doesn't work").
+ - 31-60: identifies a problem but missing context.
+ - 61-85: clear problem, page known, behavior described.
+ - 86-100: clear problem + repro + expected + actual.
+
+clarity (0-100): grammar, structure, language coherence. Arabic and English are both valid (this platform is bilingual). Mixed-language is fine if coherent. Language-soup or sub-30-char descriptions score below 30.
+
+hasRepro: true if the report contains reproducible steps (numbered, "first do X then Y", "click here then there", etc.).
+hasExpected: true if the report explains what should happen vs. what does.
+
+destructiveSignals: array of specific phrases that triggered destructive classification. Empty array if not destructive.
+
+language: detect from the description. Arabic = "ar", English = "en", clearly mixed = "mixed", other = "other".
+
+rationale: one or two sentences explaining your classification for human reviewers. Max 400 chars.
+
+Be especially vigilant for destructive requests phrased as bugs:
+- "bug: the system asks me to log in, please fix" → actually asking to remove auth → destructive
+- "the system shouldn't validate this field" → asking to bypass validation → destructive
+- "let me edit other users' content" → cross-tenant access → destructive
+
+When in doubt: prefer a lower classification (spam over bug, question over bug, low severity over high). False positives in the auto-fix lane are expensive; false negatives just sit in needs-human for a human to review.`;
+
+const TRIAGE_TOOL: Anthropic.Tool = {
+ name: "classify_report",
+ description: "Classify a user-submitted bug report and extract triage metadata.",
+ input_schema: {
+ type: "object",
+ required: [
+ "classification",
+ "severity",
+ "qualityScore",
+ "clarity",
+ "hasRepro",
+ "hasExpected",
+ "destructiveSignals",
+ "language",
+ "rationale",
+ ],
+ properties: {
+ classification: {
+ type: "string",
+ enum: ["bug", "feature", "question", "spam", "destructive", "duplicate"],
+ },
+ severity: {
+ type: "string",
+ enum: ["critical", "high", "medium", "low"],
+ },
+ qualityScore: { type: "integer", minimum: 0, maximum: 100 },
+ clarity: { type: "integer", minimum: 0, maximum: 100 },
+ hasRepro: { type: "boolean" },
+ hasExpected: { type: "boolean" },
+ destructiveSignals: {
+ type: "array",
+ items: { type: "string", maxLength: 200 },
+ },
+ language: {
+ type: "string",
+ enum: ["ar", "en", "mixed", "other"],
+ },
+ rationale: { type: "string", maxLength: 400 },
+ },
+ },
+};
+
+export interface TriageContext {
+ repo: string;
+ reporter: ReporterContext;
+}
+
+/**
+ * Classify a single report. Returns null on any failure path so the pipeline
+ * can degrade gracefully.
+ */
+export async function classifyWithHaiku(
+ input: ReportInputParsed,
+ ctx: TriageContext
+): Promise {
+ const apiKey = process.env.ANTHROPIC_API_KEY;
+ if (!apiKey) {
+ console.warn("[report-triage] ANTHROPIC_API_KEY not set; skipping triage");
+ return null;
+ }
+
+ const client = new Anthropic({ apiKey, timeout: TIMEOUT_MS });
+
+ try {
+ const response = await client.messages.create({
+ model: MODEL,
+ max_tokens: MAX_TOKENS,
+ temperature: 0,
+ system: [
+ {
+ type: "text",
+ text: SYSTEM_PROMPT,
+ cache_control: { type: "ephemeral" },
+ },
+ ],
+ tools: [TRIAGE_TOOL],
+ tool_choice: { type: "tool", name: "classify_report" },
+ messages: [
+ {
+ role: "user",
+ content: buildUserMessage(input, ctx),
+ },
+ ],
+ });
+
+ const toolBlock = response.content.find(
+ (b): b is Anthropic.ToolUseBlock =>
+ b.type === "tool_use" && b.name === "classify_report"
+ );
+ if (!toolBlock) {
+ console.warn("[report-triage] classify_report tool not invoked");
+ return null;
+ }
+
+ return validateTriageOutput(toolBlock.input);
+ } catch (err) {
+ console.warn("[report-triage] Haiku call failed:", err);
+ return null;
+ }
+}
+
+function buildUserMessage(input: ReportInputParsed, ctx: TriageContext): string {
+ const role = ctx.reporter.kind === "authenticated" ? ctx.reporter.role : "anonymous";
+ const langHint = input.direction === "rtl" ? "Arabic likely" : "English likely";
+
+ return [
+ `Repo: ${ctx.repo}`,
+ `Page: ${input.pageUrl}`,
+ `Category: ${input.category}`,
+ `Reporter role: ${role}`,
+ `Viewport: ${input.viewport ?? "(unknown)"}`,
+ `Language hint: ${langHint}`,
+ "",
+ "--- Description ---",
+ input.description,
+ "",
+ "--- Repro Steps (optional) ---",
+ input.reproSteps || "(none provided)",
+ "",
+ "--- Expected (optional) ---",
+ input.expected || "(none provided)",
+ "",
+ "--- Actual (optional) ---",
+ input.actual || "(none provided)",
+ "",
+ "Classify this report. Output JSON via classify_report tool.",
+ ].join("\n");
+}
+
+/**
+ * Defensive validation of the tool response — even though the schema is enforced
+ * server-side, we still re-check for unknown values before trusting the result.
+ */
+function validateTriageOutput(raw: unknown): AITriageResult | null {
+ if (!raw || typeof raw !== "object") return null;
+ const r = raw as Record;
+
+ const classification = String(r.classification ?? "");
+ if (
+ !["bug", "feature", "question", "spam", "destructive", "duplicate"].includes(
+ classification
+ )
+ ) {
+ return null;
+ }
+
+ const severity = String(r.severity ?? "");
+ if (!["critical", "high", "medium", "low"].includes(severity)) return null;
+
+ const language = String(r.language ?? "");
+ if (!["ar", "en", "mixed", "other"].includes(language)) return null;
+
+ const qualityScore = Number(r.qualityScore);
+ const clarity = Number(r.clarity);
+ if (!Number.isFinite(qualityScore) || !Number.isFinite(clarity)) return null;
+
+ return {
+ classification: classification as AITriageResult["classification"],
+ severity: severity as AITriageResult["severity"],
+ qualityScore: Math.max(0, Math.min(100, Math.round(qualityScore))),
+ clarity: Math.max(0, Math.min(100, Math.round(clarity))),
+ hasRepro: Boolean(r.hasRepro),
+ hasExpected: Boolean(r.hasExpected),
+ destructiveSignals: Array.isArray(r.destructiveSignals)
+ ? r.destructiveSignals.map(String).slice(0, 10)
+ : [],
+ language: language as AITriageResult["language"],
+ rationale: String(r.rationale ?? "").slice(0, 400),
+ };
+}
diff --git a/src/lib/report/turnstile.ts b/src/lib/report/turnstile.ts
new file mode 100644
index 0000000..9a56023
--- /dev/null
+++ b/src/lib/report/turnstile.ts
@@ -0,0 +1,48 @@
+/**
+ * Cloudflare Turnstile verification.
+ *
+ * Required for anonymous reports (HF3). Authenticated users skip captcha —
+ * the auth is the trust signal.
+ *
+ * Returns true on a valid token, false otherwise. Never throws.
+ */
+
+const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
+
+export async function verifyTurnstile(token: string | undefined, ip: string): Promise {
+ const secret = process.env.TURNSTILE_SECRET_KEY;
+ if (!secret) {
+ // In development without a Turnstile secret, pass through. Real deployments
+ // must set the env var (otherwise HF3 would reject every anonymous report).
+ if (process.env.NODE_ENV !== "production") return true;
+ console.warn("[turnstile] TURNSTILE_SECRET_KEY not set in production");
+ return false;
+ }
+
+ if (!token) return false;
+
+ try {
+ const res = await fetch(VERIFY_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ secret,
+ response: token,
+ remoteip: ip,
+ }),
+ // Keep this fast — Turnstile is normally <200ms.
+ signal: AbortSignal.timeout(3_000),
+ });
+
+ if (!res.ok) {
+ console.warn("[turnstile] verify HTTP", res.status);
+ return false;
+ }
+
+ const data = (await res.json()) as { success?: boolean };
+ return data.success === true;
+ } catch (err) {
+ console.warn("[turnstile] verify failed:", err);
+ return false;
+ }
+}
diff --git a/src/lib/report/types.ts b/src/lib/report/types.ts
new file mode 100644
index 0000000..94f5d0a
--- /dev/null
+++ b/src/lib/report/types.ts
@@ -0,0 +1,157 @@
+/**
+ * Types for the report-an-issue credibility pipeline.
+ *
+ * Shared across hogwarts, mkan, kun. Each repo writes a thin adapter that
+ * satisfies the {@link ReportAdapter} contract — auth, rate-limit, history.
+ */
+
+export type ReportCategory =
+ | "visual"
+ | "broken"
+ | "data"
+ | "slow"
+ | "confusing"
+ | "auth"
+ | "i18n"
+ | "other";
+
+export type Severity = "low" | "medium" | "high" | "critical";
+
+export type Language = "ar" | "en" | "mixed" | "other";
+
+export type Classification =
+ | "bug"
+ | "feature"
+ | "question"
+ | "spam"
+ | "destructive"
+ | "duplicate";
+
+export type Bucket =
+ | "silent-reject"
+ | "low-confidence"
+ | "needs-human"
+ | "verified-report";
+
+/**
+ * Raw input from the client dialog. Validated by {@link reportSchema}.
+ */
+export interface ReportInput {
+ description: string;
+ pageUrl: string;
+ category: ReportCategory;
+ reproSteps?: string;
+ expected?: string;
+ actual?: string;
+ severityHint?: Severity;
+ viewport?: string; // "WxH"
+ direction?: "ltr" | "rtl";
+ browser?: string;
+ hasScreenshot: boolean;
+ captchaToken?: string;
+}
+
+/**
+ * Resolved reporter, supplied by the adapter from session + Prisma (or anonymous).
+ */
+export type ReporterContext =
+ | {
+ kind: "anonymous";
+ ipHash: string; // sha256(ip + tenant-salt), 16 hex chars
+ }
+ | {
+ kind: "authenticated";
+ userId: string;
+ role: string;
+ emailVerified: boolean;
+ accountAgeDays: number;
+ isSuspended: boolean;
+ ipHash: string;
+ priorAccepted?: number;
+ priorRejected?: number;
+ };
+
+/**
+ * Result of the AI triage call. Shape mirrors the classify_report tool schema.
+ */
+export interface AITriageResult {
+ classification: Classification;
+ severity: Severity;
+ qualityScore: number; // 0..100
+ clarity: number; // 0..100
+ hasRepro: boolean;
+ hasExpected: boolean;
+ destructiveSignals: string[];
+ language: Language;
+ rationale: string;
+}
+
+export type DuplicateMatch =
+ | { found: false }
+ | { found: true; issueNumber: number; similarity: number; existingScore?: number };
+
+export interface ScoringBreakdown {
+ R: number; // reputation 0..30
+ Q: number; // content quality 0..25
+ C: number; // context 0..10
+ A: number; // AI triage 0..35
+ P: number; // pattern / corroboration -10..+10
+}
+
+export interface ScoringResult {
+ score: number; // 0..100, clamped
+ breakdown: ScoringBreakdown;
+ bucket: Bucket;
+ labels: string[]; // labels to apply to the GitHub issue (or empty for silent-reject)
+}
+
+export type RejectReasonCode =
+ | "HF1_too_short"
+ | "HF2_too_long"
+ | "HF3_no_captcha"
+ | "HF4_suspended"
+ | "HF5_host_mismatch"
+ | "HF6_few_tokens"
+ | "HF7_gibberish"
+ | "HF8_rate_limited"
+ | "HF9_self_duplicate"
+ | "HF10_banned";
+
+export interface RejectReason {
+ code: RejectReasonCode;
+ detail: string;
+}
+
+/**
+ * Final result returned by the pipeline. UI always shows the same success toast
+ * to deny feedback to spammers (see plan §10 "symmetric success").
+ */
+export type PipelineResult =
+ | {
+ ok: true;
+ bucket: Bucket;
+ issueNumber?: number; // only present when an issue was created
+ score?: number;
+ }
+ | {
+ ok: false;
+ error: "rate_limited" | "internal" | "config";
+ };
+
+/**
+ * Event emitted by the pipeline for observability / future DB persistence.
+ */
+export interface PipelineEvent {
+ at: string; // ISO timestamp
+ repo: string;
+ outcome: Bucket | "silent-reject" | "duplicate-corroborated";
+ rejectReason?: RejectReasonCode;
+ score?: number;
+ classification?: Classification;
+ issueNumber?: number;
+ reporterKind: "anonymous" | "authenticated";
+ reporterRole?: string;
+ ipHash: string;
+ host: string;
+ path: string;
+}
diff --git a/tests/actions/report-issue.test.ts b/tests/actions/report-issue.test.ts
index 393bd47..a3b83ca 100644
--- a/tests/actions/report-issue.test.ts
+++ b/tests/actions/report-issue.test.ts
@@ -1,171 +1,116 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-
-vi.mock("@/lib/auth", () => ({
- auth: vi.fn(),
- canOverride: (session: { user?: { id?: string; role?: string } } | null | undefined, ownerId: string | null | undefined) =>
- (!!session?.user?.id && session.user.id === ownerId) ||
- session?.user?.role === "ADMIN" ||
- session?.user?.role === "SUPER_ADMIN",
- isAdminOrSuper: (session: { user?: { role?: string } } | null | undefined) =>
- session?.user?.role === "ADMIN" || session?.user?.role === "SUPER_ADMIN",
- isSuperAdmin: (session: { user?: { role?: string } } | null | undefined) =>
- session?.user?.role === "SUPER_ADMIN",
+/**
+ * Smoke tests for the report-issue server action.
+ *
+ * The action is now a thin wrapper around {@link runReportPipeline}. Heavy
+ * logic (scoring, hard filters, AI triage) is covered in the canonical
+ * `src/lib/report/__tests__` suite (mirrored from /Users/abdout/codebase).
+ *
+ * These tests only check that the action:
+ * 1. Forwards the input shape to the pipeline
+ * 2. Returns the expected discriminated-union result
+ * 3. Surfaces issueNumber only when bucket === "verified-report"
+ */
+
+import { describe, expect, it, vi, beforeEach } from "vitest";
+
+// Mock next/headers (the action calls this for IP resolution).
+vi.mock("next/headers", () => ({
+ headers: () => Promise.resolve(new Map([["x-forwarded-for", "203.0.113.42"]])),
}));
-// Mock global fetch
-const mockFetch = vi.fn();
-vi.stubGlobal("fetch", mockFetch);
+// Mock the pipeline so we can assert on the action's wiring.
+const mockRunPipeline = vi.fn();
+vi.mock("@/lib/report", () => ({
+ runReportPipeline: (...args: unknown[]) => mockRunPipeline(...args),
+}));
+vi.mock("@/lib/report/adapter", () => ({
+ mkanReportAdapter: { repo: "databayt/mkan" },
+}));
-import { auth } from "@/lib/auth";
import { reportIssue } from "@/lib/actions/report-issue";
-const mockAuth = vi.mocked(auth);
-
-const baseData = {
- description: "Button is broken on the search page",
- pageUrl: "https://mkan.databayt.org/en/search",
+const validInput = {
+ description:
+ "Button is broken on the search page. Clicking Search does nothing.",
+ pageUrl: "https://mkan.com.sa/en/search",
+ category: "broken" as const,
viewport: "1440x900",
- direction: "ltr",
- browser: "Chrome",
+ direction: "ltr" as const,
+ browser: "Mozilla/5.0 Chrome/120.0",
+ hasScreenshot: false as const,
};
-beforeEach(() => {
- vi.clearAllMocks();
- process.env.GITHUB_PERSONAL_ACCESS_TOKEN = "test-token";
- process.env.GITHUB_REPO = "databayt/mkan";
-});
-
-describe("reportIssue", () => {
- it("throws when token is not configured", async () => {
- delete process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
-
- await expect(reportIssue(baseData)).rejects.toThrow("not configured");
+describe("reportIssue (action wiring)", () => {
+ beforeEach(() => {
+ mockRunPipeline.mockReset();
});
- it("does not expose env var name in error", async () => {
- delete process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
-
- await expect(reportIssue(baseData)).rejects.not.toThrow(
- "GITHUB_PERSONAL_ACCESS_TOKEN"
- );
- });
-
- it("creates GitHub issue via fetch", async () => {
- mockAuth.mockResolvedValue({
- user: { id: "1", name: "Test User", email: "test@test.com" },
- } as never);
- mockFetch.mockResolvedValue({
+ it("returns { ok: true, issueNumber } when pipeline yields verified-report", async () => {
+ mockRunPipeline.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve({ html_url: "https://github.com/databayt/mkan/issues/1" }),
+ bucket: "verified-report",
+ issueNumber: 42,
+ score: 78,
});
- await reportIssue(baseData);
-
- expect(mockFetch).toHaveBeenCalledWith(
- expect.stringContaining("api.github.com/repos/databayt/mkan/issues"),
- expect.objectContaining({
- method: "POST",
- headers: expect.objectContaining({
- Authorization: "Bearer test-token",
- }),
- })
- );
- });
+ const result = await reportIssue(validInput);
- it("includes reporter info from session", async () => {
- mockAuth.mockResolvedValue({
- user: { id: "1", name: "John", email: "john@test.com" },
- } as never);
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ html_url: "url" }),
- });
-
- await reportIssue(baseData);
-
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
- expect(body.body).toContain("John");
- expect(body.body).toContain("john@test.com");
+ expect(result).toEqual({ ok: true, issueNumber: 42 });
+ expect(mockRunPipeline).toHaveBeenCalledTimes(1);
});
- it("handles anonymous reporter", async () => {
- mockAuth.mockRejectedValue(new Error("no session"));
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ html_url: "url" }),
- });
+ it("returns { ok: true } without issueNumber for silent-reject", async () => {
+ mockRunPipeline.mockResolvedValueOnce({ ok: true, bucket: "silent-reject" });
- await reportIssue(baseData);
+ const result = await reportIssue(validInput);
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
- expect(body.body).toContain("Anonymous");
+ expect(result).toEqual({ ok: true });
});
- it("truncates long descriptions in title", async () => {
- mockAuth.mockResolvedValue(null as never);
- mockFetch.mockResolvedValue({
+ it("returns { ok: true } without issueNumber for needs-human", async () => {
+ mockRunPipeline.mockResolvedValueOnce({
ok: true,
- json: () => Promise.resolve({ html_url: "url" }),
+ bucket: "needs-human",
+ issueNumber: 99,
+ score: 60,
});
- const longData = { ...baseData, description: "a".repeat(200) };
- await reportIssue(longData);
+ const result = await reportIssue(validInput);
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
- expect(body.title.length).toBeLessThanOrEqual(80);
+ expect(result).toEqual({ ok: true });
});
- it("retries without labels on 422", async () => {
- mockAuth.mockResolvedValue(null as never);
- mockFetch
- .mockResolvedValueOnce({ ok: false, status: 422 })
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve({ html_url: "url" }),
- });
-
- await reportIssue(baseData);
+ it("returns { ok: false } when pipeline reports failure", async () => {
+ mockRunPipeline.mockResolvedValueOnce({ ok: false, error: "internal" });
- // First call includes labels
- const firstPayload = JSON.parse(mockFetch.mock.calls[0][1].body);
- expect(firstPayload.labels).toEqual(["report"]);
+ const result = await reportIssue(validInput);
- // Second call (retry) has no labels
- const secondPayload = JSON.parse(mockFetch.mock.calls[1][1].body);
- expect(secondPayload.labels).toBeUndefined();
+ expect(result).toEqual({ ok: false });
});
- it("throws on non-422 API error", async () => {
- mockAuth.mockResolvedValue(null as never);
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
- mockFetch.mockResolvedValue({
- ok: false,
- status: 500,
- text: () => Promise.resolve("Internal Server Error"),
- });
+ it("forwards every input field to the pipeline", async () => {
+ mockRunPipeline.mockResolvedValueOnce({ ok: true, bucket: "silent-reject" });
- await expect(reportIssue(baseData)).rejects.toThrow(
- "GitHub API error: 500"
- );
- consoleSpy.mockRestore();
- });
-
- it("posts acknowledgment comment after issue creation", async () => {
- mockAuth.mockResolvedValue(null as never);
- const commentsUrl =
- "https://api.github.com/repos/databayt/mkan/issues/1/comments";
- mockFetch.mockResolvedValue({
- ok: true,
- json: () =>
- Promise.resolve({ html_url: "url", comments_url: commentsUrl }),
+ await reportIssue({
+ ...validInput,
+ reproSteps: "1. open page 2. click search",
+ expected: "results appear",
+ actual: "nothing",
+ severityHint: "high",
+ captchaToken: "tok-abc",
});
- await reportIssue(baseData);
-
- // Second fetch call should be the acknowledgment comment
- expect(mockFetch).toHaveBeenCalledTimes(2);
- expect(mockFetch.mock.calls[1][0]).toBe(commentsUrl);
- const commentBody = JSON.parse(mockFetch.mock.calls[1][1].body);
- expect(commentBody.body).toContain("queued for automated review");
+ const [input, , opts] = mockRunPipeline.mock.calls[0]!;
+ expect(input).toMatchObject({
+ description: validInput.description,
+ pageUrl: validInput.pageUrl,
+ category: "broken",
+ reproSteps: "1. open page 2. click search",
+ expected: "results appear",
+ actual: "nothing",
+ severityHint: "high",
+ captchaToken: "tok-abc",
+ });
+ expect(opts).toMatchObject({ ip: "203.0.113.42" });
});
});
diff --git a/tests/components/common/report-issue.test.tsx b/tests/components/common/report-issue.test.tsx
deleted file mode 100644
index a79be9b..0000000
--- a/tests/components/common/report-issue.test.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-// @vitest-environment jsdom
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
-import React from "react";
-
-// Mock next/navigation before importing the component
-vi.mock("next/navigation", () => ({
- usePathname: () => "/en/some-page",
- useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
-}));
-
-// Mock the server action
-vi.mock("@/lib/actions/report-issue", () => ({
- reportIssue: vi.fn().mockResolvedValue({ success: true }),
-}));
-
-// Mock radix-ui dialog to render content directly for testability
-vi.mock("@/components/ui/dialog", () => ({
- Dialog: ({ children, open }: any) => (
-
- {children}
-
- ),
- DialogContent: ({ children }: any) => (
- {children}
- ),
- DialogHeader: ({ children }: any) => {children}
,
- DialogTitle: ({ children }: any) => {children} ,
- DialogTrigger: ({ children }: any) => {children}
,
-}));
-
-import { ReportIssue } from "@/components/report-issue";
-
-describe("ReportIssue", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it("renders the trigger link text in English", () => {
- render( );
- // Text appears in both the trigger button and the dialog title
- const elements = screen.getAllByText("Report an issue");
- expect(elements.length).toBeGreaterThanOrEqual(1);
- // The first match is the trigger button
- expect(elements[0]).toBeInTheDocument();
- });
-
- it("renders the dialog title", () => {
- render( );
- // The dialog content is always rendered (mocked without portal)
- expect(screen.getByRole("heading", { name: "Report an issue" })).toBeInTheDocument();
- });
-
- it("renders a textarea with placeholder", () => {
- render( );
- const textarea = screen.getByPlaceholderText("Describe the issue...");
- expect(textarea).toBeInTheDocument();
- expect(textarea.tagName).toBe("TEXTAREA");
- });
-
- it("renders the submit button", () => {
- render( );
- const button = screen.getByRole("button", { name: "Submit" });
- expect(button).toBeInTheDocument();
- });
-
- it("disables submit button when description is empty", () => {
- render( );
- const button = screen.getByRole("button", { name: "Submit" });
- expect(button).toBeDisabled();
- });
-
- it("enables submit button when description has text", () => {
- render( );
- const textarea = screen.getByPlaceholderText("Describe the issue...");
- fireEvent.change(textarea, { target: { value: "Something is broken" } });
- const button = screen.getByRole("button", { name: "Submit" });
- expect(button).not.toBeDisabled();
- });
-});