fix: sidebar accordion + koja slug + support ticket LINQ crash
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m50s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Has been cancelled

Sidebar:
- All groups start collapsed on first load (v4 storage key resets old state)
- Opening one group closes all others (accordion)
- Navigating to a section opens only that section's group

Koja slug:
- SlugHelper: Persian->Latin transliteration, slug validation
- Registration accepts optional custom slug; auto-derives from cafe name
- Slug can be updated from dashboard Settings -> Profile
- Settings PATCH validates uniqueness (SLUG_TAKEN) and format (INVALID_SLUG)
- koja.meezi.ir/{slug} now redirects to /fa/cafe/{slug} (short URL support)

Bug fix:
- SupportTicketService: cafeId/status filters applied before Select() projection
  to fix EF "could not be translated" crash on the support tickets page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-31 22:28:25 +03:30
parent 38e3f6a5a2
commit cd1af30bbc
17 changed files with 401 additions and 58 deletions
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useRouter, Link } from "@/i18n/routing";
import { useSearchParams } from "next/navigation";
@@ -14,6 +14,46 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { OtpInput } from "@/components/ui/otp-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
/** Client-side Persian-to-Latin slugifier — mirrors SlugHelper.Slugify on the backend */
const PERSIAN_MAP: Record<string, string> = {
آ: "a", ا: "a", أ: "a", إ: "a",
ب: "b", پ: "p", ت: "t", ث: "s",
ج: "j", چ: "ch", ح: "h", خ: "kh",
د: "d", ذ: "z", ر: "r", ز: "z", ژ: "zh",
س: "s", ش: "sh", ص: "s", ض: "z",
ط: "t", ظ: "z", ع: "a", غ: "gh",
ف: "f", ق: "gh", ک: "k", ك: "k", گ: "g",
ل: "l", م: "m", ن: "n", و: "v",
ه: "h", ی: "i", ي: "i",
ئ: "y", ء: "", ة: "t", ى: "a", ؤ: "o",
"۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4",
"۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9",
};
function slugify(input: string): string {
let s = "";
for (const ch of input) {
if (ch in PERSIAN_MAP) {
s += PERSIAN_MAP[ch];
} else if (/[a-zA-Z0-9]/.test(ch)) {
s += ch.toLowerCase();
} else if (/[\s\-_]/.test(ch)) {
s += "-";
}
}
return s.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
}
function isValidSlug(slug: string): boolean {
if (!slug || slug.length < 2 || slug.length > 80) return false;
return /^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slug);
}
const KOJA_BASE =
typeof window !== "undefined" && window.location.hostname.includes("meezi.ir")
? "koja.meezi.ir"
: "koja.meezi.ir";
function RegisterForm() {
const t = useTranslations("auth");
const router = useRouter();
@@ -22,20 +62,31 @@ function RegisterForm() {
const [phone, setPhone] = useState(searchParams.get("phone") ?? "");
const [cafeName, setCafeName] = useState("");
const [slug, setSlug] = useState("");
const [slugEdited, setSlugEdited] = useState(false);
const [code, setCode] = useState("");
const [step, setStep] = useState<"info" | "otp">("info");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Auto-derive slug from café name unless the user has manually edited it
useEffect(() => {
if (!slugEdited) {
setSlug(slugify(cafeName));
}
}, [cafeName, slugEdited]);
const slugValid = isValidSlug(slug);
const errorMessage = (err: unknown) => {
if (err instanceof ApiClientError) {
switch (err.code) {
case "RATE_LIMITED": return t("rateLimited");
case "ALREADY_REGISTERED": return t("alreadyRegistered");
case "SMS_FAILED": return t("smsFailed");
case "INVALID_OTP": return t("invalidOtp");
case "RATE_LIMITED": return t("rateLimited");
case "ALREADY_REGISTERED": return t("alreadyRegistered");
case "SMS_FAILED": return t("smsFailed");
case "INVALID_OTP": return t("invalidOtp");
case "REGISTRATION_EXPIRED": return t("registrationExpired");
default: return err.message;
default: return err.message;
}
}
return err instanceof Error ? err.message : String(err);
@@ -45,7 +96,11 @@ function RegisterForm() {
setLoading(true);
setError(null);
try {
await apiPost("/api/auth/register", { phone, cafeName });
await apiPost("/api/auth/register", {
phone,
cafeName,
slug: slugValid ? slug : undefined,
});
setStep("otp");
} catch (e) {
setError(errorMessage(e));
@@ -94,6 +149,31 @@ function RegisterForm() {
required
/>
</LabeledField>
{/* Koja slug / profile URL */}
<LabeledField
label={t("kojaSlug")}
htmlFor="reg-slug"
hint={t("kojaSlugHint")}
>
<Input
id="reg-slug"
value={slug}
onChange={(e) => {
setSlugEdited(true);
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
}}
placeholder={t("kojaSlugPlaceholder")}
dir="ltr"
className="text-start font-mono text-sm"
/>
{slug && (
<p className={`mt-1 text-xs font-mono ${slugValid ? "text-muted-foreground" : "text-destructive"}`}>
{KOJA_BASE}/{slug}
</p>
)}
</LabeledField>
<LabeledField label={t("phone")} htmlFor="reg-phone">
<Input
id="reg-phone"