Files
meezi/web/dashboard/src/components/discover/cafe-public-profile-panel.tsx
T
soroush.asadi 15def7ff1c
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m10s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 55s
CI/CD / Deploy · all services (push) Successful in 3m29s
feat: delete actions for warehouse/reservations/coupons/customers + Koja listing toggle
Delete (every manageable entity that only had "add" now has delete):
- Ingredients (warehouse): new DELETE /inventory/ingredients/{id} (soft-delete via
  the global DeletedAt filter — no FK trouble with recipes/movements) + NoOp stub +
  trash button in the materials cards.
- Reservations: new DELETE /reservations/{id} (soft-delete) + per-card delete button.
- Coupons & Customers: backend DELETE already existed; wired delete buttons in the UI.
- Shared ConfirmDialog component used by all delete flows (RTL-aware).
- Audit result: tables/branches/taxes/kitchen-stations/expenses/menu/terminals already
  had delete; HR has no "add" so no delete needed; shifts intentionally excluded
  (financial open/close records, not add-style entities).

Koja visibility:
- New Cafe.ShowOnKoja flag, default TRUE (DB default true so existing cafés stay
  listed). Discover query now filters IsVerified && !Deleted && ShowOnKoja.
- public-profile GET/PUT expose showOnKoja; dashboard public-profile panel has an
  on-by-default toggle that persists immediately. Platform IsVerified gate unchanged.
- EF migration AddCafeShowOnKoja (defaultValue: true).

Also: added the missing errors.generic i18n key (fa/en/ar) so useApiError's fallback
resolves instead of rendering the literal "errors.generic". 81 API tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:14:40 +03:30

