feat(bot,wizard): smart message filtering, encrypted standalone wizard, and file persistence
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { track } from "../../lib/analytics";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||
import { ChevronRight, ChevronLeft, Check, ShieldCheck, AlertCircle, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { LineItem, Quote } from "../../lib/types";
|
||||
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
|
||||
@@ -39,11 +39,111 @@ import {
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
// ویزارد ۵ مرحلهای افزودن پیشنهاد جدید
|
||||
export function WizardPage() {
|
||||
// ---------- صفحه خروج امن (Safe Exit Screen) ----------
|
||||
function SafeExitScreen({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 p-8 text-center shadow-xl">
|
||||
<div className="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-5 bg-emerald-100 dark:bg-emerald-950/60 text-emerald-600 dark:text-emerald-400">
|
||||
<Check className="w-8 h-8 stroke-[2.5]" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-3">
|
||||
{title || "پیشنویس استعلام ذخیره گردید"}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6 leading-relaxed">
|
||||
{subtitle ||
|
||||
"اطلاعات با موفقیت ذخیره شد. اکنون میتوانید این برگه را ببندید و برای ادامه کار به ربات بله بازگردید."}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 rounded-xl transition shadow-xs text-sm"
|
||||
onClick={() => {
|
||||
window.close();
|
||||
}}
|
||||
>
|
||||
بستن این برگه
|
||||
</Button>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
(در صورت مسدود بودن بستن خودکار توسط مرورگر، لطفاً تب مرورگر را به صورت دستی ببندید)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- صفحه خطای توکن نامعتبر ----------
|
||||
function TokenInvalidScreen({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white dark:bg-slate-900 rounded-2xl border border-rose-200 dark:border-rose-900/50 p-8 text-center shadow-xl">
|
||||
<div className="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-5 bg-rose-100 dark:bg-rose-950/60 text-rose-600 dark:text-rose-400">
|
||||
<AlertCircle className="w-8 h-8 stroke-[2.5]" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-3">
|
||||
لینک نامعتبر یا منقضی شده
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6 leading-relaxed">
|
||||
{message ||
|
||||
"اعتبار این لینک بازبینی به پایان رسیده است. لطفاً از طریق ربات بله، مجدداً دکمه بازبینی را دریافت فرمایید."}
|
||||
</p>
|
||||
<Button
|
||||
variant="line"
|
||||
className="w-full rounded-xl py-2 text-sm"
|
||||
onClick={() => {
|
||||
window.close();
|
||||
}}
|
||||
>
|
||||
بستن برگه
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ویزارد ۵ مرحلهای افزودن پیشنهاد جدید و بازبینی پیشنویس
|
||||
export function WizardPage({ isStandalone = false }: { isStandalone?: boolean }) {
|
||||
useStoreData();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const token = params.get("token");
|
||||
const isStandaloneMode = isStandalone || Boolean(token);
|
||||
|
||||
const [exitInfo, setExitInfo] = useState<{
|
||||
show: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}>({ show: false });
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
// Setup History Trap when in standalone mode
|
||||
useEffect(() => {
|
||||
if (!isStandaloneMode) return;
|
||||
try {
|
||||
window.history.replaceState({ page: "review_root" }, "", window.location.href);
|
||||
window.history.pushState({ page: "review_active" }, "", window.location.href);
|
||||
} catch {}
|
||||
|
||||
const onPop = () => {
|
||||
setExitInfo({
|
||||
show: true,
|
||||
title: "بازبینی به پایان رسید",
|
||||
subtitle: "پیشنویس ذخیره گردید. اکنون میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||||
});
|
||||
try {
|
||||
window.history.pushState(null, "", window.location.href);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, [isStandaloneMode]);
|
||||
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
@@ -214,16 +314,25 @@ export function WizardPage() {
|
||||
}
|
||||
}, [params, suppliers, topics]);
|
||||
|
||||
// ---------- بارگذاری اولیه پیشنویس ربات بله (?draft=&draft_id=) ----------
|
||||
// ---------- بارگذاری اولیه پیشنویس ربات بله (?token= یا ?draft=) ----------
|
||||
useEffect(() => {
|
||||
const draftId = params.get("draft") || params.get("draft_id");
|
||||
if (!draftId || draftFetchedRef.current) return;
|
||||
if ((!token && !draftId) || draftFetchedRef.current) return;
|
||||
|
||||
draftFetchedRef.current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const draft = await api.drafts.get(draftId);
|
||||
if (!draft || !draft.payload) return;
|
||||
let draft: any = null;
|
||||
if (token) {
|
||||
const res = await api.drafts.verifyToken(token);
|
||||
draft = (res as any)?.data || res;
|
||||
} else if (draftId) {
|
||||
draft = await api.drafts.get(draftId);
|
||||
}
|
||||
if (!draft || !draft.payload) {
|
||||
if (token) setTokenError("پیشنویس معتبری یافت نشد.");
|
||||
return;
|
||||
}
|
||||
setLoadedDraftId(draft.id);
|
||||
lastRemoteUpdatedAtRef.current = draft.updated_at || null;
|
||||
|
||||
@@ -234,31 +343,57 @@ export function WizardPage() {
|
||||
toast.success("پیشنویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", {
|
||||
duration: 6000,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(`خطا در فراخوانی پیشنویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
||||
} catch (err: any) {
|
||||
if (token) {
|
||||
setTokenError(err?.message || "لینک بازبینی نامعتبر یا منقضی شده است.");
|
||||
} else {
|
||||
toast.error(`خطا در فراخوانی پیشنویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [params, applyDraftPayloadToForm]);
|
||||
}, [params, token, applyDraftPayloadToForm]);
|
||||
|
||||
// ---------- پولینگ دورهای برای دریافت تغییرات یا ثبت از سمت ربات بله ----------
|
||||
useEffect(() => {
|
||||
const draftId = params.get("draft") || params.get("draft_id");
|
||||
if (!draftId) return;
|
||||
if (!token && !draftId) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
||||
try {
|
||||
const draft = await api.drafts.get(draftId);
|
||||
let draft: any = null;
|
||||
if (token) {
|
||||
const res = await api.drafts.verifyToken(token);
|
||||
draft = (res as any)?.data || res;
|
||||
} else if (draftId) {
|
||||
draft = await api.drafts.get(draftId);
|
||||
}
|
||||
if (!draft) return;
|
||||
|
||||
if (draft.status === "committed") {
|
||||
isCommittedOrCancelledRef.current = true;
|
||||
if (isStandaloneMode) {
|
||||
setExitInfo({
|
||||
show: true,
|
||||
title: "این پیشنهاد ثبت نهایی شد ✅",
|
||||
subtitle: "استعلام با موفقیت در سیستم ثبت گردید. میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 });
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
if (draft.status === "cancelled") {
|
||||
isCommittedOrCancelledRef.current = true;
|
||||
if (isStandaloneMode) {
|
||||
setExitInfo({
|
||||
show: true,
|
||||
title: "پیشنویس لغو شد",
|
||||
subtitle: "این پیشنویس از طریق ربات بله لغو گردید. میتوانید این برگه را ببندید.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.error("این پیشنویس از طریق ربات بله لغو شد.", { duration: 6000 });
|
||||
navigate("/");
|
||||
return;
|
||||
@@ -279,12 +414,12 @@ export function WizardPage() {
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [params, navigate, applyDraftPayloadToForm]);
|
||||
}, [params, token, isStandaloneMode, navigate, applyDraftPayloadToForm]);
|
||||
|
||||
// ---------- همگامسازی خودکار تغییرات ویزارد به پیشنویس ربات (Debounced Auto-Save) ----------
|
||||
useEffect(() => {
|
||||
const draftId = params.get("draft") || params.get("draft_id");
|
||||
if (!draftId || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
||||
if ((!token && !draftId) || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
isSelfUpdatingRef.current = true;
|
||||
@@ -312,10 +447,19 @@ export function WizardPage() {
|
||||
expiry_date: expiryDate || null,
|
||||
};
|
||||
|
||||
await api.drafts.update(draftId, payloadToSave);
|
||||
const updated = await api.drafts.get(draftId);
|
||||
if (updated?.updated_at) {
|
||||
lastRemoteUpdatedAtRef.current = updated.updated_at;
|
||||
if (token) {
|
||||
await api.drafts.updateByToken(token, payloadToSave);
|
||||
const res = await api.drafts.verifyToken(token);
|
||||
const updated = (res as any)?.data || res;
|
||||
if (updated?.updated_at) {
|
||||
lastRemoteUpdatedAtRef.current = updated.updated_at;
|
||||
}
|
||||
} else if (draftId) {
|
||||
await api.drafts.update(draftId, payloadToSave);
|
||||
const updated = await api.drafts.get(draftId);
|
||||
if (updated?.updated_at) {
|
||||
lastRemoteUpdatedAtRef.current = updated.updated_at;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Auto-save to draft failed:", err);
|
||||
@@ -327,7 +471,7 @@ export function WizardPage() {
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params]);
|
||||
}, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params, token]);
|
||||
|
||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||
const completedSteps = useMemo(() => {
|
||||
@@ -607,8 +751,16 @@ export function WizardPage() {
|
||||
|
||||
try {
|
||||
const created = await createQuote.mutateAsync(quote);
|
||||
const token = params.get("token");
|
||||
const draftId = params.get("draft") || params.get("draft_id");
|
||||
if (draftId) {
|
||||
if (token) {
|
||||
isCommittedOrCancelledRef.current = true;
|
||||
try {
|
||||
await api.drafts.commitByToken(token, created.id);
|
||||
} catch (err) {
|
||||
console.warn("Could not commit draft via token:", err);
|
||||
}
|
||||
} else if (draftId) {
|
||||
isCommittedOrCancelledRef.current = true;
|
||||
try {
|
||||
await api.drafts.commit(draftId, created.id);
|
||||
@@ -627,6 +779,15 @@ export function WizardPage() {
|
||||
}
|
||||
toast.success("پیشنهاد ذخیره شد و در ربات بله تأیید گردید.");
|
||||
track("quote_submitted", { quote_id: created.id, items: lineItems.length });
|
||||
|
||||
if (isStandaloneMode) {
|
||||
setExitInfo({
|
||||
show: true,
|
||||
title: "پیشنهاد با موفقیت در سیستم ثبت گردید ✅",
|
||||
subtitle: `شناسه استعلام: #${created.id.slice(0, 8)} — تغییرات شما با موفقیت اعمال شد. اکنون میتوانید این برگه را ببندید و به ربات بله بازگردید.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
const target = resolveErrorTarget(e);
|
||||
@@ -635,15 +796,30 @@ export function WizardPage() {
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
const token = params.get("token");
|
||||
const draftId = params.get("draft") || params.get("draft_id");
|
||||
if (draftId) {
|
||||
isCommittedOrCancelledRef.current = true;
|
||||
isCommittedOrCancelledRef.current = true;
|
||||
if (token) {
|
||||
try {
|
||||
await api.drafts.cancelByToken(token);
|
||||
} catch (err) {
|
||||
console.warn("Could not cancel draft via token:", err);
|
||||
}
|
||||
} else if (draftId) {
|
||||
try {
|
||||
await api.drafts.cancel(draftId);
|
||||
} catch (err) {
|
||||
console.warn("Could not cancel draft:", err);
|
||||
}
|
||||
}
|
||||
if (isStandaloneMode) {
|
||||
setExitInfo({
|
||||
show: true,
|
||||
title: "پیشنویس لغو شد",
|
||||
subtitle: "این پیشنویس استعلام لغو گردید. میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
@@ -661,14 +837,55 @@ export function WizardPage() {
|
||||
void doSave();
|
||||
};
|
||||
|
||||
if (exitInfo.show) {
|
||||
return <SafeExitScreen title={exitInfo.title} subtitle={exitInfo.subtitle} />;
|
||||
}
|
||||
|
||||
if (tokenError) {
|
||||
return <TokenInvalidScreen message={tokenError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-10">
|
||||
<Header
|
||||
title="افزودن پیشنهاد جدید"
|
||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
/>
|
||||
{isStandaloneMode ? (
|
||||
<header className="sticky top-0 z-40 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-md border-b border-line px-4 py-3.5 shadow-xs">
|
||||
<div className="max-w-5xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-xl bg-blue-600 text-white flex items-center justify-center shadow-xs">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-base font-bold text-ink">
|
||||
بازبینی و تکمیل پیشنویس استعلام
|
||||
</h1>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 dark:bg-blue-950/80 dark:text-blue-300">
|
||||
ربات بله
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
محیط امن بازبینی — اطلاعات با ربات بله همگامسازی میشود
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted hover:text-rose-600 rounded-xl hover:bg-panel transition-colors"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span>انصراف و خروج</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
) : (
|
||||
<Header
|
||||
title="افزودن پیشنهاد جدید"
|
||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
/>
|
||||
)}
|
||||
<PageLayout>
|
||||
{loadedDraftId && (
|
||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-blue-200 bg-blue-50/80 p-3.5 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/40 dark:text-blue-200 shadow-xs">
|
||||
|
||||
Reference in New Issue
Block a user