@@ -13,6 +13,8 @@ import {
1313 saveFeedDraftState ,
1414} from '../utils/feedSessionStorage' ;
1515import { normalizeUserUrl } from '../utils/url' ;
16+ import type { WorkflowErrorKind , WorkflowState } from './AppPanels' ;
17+ import type { CreatedFeedResult } from '../api/contracts' ;
1618
1719const EMPTY_FEED_ERRORS = { url : '' , form : '' } ;
1820const DEFAULT_FEED_CREATION = { enabled : true , access_token_required : true } ;
@@ -64,6 +66,102 @@ interface ConversionErrorWithMeta extends Error {
6466 manualRetryStrategy ?: string ;
6567}
6668
69+ function classifyWorkflowError ( message ?: string ) : WorkflowErrorKind | undefined {
70+ if ( ! message ) return undefined ;
71+
72+ const normalized = message . toLowerCase ( ) ;
73+ const mentionsToken = normalized . includes ( 'access token' ) || normalized . includes ( 'token' ) ;
74+
75+ if (
76+ normalized . includes ( 'unauthorized' ) ||
77+ normalized . includes ( 'invalid token' ) ||
78+ normalized . includes ( 'token rejected' ) ||
79+ normalized . includes ( 'authentication' ) ||
80+ ( normalized . includes ( 'forbidden' ) && mentionsToken )
81+ ) {
82+ return 'auth' ;
83+ }
84+
85+ if (
86+ normalized . includes ( 'still preparing' ) ||
87+ normalized . includes ( 'warming' ) ||
88+ normalized . includes ( 'not ready' ) ||
89+ normalized . includes ( 'readiness' ) ||
90+ normalized . includes ( 'preview unavailable' )
91+ ) {
92+ return 'readiness' ;
93+ }
94+
95+ if (
96+ normalized . includes ( 'network' ) ||
97+ normalized . includes ( 'fetch' ) ||
98+ normalized . includes ( 'timeout' ) ||
99+ normalized . includes ( 'offline' ) ||
100+ normalized . includes ( 'failed to fetch' )
101+ ) {
102+ return 'network' ;
103+ }
104+
105+ if (
106+ normalized . includes ( 'not allowed' ) ||
107+ normalized . includes ( 'disabled' ) ||
108+ ( normalized . includes ( 'forbidden' ) && ! mentionsToken ) ||
109+ normalized . includes ( 'access denied' )
110+ ) {
111+ return 'server' ;
112+ }
113+
114+ if (
115+ normalized . includes ( 'url' ) ||
116+ normalized . includes ( 'strategy' ) ||
117+ normalized . includes ( 'bad request' ) ||
118+ normalized . includes ( 'unsupported strategy' ) ||
119+ normalized . includes ( 'invalid response format' )
120+ ) {
121+ return 'input' ;
122+ }
123+
124+ return 'server' ;
125+ }
126+
127+ function deriveWorkflowState ( {
128+ resultReadinessPhase,
129+ conversionError,
130+ feedFieldErrors,
131+ isConverting,
132+ missingResultRoute,
133+ routeKind,
134+ tokenError,
135+ tokenStateError,
136+ metadataError,
137+ } : {
138+ resultReadinessPhase ?: CreatedFeedResult [ 'readinessPhase' ] ;
139+ conversionError ?: string ;
140+ feedFieldErrors : { url : string ; form : string } ;
141+ isConverting : boolean ;
142+ missingResultRoute : boolean ;
143+ routeKind : string ;
144+ tokenError : string ;
145+ tokenStateError ?: string ;
146+ metadataError ?: string ;
147+ } ) : WorkflowState {
148+ if ( missingResultRoute || tokenStateError || metadataError ) return 'failed' ;
149+ if ( routeKind === 'token' || classifyWorkflowError ( tokenError ) === 'auth' ) return 'token_required' ;
150+ if ( resultReadinessPhase === 'feed_ready' ) return 'ready' ;
151+ if ( resultReadinessPhase === 'link_created' || resultReadinessPhase === 'feed_not_ready_yet' ) {
152+ return 'warming' ;
153+ }
154+ if ( resultReadinessPhase === 'preview_unavailable' ) return 'failed' ;
155+ if ( isConverting ) return 'submitting' ;
156+ if ( feedFieldErrors . url || feedFieldErrors . form || classifyWorkflowError ( conversionError ) === 'input' ) {
157+ return 'validating' ;
158+ }
159+
160+ if ( conversionError ) return 'failed' ;
161+
162+ return 'idle' ;
163+ }
164+
67165function BrandLockup ( { onNavigateHome } : { onNavigateHome : ( ) => void } ) {
68166 return (
69167 < a
@@ -114,6 +212,7 @@ export function App() {
114212 const [ tokenError , setTokenError ] = useState ( '' ) ;
115213 const [ manualRetryStrategy , setManualRetryStrategy ] = useState ( '' ) ;
116214 const [ focusCreateComposerKey , setFocusCreateComposerKey ] = useState ( 0 ) ;
215+ const [ resultRouteRecoveryAttempted , setResultRouteRecoveryAttempted ] = useState ( false ) ;
117216 const autoSubmitUrlReference = useRef < string | undefined > ( route . prefillUrl ) ;
118217 const hasAutoSubmittedReference = useRef ( false ) ;
119218 const restoredFeedTokenReference = useRef < string | undefined > ( undefined ) ;
@@ -122,7 +221,25 @@ export function App() {
122221 const routeFeedToken = route . kind === 'result' ? route . feedToken : undefined ;
123222 const activeResult =
124223 route . kind === 'result' && result ?. feed . feed_token === route . feedToken ? result : undefined ;
125- const missingResultRoute = route . kind === 'result' && ! activeResult && ! isConverting ;
224+ const resultRouteRestorePending = route . kind === 'result' && ! activeResult && ! resultRouteRecoveryAttempted ;
225+ const missingResultRoute =
226+ route . kind === 'result' && resultRouteRecoveryAttempted && ! activeResult && ! isConverting ;
227+ const visibleErrorMessage =
228+ tokenError || conversionError || feedFieldErrors . url || feedFieldErrors . form || metadataError || tokenStateError ;
229+ const errorKind = classifyWorkflowError ( visibleErrorMessage ) ;
230+ const workflowState : WorkflowState = resultRouteRestorePending
231+ ? 'validating'
232+ : deriveWorkflowState ( {
233+ resultReadinessPhase : activeResult ?. readinessPhase ,
234+ conversionError,
235+ feedFieldErrors,
236+ isConverting,
237+ missingResultRoute,
238+ routeKind : route . kind ,
239+ tokenError,
240+ tokenStateError,
241+ metadataError,
242+ } ) ;
126243
127244 useEffect ( ( ) => {
128245 if ( ! route . prefillUrl ) return ;
@@ -267,16 +384,27 @@ export function App() {
267384 void attemptFeedCreation ( token ?? '' , manualRetryStrategy ) ;
268385 } ;
269386
387+ useEffect ( ( ) => {
388+ setResultRouteRecoveryAttempted ( false ) ;
389+ } , [ routeFeedToken ] ) ;
390+
270391 useEffect ( ( ) => {
271392 if ( ! routeFeedToken ) return ;
272393 if ( result ?. feed . feed_token === routeFeedToken ) return ;
273394 if ( restoredFeedTokenReference . current === routeFeedToken ) return ;
274395
275396 const recoveredResult = loadFeedResultState ( routeFeedToken ) ;
276397 restoredFeedTokenReference . current = routeFeedToken ;
398+ setResultRouteRecoveryAttempted ( true ) ;
277399 if ( recoveredResult ) restoreResult ( recoveredResult ) ;
278400 } , [ result ?. feed . feed_token , restoreResult , routeFeedToken ] ) ;
279401
402+ useEffect ( ( ) => {
403+ if ( ! routeFeedToken ) return ;
404+ if ( result ?. feed . feed_token !== routeFeedToken ) return ;
405+ setResultRouteRecoveryAttempted ( true ) ;
406+ } , [ result ?. feed . feed_token , routeFeedToken ] ) ;
407+
280408 useEffect ( ( ) => {
281409 const autoSubmitUrl = autoSubmitUrlReference . current ;
282410 if ( ! autoSubmitUrl || hasAutoSubmittedReference . current ) return ;
@@ -336,34 +464,44 @@ export function App() {
336464 </ section >
337465 ) }
338466
339- { activeResult ? (
467+ { resultRouteRestorePending ? (
468+ < section class = "ui-card ui-card--notice ui-card--roomy notice" data-state = "loading" aria-live = "polite" >
469+ < div class = "notice__spinner" aria-hidden = "true" />
470+ < div >
471+ < strong > Restoring saved result</ strong >
472+ < p > Checking local session state.</ p >
473+ </ div >
474+ </ section >
475+ ) : activeResult ? (
340476 < ResultDisplay
341477 result = { activeResult }
478+ workflowState = { workflowState }
342479 onCreateAnother = { handleCreateAnother }
343480 onRetryReadiness = { retryReadinessCheck }
344481 />
482+ ) : missingResultRoute ? (
483+ < section class = "ui-card ui-card--notice ui-card--padded notice" data-tone = "error" role = "alert" >
484+ < div class = "notice__title" > Saved result unavailable</ div >
485+ < p > We could not restore this feed result. Create a new feed link to continue.</ p >
486+ < div class = "notice__actions" >
487+ < button
488+ type = "button"
489+ class = "btn btn--primary"
490+ onClick = { ( ) => navigate ( { kind : 'create' , prefillUrl : feedFormData . url || undefined } ) }
491+ >
492+ Go to create
493+ </ button >
494+ </ div >
495+ </ section >
345496 ) : (
346497 < >
347- { missingResultRoute && (
348- < section class = "ui-card ui-card--notice ui-card--padded notice" data-tone = "error" role = "alert" >
349- < div class = "notice__title" > Saved result unavailable</ div >
350- < p > We could not restore this feed result. Create a new feed link to continue.</ p >
351- < div class = "notice__actions" >
352- < button
353- type = "button"
354- class = "btn btn--ghost"
355- onClick = { ( ) => navigate ( { kind : 'create' , prefillUrl : feedFormData . url || undefined } ) }
356- >
357- Go to create
358- </ button >
359- </ div >
360- </ section >
361- ) }
362498 < CreateFeedPanel
363499 focusComposerKey = { focusCreateComposerKey }
500+ workflowState = { workflowState }
364501 feedFormData = { { ...feedFormData , strategy : selectedStrategy } }
365502 feedFieldErrors = { feedFieldErrors }
366503 conversionError = { conversionError }
504+ errorKind = { errorKind }
367505 isConverting = { isConverting }
368506 submitDisabled = { submitDisabled }
369507 strategies = { strategies }
0 commit comments