Files
Nils-Johan Gynther ffe50e5151
Test Suite / test (24.15.0) (push) Has been cancelled
feat: add TypeScript definitions for next-auth session with accessToken and user details
2026-05-04 20:09:21 +02:00

447 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useState, useTransition } from 'react';
import Link from 'next/link';
import { parseErrorResponse } from '../../lib/error-handler';
import type {
Recipe,
RecipeInventoryPreview,
} from '../../features/inventory/types';
type Props = {
recipes: Recipe[];
};
function getStatusStyle(status: 'enough' | 'missing' | 'unit_mismatch') {
if (status === 'enough') {
return {
label: 'Räcker',
color: '#1f5f2c',
background: '#ecf8ee',
border: '#b9e0bf',
};
}
if (status === 'missing') {
return {
label: 'Saknas',
color: '#8b0000',
background: '#ffeaea',
border: '#f1b5b5',
};
}
return {
label: 'Enhetskonflikt',
color: '#8a4b00',
background: '#fff4e5',
border: '#f0cf9b',
};
}
function formatDate(value: string | null) {
if (!value) return null;
return new Date(value).toLocaleDateString('sv-SE');
}
function isWeightUnit(unit: string): boolean {
return ['kg', 'g', 'mg', 'ml', 'l'].includes(unit.trim().toLowerCase());
}
function isPieceUnit(unit: string): boolean {
return ['st', 'stycke'].includes(unit.trim().toLowerCase());
}
export default function RecipePreview({ recipes }: Props) {
const [selectedRecipeId, setSelectedRecipeId] = useState('');
const [preview, setPreview] = useState<RecipeInventoryPreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const selectAndLoad = (id: string) => {
setSelectedRecipeId(id);
setError(null);
setPreview(null);
startTransition(async () => {
try {
const res = await fetch(`/api/recipe-preview-proxy?id=${id}`, {
method: 'GET',
cache: 'no-store',
});
if (!res.ok) {
const errorMessage = await parseErrorResponse(res);
throw new Error(errorMessage);
}
const data: RecipeInventoryPreview = await res.json();
setPreview(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.';
setError(message);
}
});
};
const loadPreview = () => {
setError(null);
setPreview(null);
if (!selectedRecipeId) {
setError('Välj ett recept.');
return;
}
startTransition(async () => {
try {
const res = await fetch(`/api/recipe-preview-proxy?id=${selectedRecipeId}`, {
method: 'GET',
cache: 'no-store',
});
if (!res.ok) {
const errorMessage = await parseErrorResponse(res);
throw new Error(errorMessage);
}
const data: RecipeInventoryPreview = await res.json();
setPreview(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.';
setError(message);
}
});
};
const listedRecipes = recipes.slice(0, 10);
return (
<section style={{ display: 'grid', gap: '1rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '1rem', alignItems: 'start' }}>
<div
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.75rem',
}}
>
<h2 style={{ margin: 0 }}>Recept mot hemmavaror</h2>
<label>
Recept
<br />
<select
value={selectedRecipeId}
onChange={(e) => setSelectedRecipeId(e.target.value)}
style={{ width: '100%', padding: '0.5rem' }}
>
<option value="">Välj recept</option>
{recipes.map((recipe) => (
<option key={recipe.id} value={recipe.id}>
{recipe.name}
</option>
))}
</select>
</label>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button
type="button"
onClick={loadPreview}
disabled={isPending}
style={{ padding: '0.6rem 1rem' }}
>
{isPending ? 'Hämtar preview...' : 'Visa preview'}
</button>
{selectedRecipeId && (
<Link
href={`/recipes/${selectedRecipeId}/edit`}
style={{
padding: '0.6rem 1rem',
background: '#f0f0f0',
color: '#333',
border: '1px solid #ccc',
borderRadius: '4px',
textDecoration: 'none',
cursor: 'pointer',
display: 'inline-block',
}}
>
Redigera recept
</Link>
)}
</div>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
{/* Receptlista till höger */}
<div
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
minWidth: '180px',
maxWidth: '220px',
}}
>
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1rem' }}>Mina recept</h3>
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'grid', gap: '0.4rem' }}>
{listedRecipes.map((recipe) => (
<li key={recipe.id}>
<button
type="button"
disabled={isPending}
onClick={() => selectAndLoad(String(recipe.id))}
style={{
width: '100%',
textAlign: 'left',
padding: '0.4rem 0.6rem',
background: String(recipe.id) === selectedRecipeId ? '#e8f0fe' : 'transparent',
border: `1px solid ${String(recipe.id) === selectedRecipeId ? '#4285f4' : '#eee'}`,
borderRadius: '4px',
cursor: 'pointer',
fontWeight: String(recipe.id) === selectedRecipeId ? 600 : 400,
color: String(recipe.id) === selectedRecipeId ? '#1a56db' : '#333',
fontSize: '0.9rem',
}}
>
{recipe.name}
</button>
</li>
))}
</ul>
</div>
</div>
{(() => {
const selected = recipes.find((r) => String(r.id) === selectedRecipeId);
if (!selected?.instructions) return null;
return (
<article
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.5rem',
}}
>
<h3 style={{ margin: 0 }}>Instruktioner {selected.name}</h3>
<p style={{ margin: 0, whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{selected.instructions}
</p>
</article>
);
})()}
{preview && preview.summary.missingCount > 0 && (
<article
style={{
border: '1px solid #f1b5b5',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.5rem',
background: '#ffeaea',
}}
>
<h3 style={{ margin: 0, color: '#8b0000' }}>
Saknade ingredienser ({preview.summary.missingCount})
</h3>
<ul style={{ margin: 0, paddingLeft: '1.25rem', display: 'grid', gap: '0.25rem' }}>
{preview.ingredients
.filter((ing) => ing.status === 'missing')
.map((ing) => (
<li key={ing.ingredientId}>
<strong>{ing.productName}</strong> saknas{' '}
{ing.missingQuantity} {ing.requiredUnit}
</li>
))}
</ul>
</article>
)}
{preview ? (
<section style={{ display: 'grid', gap: '1rem' }}>
<article
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.5rem',
}}
>
<h3 style={{ margin: 0 }}>{preview.recipe.name}</h3>
{preview.recipe.description ? <div>{preview.recipe.description}</div> : null}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<span>Ingredienser: {preview.summary.totalIngredients}</span>
<span>Räcker: {preview.summary.enoughCount}</span>
<span>Saknas: {preview.summary.missingCount}</span>
<span>Enhetskonflikter: {preview.summary.unitMismatchCount}</span>
<strong>
{preview.summary.canCookExactly
? 'Kan lagas exakt'
: 'Kan inte lagas exakt ännu'}
</strong>
</div>
{preview.summary.unitMismatchCount > 0 && (
<div
style={{
padding: '0.75rem',
background: '#fff4e5',
border: '1px solid #f0cf9b',
borderRadius: '4px',
color: '#8a4b00',
fontSize: '0.9rem',
marginTop: '0.5rem',
}}
>
<strong> Enhetskonflikt!</strong> {preview.summary.unitMismatchCount} ingrediens
{preview.summary.unitMismatchCount !== 1 ? 'er har' : ' har'} olika enheter än vad som finns i hemmavaror.
<br />
<span style={{ fontSize: '0.85rem', marginTop: '0.25rem', display: 'block' }}>
T.ex. receptet säger "0.5 st" men du har lagrat "1.3 kg". Du kan antingen:
<ul style={{ margin: '0.5rem 0 0 1rem', paddingLeft: '1rem' }}>
<li>Redigera receptet för att matcha dina enheter</li>
<li>Lagra ingrediensen med samma enhet som receptet använder</li>
</ul>
</span>
</div>
)}
</article>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{preview.ingredients.map((ingredient) => {
const statusStyle = getStatusStyle(ingredient.status);
return (
<article
key={ingredient.ingredientId}
style={{
border: `1px solid ${statusStyle.border}`,
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.75rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<div>
<strong>{ingredient.productName}</strong>
<div>
Krävs: {ingredient.requiredQuantity} {ingredient.requiredUnit}
</div>
{ingredient.note ? <div>Notering: {ingredient.note}</div> : null}
</div>
<div
style={{
padding: '0.3rem 0.6rem',
borderRadius: '999px',
background: statusStyle.background,
color: statusStyle.color,
border: `1px solid ${statusStyle.border}`,
fontSize: '0.85rem',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{statusStyle.label}
</div>
</div>
<div style={{ display: 'grid', gap: '0.35rem' }}>
<div>
Tillgängligt i jämförbar enhet: {ingredient.availableQuantity}{' '}
{ingredient.availableUnit || ''}
</div>
{ingredient.status === 'missing' ? (
<div>
Saknas: {ingredient.missingQuantity} {ingredient.requiredUnit}
</div>
) : null}
{ingredient.status === 'unit_mismatch' ? (
<div
style={{
padding: '0.5rem',
background: '#fff4e5',
border: '1px solid #f0cf9b',
borderRadius: '4px',
fontSize: '0.9rem',
marginTop: '0.25rem',
}}
>
<strong>Enhetsproblem:</strong> Receptet kräver {ingredient.requiredUnit} men hemmavaror lagras i andra enheter.
Uppdatera receptet eller lagra ingrediensen med rätt enhet.
</div>
) : null}
</div>
{ingredient.matchingInventoryItems.length > 0 ? (
<div style={{ display: 'grid', gap: '0.35rem' }}>
<strong>Matchande inventory</strong>
{ingredient.matchingInventoryItems.map((item) => (
<div key={item.id}>
#{item.id}: {item.quantity} {item.unit}
{item.brand ? `, ${item.brand}` : ''}
{item.location ? `, ${item.location}` : ''}
{item.bestBeforeDate ? `, bäst före ${formatDate(item.bestBeforeDate)}` : ''}
</div>
))}
</div>
) : null}
{ingredient.otherInventoryItems && ingredient.otherInventoryItems.length > 0 ? (
<div style={{ display: 'grid', gap: '0.35rem' }}>
<strong>Andra enheter:</strong>
{ingredient.otherInventoryItems.map((item) => {
const weight = isWeightUnit(item.unit);
const pieces = isPieceUnit(ingredient.requiredUnit);
const pieces2 = isPieceUnit(item.unit);
const weight2 = isWeightUnit(ingredient.requiredUnit);
return (
<div key={item.id}>
#{item.id}: {item.quantity} {item.unit}
{item.canConvert ? (
<span> {(item.convertedQuantity || 0).toFixed(2)} {ingredient.requiredUnit}</span>
) : (
<span>
{weight && pieces
? ' (kan inte konvertera vikt till stycken)'
: pieces2 && weight2
? ' (kan inte konvertera stycken till vikt)'
: ' (kan inte konvertera)'}
</span>
)}
</div>
);
})}
</div>
) : null}
</article>
);
})}
</div>
</section>
) : null}
</section>
);
}