feat(frontend): add URL import error states
This commit is contained in:
parent
e1f5019006
commit
15ada9cb52
2
TODO.md
2
TODO.md
|
|
@ -34,7 +34,7 @@ MVP is functionally complete (core app + docs + tests).
|
||||||
- [x] Add “Import from URL” UI page/form in frontend
|
- [x] Add “Import from URL” UI page/form in frontend
|
||||||
- [x] Show parsed preview (title, ingredients, steps, source URL)
|
- [x] Show parsed preview (title, ingredients, steps, source URL)
|
||||||
- [x] Allow edit-before-save flow, then save to existing create recipe API
|
- [x] Allow edit-before-save flow, then save to existing create recipe API
|
||||||
- [ ] Add frontend error states (invalid URL, parse failure, timeout)
|
- [x] Add frontend error states (invalid URL, parse failure, timeout)
|
||||||
|
|
||||||
### Phase 3: Fallback Parsing + Hardening
|
### Phase 3: Fallback Parsing + Hardening
|
||||||
- [ ] Add heuristic fallback parser when Schema.org missing
|
- [ ] Add heuristic fallback parser when Schema.org missing
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { createRecipe, importRecipeFromUrl } from '../services/api';
|
import { createRecipe, importRecipeFromUrl } from '../services/api';
|
||||||
import type { RecipeDraft, UrlImportResult } from '../types/recipe';
|
import type { RecipeDraft, UrlImportResult } from '../types/recipe';
|
||||||
|
|
||||||
|
type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic';
|
||||||
|
|
||||||
function toTextBlock(items: string[]): string {
|
function toTextBlock(items: string[]): string {
|
||||||
return items.join('\n');
|
return items.join('\n');
|
||||||
}
|
}
|
||||||
|
|
@ -15,12 +17,36 @@ function toList(text: string): string[] {
|
||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getImportErrorDetails(message: string): { type: ImportErrorType; message: string } {
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
|
||||||
|
if (normalized.includes('valid url')) {
|
||||||
|
return {
|
||||||
|
type: 'invalid-url',
|
||||||
|
message: 'Please enter a valid URL (including https://).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('timed out')) {
|
||||||
|
return {
|
||||||
|
type: 'timeout',
|
||||||
|
message: 'The import request timed out. Please try again in a moment.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'generic',
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function ImportUrlPage() {
|
export function ImportUrlPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [errorType, setErrorType] = useState<ImportErrorType | null>(null);
|
||||||
const [result, setResult] = useState<UrlImportResult | null>(null);
|
const [result, setResult] = useState<UrlImportResult | null>(null);
|
||||||
|
|
||||||
const [draft, setDraft] = useState<RecipeDraft | null>(null);
|
const [draft, setDraft] = useState<RecipeDraft | null>(null);
|
||||||
|
|
@ -31,6 +57,7 @@ export function ImportUrlPage() {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setErrorType(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setDraftError(null);
|
setDraftError(null);
|
||||||
|
|
@ -39,9 +66,16 @@ export function ImportUrlPage() {
|
||||||
const imported = await importRecipeFromUrl(url);
|
const imported = await importRecipeFromUrl(url);
|
||||||
setResult(imported);
|
setResult(imported);
|
||||||
setDraft(imported.draft_recipe);
|
setDraft(imported.draft_recipe);
|
||||||
|
|
||||||
|
if (!imported.draft_recipe) {
|
||||||
|
setErrorType('parse-failure');
|
||||||
|
setError('We could fetch this page, but could not find recipe fields to import.');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
|
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
|
||||||
setError(message);
|
const details = getImportErrorDetails(message);
|
||||||
|
setErrorType(details.type);
|
||||||
|
setError(details.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -122,9 +156,21 @@ export function ImportUrlPage() {
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
<div
|
||||||
<p className="text-red-800">
|
className={`mt-4 border rounded-lg p-4 ${
|
||||||
<strong>Error:</strong> {error}
|
errorType === 'parse-failure'
|
||||||
|
? 'bg-amber-50 border-amber-200'
|
||||||
|
: 'bg-red-50 border-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className={errorType === 'parse-failure' ? 'text-amber-800' : 'text-red-800'}>
|
||||||
|
<strong>
|
||||||
|
{errorType === 'invalid-url' && 'Invalid URL:'}
|
||||||
|
{errorType === 'timeout' && 'Import timed out:'}
|
||||||
|
{errorType === 'parse-failure' && 'Parse failed:'}
|
||||||
|
{errorType === 'generic' && 'Error:'}
|
||||||
|
</strong>{' '}
|
||||||
|
{error}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -139,9 +185,7 @@ export function ImportUrlPage() {
|
||||||
|
|
||||||
{draft ? (
|
{draft ? (
|
||||||
<form onSubmit={handleSave} className="space-y-4">
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">Review and edit before saving.</p>
|
||||||
Review and edit before saving.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{draftError && (
|
{draftError && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-800 text-sm">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-800 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -253,11 +253,15 @@ export async function importRecipeFromUrl(url: string): Promise<UrlImportResult>
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result: ApiResponse<UrlImportResult> = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to import URL: ${response.statusText}`);
|
const errorMessage = typeof result.error === 'string'
|
||||||
|
? result.error
|
||||||
|
: JSON.stringify(result.error ?? 'Failed to import URL');
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: ApiResponse<UrlImportResult> = await response.json();
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.error || 'Failed to import URL');
|
throw new Error(result.error || 'Failed to import URL');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue