@@ -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" >