feat(recipes): B2 — Recipe detail view with hero, ingredients, steps #37
67
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
67
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
type RecipeHeroProps = {
|
||||
recipe: {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
tags: { id: string; name: string; tagType?: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
let { recipe }: RecipeHeroProps = $props();
|
||||
|
||||
let hasImage = $derived(!!recipe.heroImageUrl);
|
||||
|
||||
let pillBase = $derived(
|
||||
hasImage
|
||||
? 'bg-white/20 text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||
: 'bg-[var(--color-border)] text-[var(--color-text-muted)] text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||
);
|
||||
|
||||
let cookBtnClass = $derived(
|
||||
hasImage
|
||||
? 'font-sans text-[13px] font-medium tracking-[0.04em] bg-white text-[var(--green-dark)] rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||
: 'font-sans text-[13px] font-medium tracking-[0.04em] bg-[var(--green-dark)] text-white rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="recipe-hero"
|
||||
class="{hasImage
|
||||
? 'relative text-white'
|
||||
: 'bg-[var(--green-tint)] text-[var(--color-text)]'} p-[24px] md:p-[32px]"
|
||||
>
|
||||
{#if hasImage}
|
||||
<img
|
||||
src={recipe.heroImageUrl}
|
||||
alt={recipe.name}
|
||||
class="object-cover w-full h-full absolute inset-0"
|
||||
/>
|
||||
<div class="absolute inset-0" style="background: rgba(0,0,0,0.5);"></div>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<a href="/recipes" class="text-sm">← Zurück</a>
|
||||
|
||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-[400] mt-[8px]">
|
||||
{recipe.name}
|
||||
</h1>
|
||||
|
||||
<div class="flex gap-[8px] flex-wrap mt-[12px]">
|
||||
{#if recipe.cookTimeMin != null}
|
||||
<span class={pillBase}>{recipe.cookTimeMin} Min</span>
|
||||
{/if}
|
||||
{#if recipe.effort}
|
||||
<span class={pillBase}>{recipe.effort}</span>
|
||||
{/if}
|
||||
{#if recipe.serves != null}
|
||||
<span class={pillBase}>{recipe.serves} Port.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<a href="/cook/{recipe.id}" class={cookBtnClass}>Jetzt kochen</a>
|
||||
</div>
|
||||
</div>
|
||||
66
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
66
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import RecipeHero from './RecipeHero.svelte';
|
||||
|
||||
const baseRecipe = {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
tags: [] as { id: string; name: string; tagType?: string }[]
|
||||
};
|
||||
|
||||
describe('RecipeHero', () => {
|
||||
it('renders the recipe name', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders green-tint hero when no image', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const hero = document.querySelector('[data-testid="recipe-hero"]');
|
||||
expect(hero?.className).toContain('bg-[var(--green-tint)]');
|
||||
});
|
||||
|
||||
it('renders image when heroImageUrl is provided', () => {
|
||||
render(RecipeHero, {
|
||||
props: { recipe: { ...baseRecipe, heroImageUrl: '/uploads/pasta.jpg' } }
|
||||
});
|
||||
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
|
||||
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
|
||||
});
|
||||
|
||||
it('renders cook time pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/30 Min/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders effort pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/Easy/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders serves pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/4/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders back link to /recipes', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const backLink = screen.getByRole('link', { name: /zurück/i });
|
||||
expect(backLink).toHaveAttribute('href', '/recipes');
|
||||
});
|
||||
|
||||
it('renders cook now link to /cook/[id]', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
|
||||
expect(cookLink).toHaveAttribute('href', '/cook/r1');
|
||||
});
|
||||
|
||||
it('does not render img when no heroImageUrl', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user