370 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchCafePublicProfile,
removeGalleryPhoto,
updateCafePublicProfile,
uploadGalleryPhoto,
type CafeProfileEdit,
type UpdateCafeProfilePayload,
} from "@/lib/api/cafe-public-profile";
import type { WorkingHours } from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type Props = { cafeId: string };
type Tab = "info" | "gallery" | "hours" | "social";
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
export function CafePublicProfilePanel({ cafeId }: Props) {
const t = useTranslations("cafePublicProfile");
const qc = useQueryClient();
const [tab, setTab] = useState<Tab>("info");
const [saved, setSaved] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// ── Server state ──────────────────────────────────────────────────────────
const { data: profile, isLoading } = useQuery({
queryKey: ["cafe-public-profile", cafeId],
queryFn: () => fetchCafePublicProfile(cafeId),
});
// ── Local edit state ──────────────────────────────────────────────────────
const [description, setDescription] = useState<string>("");
const [instagram, setInstagram] = useState<string>("");
const [website, setWebsite] = useState<string>("");
const [hours, setHours] = useState<WorkingHours>(emptyHours());
const [showOnKoja, setShowOnKoja] = useState(true);
const [initialized, setInitialized] = useState(false);
// Populate local state once we get server data
if (profile && !initialized) {
setDescription(profile.description ?? "");
setInstagram(profile.instagramHandle ?? "");
setWebsite(profile.websiteUrl ?? "");
setHours(profile.workingHours ?? emptyHours());
setShowOnKoja(profile.showOnKoja ?? true);
setInitialized(true);
}
// ── Save info/social/hours ────────────────────────────────────────────────
const saveMutation = useMutation({
mutationFn: (override?: Partial<UpdateCafeProfilePayload>) =>
updateCafePublicProfile(cafeId, {
description,
instagramHandle: instagram || null,
websiteUrl: website || null,
workingHours: hours,
showOnKoja,
...override,
}),
onSuccess: (data) => {
qc.setQueryData(["cafe-public-profile", cafeId], data);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
// ── Gallery upload ────────────────────────────────────────────────────────
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadError(null);
try {
const gallery = await uploadGalleryPhoto(cafeId, file);
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
old ? { ...old, galleryUrls: gallery } : old
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : t("uploadFailed");
setUploadError(msg.includes("GALLERY_FULL") ? t("galleryFull") : t("uploadFailed"));
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const removeMutation = useMutation({
mutationFn: (url: string) => removeGalleryPhoto(cafeId, url),
onSuccess: (gallery) => {
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
old ? { ...old, galleryUrls: gallery } : old
);
},
});
// ── Hours helpers ─────────────────────────────────────────────────────────
const setDayField = (
day: keyof WorkingHours,
field: "isOpen" | "open" | "close",
value: string | boolean
) => {
setHours((prev) => ({
...prev,
[day]: {
...((prev[day] as object) ?? { isOpen: false, open: "", close: "" }),
[field]: value,
},
}));
};
if (isLoading) {
return <p className="text-sm text-muted-foreground p-4">{t("loading")}</p>;
}
const tabs: { id: Tab; label: string }[] = [
{ id: "info", label: t("tabs.info") },
{ id: "gallery", label: t("tabs.gallery") },
{ id: "hours", label: t("tabs.hours") },
{ id: "social", label: t("tabs.social") },
];
return (
<div className="space-y-4">
<div>
<h2 className="text-base font-semibold">{t("title")}</h2>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 rounded-xl border border-border/80 bg-muted/40 p-1">
{tabs.map((tb) => (
<button
key={tb.id}
type="button"
onClick={() => setTab(tb.id)}
className={cn(
"flex-1 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
tab === tb.id
? "bg-white shadow-sm text-[#0F6E56]"
: "text-muted-foreground hover:text-foreground"
)}
>
{tb.label}
</button>
))}
</div>
{/* ── Info tab ─────────────────────────────────────────────────────── */}
{tab === "info" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4">
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/40 px-3 py-2.5">
<span className="min-w-0">
<span className="block text-sm font-medium">{t("showOnKoja")}</span>
<span className="block text-xs text-muted-foreground">{t("showOnKojaHint")}</span>
</span>
<input
type="checkbox"
checked={showOnKoja}
onChange={(e) => {
const v = e.target.checked;
setShowOnKoja(v);
// Persist immediately (pass the new value to avoid stale state).
saveMutation.mutate({ showOnKoja: v });
}}
className="h-5 w-5 shrink-0 cursor-pointer accent-[#0F6E56]"
/>
</label>
<div className="space-y-1">
<Label>{t("description")}</Label>
<textarea
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
placeholder={t("descriptionPlaceholder")}
rows={5}
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
/>
</div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent>
</Card>
)}
{/* ── Gallery tab ──────────────────────────────────────────────────── */}
{tab === "gallery" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4">
<div>
<p className="text-sm font-medium">{t("gallery")}</p>
<p className="text-xs text-muted-foreground">{t("galleryHint")}</p>
</div>
{/* Existing photos */}
{profile?.galleryUrls && profile.galleryUrls.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{profile.galleryUrls.map((url) => {
const src = resolveMediaUrl(url);
return (
<div key={url} className="group relative">
<div
className="aspect-square rounded-lg bg-cover bg-center"
style={{ backgroundImage: src ? `url(${src})` : undefined }}
/>
<button
type="button"
onClick={() => removeMutation.mutate(url)}
disabled={removeMutation.isPending}
className="absolute end-1 top-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white opacity-0 transition group-hover:opacity-100 cursor-pointer"
>
{t("removePhoto")}
</button>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">هنوز عکسی آپلود نشده</p>
)}
{/* Upload button */}
<div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFileChange}
/>
<Button
variant="outline"
size="sm"
disabled={uploading || (profile?.galleryUrls?.length ?? 0) >= 8}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? t("uploading") : t("uploadPhoto")}
</Button>
{uploadError && (
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
)}
</div>
</CardContent>
</Card>
)}
{/* ── Working hours tab ─────────────────────────────────────────────── */}
{tab === "hours" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-3 p-4">
<p className="text-sm font-medium">{t("workingHours")}</p>
<div className="space-y-2">
{DAY_KEYS.map((day) => {
const d = hours[day] as { isOpen: boolean; open?: string; close?: string } | null;
return (
<div key={day} className="flex flex-wrap items-center gap-3 rounded-lg border border-border/60 px-3 py-2">
<span className="w-20 text-sm font-medium">{t(`days.${day}`)}</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={d?.isOpen ?? false}
onChange={(e) => setDayField(day, "isOpen", e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<span className="text-xs">{t("isOpen")}</span>
</label>
{d?.isOpen && (
<div className="flex items-center gap-2">
<input
type="time"
value={d.open ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "open", e.target.value)}
className="rounded border border-border/80 px-2 py-1 text-xs"
dir="ltr"
/>
<span className="text-xs text-muted-foreground"></span>
<input
type="time"
value={d.close ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "close", e.target.value)}
className="rounded border border-border/80 px-2 py-1 text-xs"
dir="ltr"
/>
</div>
)}
</div>
);
})}
</div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent>
</Card>
)}
{/* ── Social tab ───────────────────────────────────────────────────── */}
{tab === "social" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4">
<div className="space-y-1">
<Label>{t("instagram")}</Label>
<div className="flex items-center rounded-lg border border-border/80 px-3">
<span className="text-sm text-muted-foreground">@</span>
<Input
value={instagram}
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
placeholder={t("instagramPlaceholder")}
className="border-0 ps-1 shadow-none"
dir="ltr"
/>
</div>
</div>
<div className="space-y-1">
<Label>{t("website")}</Label>
<Input
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder={t("websitePlaceholder")}
dir="ltr"
/>
</div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent>
</Card>
)}
</div>
);
}
// ── Save button shared sub-component ─────────────────────────────────────────
function SaveButton({
saving,
saved,
onSave,
t,
}: {
saving: boolean;
saved: boolean;
onSave: () => void;
t: ReturnType<typeof useTranslations<"cafePublicProfile">>;
}) {
return (
<Button
onClick={onSave}
disabled={saving}
className="bg-[#0F6E56]"
>
{saving ? "…" : saved ? t("saved") : t("save")}
</Button>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function emptyHours(): WorkingHours {
const day = () => ({ isOpen: false, open: null, close: null });
return { sat: day(), sun: day(), mon: day(), tue: day(), wed: day(), thu: day(), fri: day() };
}