kiwi/frontend/src/components/RecipesView.vue

1548 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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.

<template>
<div class="recipes-view">
<!-- Tab bar: Find / Browse / Saved -->
<div role="tablist" aria-label="Recipe sections" class="tab-bar flex gap-xs mb-md">
<button
v-for="tab in tabs"
:key="tab.id"
:id="`tab-${tab.id}`"
role="tab"
:aria-selected="activeTab === tab.id"
:tabindex="activeTab === tab.id ? 0 : -1"
:class="['btn', 'tab-btn', activeTab === tab.id ? 'btn-primary' : 'btn-secondary']"
@click="activateTab(tab.id)"
@keydown="onTabKeydown"
>{{ tab.label }}</button>
</div>
<!-- Browse tab -->
<RecipeBrowserPanel
v-if="activeTab === 'browse'"
role="tabpanel"
aria-labelledby="tab-browse"
tabindex="0"
@open-recipe="openRecipeById"
/>
<!-- Saved tab -->
<SavedRecipesPanel
v-else-if="activeTab === 'saved'"
role="tabpanel"
aria-labelledby="tab-saved"
tabindex="0"
@open-recipe="openRecipeById"
@go-to-tab="(tab: string) => activateTab(tab as TabId)"
/>
<!-- Community tab -->
<CommunityFeedPanel
v-else-if="activeTab === 'community'"
role="tabpanel"
aria-labelledby="tab-community"
tabindex="0"
@plan-forked="onPlanForked"
/>
<!-- Build Your Own tab -->
<BuildYourOwnTab
v-else-if="activeTab === 'build'"
role="tabpanel"
aria-labelledby="tab-build"
tabindex="0"
/>
<!-- Find tab (existing search UI) -->
<div v-else ref="findPanelRef" role="tabpanel" aria-labelledby="tab-find" tabindex="0">
<!-- Controls Panel -->
<div class="card mb-controls">
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
<!-- Level Selector -->
<div class="form-group">
<label class="form-label">How far should we stretch?</label>
<div class="flex flex-wrap gap-sm">
<button
v-for="lvl in levels"
:key="lvl.value"
:class="['btn', 'btn-secondary', { active: recipesStore.level === lvl.value }]"
@click="recipesStore.level = lvl.value"
:aria-pressed="recipesStore.level === lvl.value"
:title="lvl.description"
>
{{ lvl.label }}
</button>
</div>
<p v-if="activeLevel" class="level-description text-sm text-secondary mt-xs">
{{ activeLevel.description }}
</p>
<!-- Orch budget pill — only visible when user has opted in (Settings toggle) -->
<OrchUsagePill class="mt-xs" />
</div>
<!-- Surprise Me confirmation -->
<div v-if="recipesStore.level === 4" class="status-badge status-info wildcard-warning">
<span id="wildcard-warning-desc">Wildcard mode lets the AI get creative with whatever you have on hand. Results might surprise you.</span>
<label class="flex-start gap-sm mt-xs">
<input type="checkbox" v-model="recipesStore.wildcardConfirmed" aria-describedby="wildcard-warning-desc" />
<span>I understand, go for it</span>
</label>
</div>
<!-- Hard Day Mode — surfaced as a top-level toggle button -->
<button
:class="['btn', 'hard-day-btn', recipesStore.hardDayMode ? 'hard-day-active' : 'btn-secondary']"
@click="recipesStore.hardDayMode = !recipesStore.hardDayMode"
:aria-pressed="recipesStore.hardDayMode"
>
🌿 Hard Day Mode
<span class="hard-day-sub">{{ recipesStore.hardDayMode ? 'on — quick &amp; simple only' : 'quick, simple recipes only' }}</span>
</button>
<p v-if="recipesStore.hardDayMode && recipesStore.result && recipesStore.result.suggestions.length > 0" class="text-muted text-sm mt-xs">
Tap "Find recipes" again to apply.
</p>
<!-- Dietary Preferences (collapsible) -->
<details class="collapsible form-group" @toggle="(e: Event) => dietaryOpen = (e.target as HTMLDetailsElement).open">
<summary class="collapsible-summary filter-summary" :aria-expanded="dietaryOpen">
Dietary preferences
<span v-if="dietaryActive" class="filter-active-dot" aria-label="filters active"></span>
</summary>
<div class="collapsible-body">
<!-- Dietary Constraints — preset checkboxes + other -->
<div class="form-group">
<label class="form-label">I eat</label>
<div class="preset-grid">
<button
v-for="opt in dietaryPresets"
:key="opt.value"
:class="['btn', 'btn-sm', 'preset-btn', recipesStore.constraints.includes(opt.value) ? 'preset-active' : '']"
@click="toggleConstraint(opt.value)"
:aria-pressed="recipesStore.constraints.includes(opt.value)"
>{{ opt.label }}</button>
</div>
<input
class="form-input mt-sm"
v-model="constraintInput"
placeholder="Other (e.g. low-sodium, raw, whole30)"
aria-describedby="constraint-hint"
@keydown="onConstraintKey"
@blur="commitConstraintInput"
autocomplete="off"
/>
<span id="constraint-hint" class="form-hint">Press Enter or comma to add.</span>
</div>
<!-- Allergies — Big 9 quick-select + other -->
<div class="form-group">
<label class="form-label">Allergies <span class="text-muted text-xs">(hard exclusions)</span></label>
<div class="preset-grid">
<button
v-for="allergen in allergenPresets"
:key="allergen.value"
:class="['btn', 'btn-sm', 'allergen-btn', recipesStore.allergies.includes(allergen.value) ? 'allergen-active' : '']"
@click="toggleAllergy(allergen.value)"
:aria-pressed="recipesStore.allergies.includes(allergen.value)"
>{{ allergen.label }}</button>
</div>
<!-- Active custom allergy tags -->
<div v-if="recipesStore.allergies.filter(a => !allergenPresets.map(p=>p.value).includes(a)).length > 0" class="tags-wrap flex flex-wrap gap-xs mt-xs">
<span
v-for="tag in recipesStore.allergies.filter(a => !allergenPresets.map(p=>p.value).includes(a))"
:key="tag"
class="tag-chip status-badge status-error"
>
{{ tag }}
<button class="chip-remove" @click="removeAllergy(tag)" :aria-label="'Remove allergy: ' + tag">×</button>
</span>
</div>
<input
class="form-input mt-sm"
v-model="allergyInput"
placeholder="Other allergen"
aria-describedby="allergy-hint"
@keydown="onAllergyKey"
@blur="commitAllergyInput"
autocomplete="off"
/>
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
</div>
<!-- Shopping Mode (temporary home — moves to Shopping tab in #71) -->
<div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
<input type="checkbox" v-model="recipesStore.shoppingMode" />
<span class="form-label" style="margin-bottom: 0;">Open to buying missing ingredients</span>
</label>
<p v-if="recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
All recipes shown regardless of missing ingredients. Affiliate links appear for anything you'd need to buy.
</p>
</div>
<!-- Max Missing — hidden in shopping mode -->
<div v-if="!recipesStore.shoppingMode" class="form-group">
<label class="form-label" for="max-missing">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
<input
id="max-missing"
type="number"
class="form-input"
min="0"
max="5"
placeholder="Leave blank for no limit"
:value="recipesStore.maxMissing ?? ''"
@input="onMaxMissingInput"
/>
</div>
</div>
</details>
<!-- Advanced Filters (collapsible) -->
<details class="collapsible form-group" @toggle="(e: Event) => advancedOpen = (e.target as HTMLDetailsElement).open">
<summary class="collapsible-summary filter-summary" :aria-expanded="advancedOpen">
Nutrition Filters{{ activeNutritionFilterCount > 0 ? ` (${activeNutritionFilterCount} active)` : '' }}
<span v-if="advancedActive" class="filter-active-dot" aria-label="filters active"></span>
</summary>
<div class="collapsible-body">
<!-- Nutrition Filters -->
<div class="form-group">
<label class="form-label">Nutrition limits <span class="text-muted text-xs">(per recipe, optional)</span></label>
<div class="nutrition-filters-grid mt-xs">
<div class="form-group">
<label class="form-label" for="filter-max-cal">Max Calories</label>
<input id="filter-max-cal" type="number" class="form-input" min="0" placeholder="e.g. 600"
:value="recipesStore.nutritionFilters.max_calories ?? ''"
@input="onNutritionInput('max_calories', $event)" />
</div>
<div class="form-group">
<label class="form-label" for="filter-max-sugar">Max Sugar (g)</label>
<input id="filter-max-sugar" type="number" class="form-input" min="0" placeholder="e.g. 10"
:value="recipesStore.nutritionFilters.max_sugar_g ?? ''"
@input="onNutritionInput('max_sugar_g', $event)" />
</div>
<div class="form-group">
<label class="form-label" for="filter-max-carbs">Max Carbs (g)</label>
<input id="filter-max-carbs" type="number" class="form-input" min="0" placeholder="e.g. 50"
:value="recipesStore.nutritionFilters.max_carbs_g ?? ''"
@input="onNutritionInput('max_carbs_g', $event)" />
</div>
<div class="form-group">
<label class="form-label" for="filter-max-sodium">Max Sodium (mg)</label>
<input id="filter-max-sodium" type="number" class="form-input" min="0" placeholder="e.g. 800"
:value="recipesStore.nutritionFilters.max_sodium_mg ?? ''"
@input="onNutritionInput('max_sodium_mg', $event)" />
</div>
</div>
<p class="text-xs text-muted mt-xs">
Recipes without nutrition data always appear.
</p>
</div>
<!-- Cuisine Style (Level 3+ only) -->
<div v-if="recipesStore.level >= 3" class="form-group">
<label class="form-label">Cuisine Style</label>
<div class="flex flex-wrap gap-xs">
<button
v-for="style in cuisineStyles"
:key="style.id"
:class="['btn', 'btn-secondary', 'btn-sm', { active: recipesStore.styleId === style.id }]"
@click="recipesStore.styleId = recipesStore.styleId === style.id ? null : style.id"
:aria-pressed="recipesStore.styleId === style.id"
>{{ style.label }}</button>
</div>
</div>
<!-- Category Filter (Level 12 only) -->
<div v-if="recipesStore.level <= 2" class="form-group">
<label class="form-label" for="adv-category">Category <span class="text-muted text-xs">(optional)</span></label>
<input
id="adv-category"
class="form-input"
v-model="categoryInput"
placeholder="e.g. Breakfast, Asian, Chicken, &lt; 30 Mins"
@blur="recipesStore.category = categoryInput.trim() || null"
@keydown.enter="recipesStore.category = categoryInput.trim() || null"
/>
</div>
</div>
</details>
<!-- Suggest Button -->
<div class="suggest-row">
<button
class="btn btn-primary btn-lg flex-1"
:disabled="recipesStore.loading || pantryItems.length === 0 || (recipesStore.level === 4 && !recipesStore.wildcardConfirmed)"
@click="handleSuggest"
>
<span v-if="recipesStore.loading && !isLoadingMore">
<span class="spinner spinner-sm inline-spinner"></span> Finding recipes…
</span>
<span v-else>Suggest Recipes</span>
</button>
<button
v-if="recipesStore.dismissedCount > 0"
class="btn btn-ghost btn-sm"
@click="recipesStore.clearDismissed()"
title="Show all dismissed recipes again"
>Clear dismissed ({{ recipesStore.dismissedCount }})</button>
</div>
<!-- Empty pantry nudge -->
<p v-if="pantryItems.length === 0 && !recipesStore.loading" class="text-sm text-muted text-center mt-xs">
Add items to your pantry first, then tap Suggest to find recipes.
</p>
</div>
<!-- Error -->
<div v-if="recipesStore.error" role="alert" class="status-badge status-error mb-md">
{{ recipesStore.error }}
</div>
<!-- Screen reader announcement for loading + results -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<span v-if="recipesStore.loading">Finding recipes…</span>
<span v-else-if="recipesStore.result">
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
</span>
</div>
<!-- Results -->
<div v-if="recipesStore.result" class="results-section fade-in" :aria-busy="recipesStore.loading">
<!-- Rate limit warning -->
<div
v-if="recipesStore.result.rate_limited"
class="status-badge status-warning rate-limit-banner mb-md"
>
Today's free suggestions are used up. Your limit resets tomorrow, or
<a href="#" class="link-inline" @click.prevent="$emit('upgrade')">upgrade for unlimited access</a>.
</div>
<!-- Element gaps -->
<div v-if="recipesStore.result.element_gaps.length > 0" class="card card-secondary mb-md">
<p class="text-sm font-semibold">These would expand your options:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="gap in recipesStore.result.element_gaps"
:key="gap"
class="status-badge status-info"
>{{ gap }}</span>
</div>
</div>
<!-- Filter bar -->
<div v-if="recipesStore.result.suggestions.length > 0" class="filter-bar mb-md">
<input
class="form-input filter-search"
v-model="filterText"
placeholder="Search recipes or ingredients"
aria-label="Filter recipes"
autocomplete="off"
/>
<div class="filter-chips">
<template v-if="availableLevels.length > 1">
<button
v-for="lvl in availableLevels"
:key="lvl"
:class="['filter-chip', { active: filterLevel === lvl }]"
:aria-pressed="filterLevel === lvl"
@click="filterLevel = filterLevel === lvl ? null : lvl"
>{{ levelLabels[lvl] ?? `Level ${lvl}` }}</button>
</template>
<button
:class="['filter-chip', { active: filterMissing === 0 }]"
:aria-pressed="filterMissing === 0"
@click="filterMissing = filterMissing === 0 ? null : 0"
>Can make now</button>
<button
:class="['filter-chip', { active: filterMissing === 2 }]"
:aria-pressed="filterMissing === 2"
@click="filterMissing = filterMissing === 2 ? null : 2"
>≤2 missing</button>
<button
v-if="hasActiveFilters"
class="filter-chip filter-chip-clear"
aria-label="Clear all recipe filters"
@click="clearFilters"
><span aria-hidden="true">✕</span> Clear</button>
</div>
</div>
<!-- No suggestions -->
<div
v-if="filteredSuggestions.length === 0"
class="card text-center text-muted"
>
<template v-if="hasActiveFilters">
<p>No recipes match your filters.</p>
<button class="btn btn-ghost btn-sm mt-xs" @click="clearFilters">Clear filters</button>
</template>
<p v-else>We didn't find matches at this level. Try <button class="btn btn-ghost btn-sm" @click="recipesStore.level = 1; handleSuggest()">Level 1 — Use What I Have</button> or adjust your filters.</p>
</div>
<!-- Recipe Cards -->
<div class="grid-auto mb-md">
<div
v-for="recipe in filteredSuggestions"
:key="recipe.id"
class="card slide-up"
>
<!-- Header row -->
<div class="flex-between mb-sm">
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
<div class="flex flex-wrap gap-xs" style="align-items:center">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
<button
v-if="recipe.id"
:class="['btn-icon', 'btn-bookmark', { active: recipesStore.isBookmarked(recipe.id) }]"
@click="recipesStore.toggleBookmark(recipe)"
:aria-label="recipesStore.isBookmarked(recipe.id) ? 'Remove bookmark: ' + recipe.title : 'Bookmark: ' + recipe.title"
>{{ recipesStore.isBookmarked(recipe.id) ? '★' : '☆' }}</button>
<button
v-if="recipe.id"
class="btn-icon btn-dismiss"
@click="recipesStore.dismiss(recipe.id)"
:aria-label="'Hide recipe: ' + recipe.title"
>✕</button>
</div>
</div>
<!-- Notes -->
<p v-if="recipe.notes" class="text-sm text-secondary mb-sm">{{ recipe.notes }}</p>
<!-- Matched ingredients (what you already have) -->
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-section mb-sm">
<p class="text-sm font-semibold ingredient-label ingredient-label-have">From your pantry:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="ing in recipe.matched_ingredients"
:key="ing"
class="ingredient-chip ingredient-chip-have"
>{{ ing }}</span>
</div>
</div>
<!-- Nutrition chips -->
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm">
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">
<span aria-hidden="true">🔥</span><span class="sr-only">Calories:</span> {{ Math.round(recipe.nutrition.calories) }} kcal
</span>
<span v-if="recipe.nutrition.fat_g != null" class="nutrition-chip">
<span aria-hidden="true">🧈</span><span class="sr-only">Fat:</span> {{ recipe.nutrition.fat_g.toFixed(1) }}g fat
</span>
<span v-if="recipe.nutrition.protein_g != null" class="nutrition-chip">
<span aria-hidden="true">💪</span><span class="sr-only">Protein:</span> {{ recipe.nutrition.protein_g.toFixed(1) }}g protein
</span>
<span v-if="recipe.nutrition.carbs_g != null" class="nutrition-chip">
<span aria-hidden="true">🌾</span><span class="sr-only">Carbs:</span> {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs
</span>
<span v-if="recipe.nutrition.fiber_g != null" class="nutrition-chip">
<span aria-hidden="true">🌿</span><span class="sr-only">Fiber:</span> {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber
</span>
<span v-if="recipe.nutrition.sugar_g != null" class="nutrition-chip nutrition-chip-sugar">
<span aria-hidden="true">🍬</span><span class="sr-only">Sugar:</span> {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar
</span>
<span v-if="recipe.nutrition.sodium_mg != null" class="nutrition-chip">
<span aria-hidden="true">🧂</span><span class="sr-only">Sodium:</span> {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium
</span>
<span v-if="recipe.nutrition.servings != null" class="nutrition-chip nutrition-chip-servings">
<span aria-hidden="true">🍽️</span><span class="sr-only">Servings:</span> {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
</span>
<span v-if="recipe.nutrition.estimated" class="nutrition-chip nutrition-chip-estimated" title="Estimated from ingredient profiles">
~ estimated
</span>
</div>
<!-- Missing ingredients -->
<div v-if="recipe.missing_ingredients.length > 0" class="mb-sm">
<p class="text-sm font-semibold text-secondary">To complete this recipe:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="ing in recipe.missing_ingredients"
:key="ing"
class="status-badge status-info"
>{{ ing }}</span>
</div>
</div>
<!-- Grocery links for this recipe's missing ingredients -->
<div v-if="groceryLinksForRecipe(recipe).length > 0" class="mb-sm">
<p class="text-sm font-semibold">Buy online:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<a
v-for="link in groceryLinksForRecipe(recipe)"
:key="link.ingredient + link.retailer"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="grocery-link status-badge status-info"
title="This is an affiliate link"
>
{{ link.ingredient }} @ {{ link.retailer }} ↗
</a>
</div>
</div>
<!-- Prep notes -->
<div v-if="recipe.prep_notes && recipe.prep_notes.length > 0" class="prep-notes mb-sm">
<p class="text-sm font-semibold">Before you start:</p>
<ul class="prep-notes-list mt-xs">
<li v-for="note in recipe.prep_notes" :key="note" class="text-sm prep-note-item">
{{ note }}
</li>
</ul>
</div>
<!-- Directions — always visible; this is the content people came for -->
<div v-if="recipe.directions.length > 0" class="directions-section">
<p class="text-sm font-semibold directions-label">Steps</p>
<ol class="directions-list mt-xs">
<li v-for="(step, idx) in recipe.directions" :key="idx" class="text-sm direction-step">
{{ step }}
</li>
</ol>
</div>
<!-- Swap candidates collapsible — shown after directions per M8 -->
<details
v-if="recipe.swap_candidates.length > 0"
class="collapsible mb-sm"
@toggle="(e: Event) => toggleSwapOpen(recipe.id, (e.target as HTMLDetailsElement).open)"
>
<summary class="text-sm font-semibold collapsible-summary" :aria-expanded="openSwapIds.has(recipe.id)">
Possible swaps ({{ recipe.swap_candidates.length }})
</summary>
<div class="card-secondary mt-xs">
<div
v-for="swap in recipe.swap_candidates"
:key="swap.original_name + swap.substitute_name"
class="swap-row text-sm"
>
<span class="font-semibold">{{ swap.original_name }}</span>
<span class="text-muted"> → </span>
<span class="font-semibold">{{ swap.substitute_name }}</span>
<span v-if="swap.constraint_label" class="status-badge status-info ml-xs">{{ swap.constraint_label }}</span>
<p v-if="swap.explanation" class="text-muted mt-xs">{{ swap.explanation }}</p>
</div>
</div>
</details>
<!-- Primary action: open detail panel -->
<div class="card-actions">
<button class="btn btn-primary btn-make" @click="openRecipe(recipe)">
Make this
</button>
</div>
</div>
</div>
<!-- Load More -->
<div v-if="recipesStore.result.suggestions.length > 0" class="load-more-row">
<button
class="btn btn-secondary"
:disabled="recipesStore.loading"
@click="handleLoadMore"
>
<span v-if="recipesStore.loading && isLoadingMore">
<span class="spinner spinner-sm inline-spinner"></span> Loading…
</span>
<span v-else>Load more recipes</span>
</button>
</div>
<!-- Soft Build Your Own nudge when corpus results are sparse -->
<div
v-if="!recipesStore.loading && filteredSuggestions.length <= 2 && recipesStore.result"
class="byo-nudge text-sm text-secondary mt-md"
>
Not finding what you want?
<button class="btn-link" @click="activeTab = 'build'">Try Build Your Own</button>
to make something from scratch.
</div>
</div>
<!-- Recipe detail panel — mounts as a full-screen overlay -->
<RecipeDetailPanel
v-if="selectedRecipe"
:recipe="selectedRecipe"
:grocery-links="selectedGroceryLinks"
@close="selectedRecipe = null"
@cooked="(recipe) => { onCooked(recipe); selectedRecipe = null }"
/>
<!-- Empty state when no results yet and pantry has items -->
<div
v-if="!recipesStore.result && !recipesStore.loading && pantryItems.length > 0"
class="card text-center text-muted"
>
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" style="width:40px;height:40px;color:var(--color-text-muted);margin-bottom:var(--spacing-sm)">
<path d="M12 8c0 0 4-4 12-4s12 4 12 4v8H12V8z"/>
<path d="M10 16h28v4l-2 20H12L10 20v-4z"/>
<line x1="20" y1="24" x2="28" y2="24"/>
</svg>
<p class="mt-xs">Tap "Suggest Recipes" to find recipes using your pantry items.</p>
</div>
</div><!-- end Find tab -->
<!-- Recipe load error — announced to screen readers via aria-live -->
<div v-if="browserRecipeError" role="alert" class="status-badge status-error mb-sm">
{{ browserRecipeError }}
</div>
<!-- Detail panel for browser/saved recipe lookups -->
<RecipeDetailPanel
v-if="browserSelectedRecipe"
:recipe="browserSelectedRecipe"
:grocery-links="[]"
@close="browserSelectedRecipe = null"
@cooked="browserSelectedRecipe = null"
/>
<!-- Undo toast for "I cooked this" dismiss -->
<div
v-if="lastCookedRecipe"
class="undo-toast"
role="status"
aria-live="polite"
>
Dismissed from suggestions.
<button class="btn-link" @click="undoCooked">Undo</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRecipesStore } from '../stores/recipes'
import { useInventoryStore } from '../stores/inventory'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import RecipeDetailPanel from './RecipeDetailPanel.vue'
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
import SavedRecipesPanel from './SavedRecipesPanel.vue'
import CommunityFeedPanel from './CommunityFeedPanel.vue'
import BuildYourOwnTab from './BuildYourOwnTab.vue'
import OrchUsagePill from './OrchUsagePill.vue'
import type { ForkResult } from '../stores/community'
import type { RecipeSuggestion, GroceryLink } from '../services/api'
import { recipesAPI } from '../services/api'
const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore()
// Tab state
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
const tabs: Array<{ id: TabId; label: string }> = [
{ id: 'saved', label: 'Saved' },
{ id: 'build', label: 'Build Your Own' },
{ id: 'community', label: 'Community' },
{ id: 'find', label: 'Find' },
{ id: 'browse', label: 'Browse' },
]
const activeTab = ref<TabId>('saved')
const savedStore = useSavedRecipesStore()
// Template ref for the Find-tab panel div (used for focus management on tab switch)
const findPanelRef = ref<HTMLElement | null>(null)
function onTabKeydown(e: KeyboardEvent) {
const tabIds: TabId[] = ['saved', 'build', 'community', 'find', 'browse']
const current = tabIds.indexOf(activeTab.value)
if (e.key === 'ArrowRight') {
e.preventDefault()
activateTab(tabIds[(current + 1) % tabIds.length]!)
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
activateTab(tabIds[(current - 1 + tabIds.length) % tabIds.length]!)
}
}
async function activateTab(tab: TabId) {
activeTab.value = tab
await nextTick()
// Move focus to the newly active panel so keyboard users don't have to Tab
// through the entire tab bar again to reach the panel content.
// findPanelRef handles the Find tab (a plain div); other tabs are child
// components so we locate their panel via querySelector.
if (tab === 'find' && findPanelRef.value) {
findPanelRef.value.focus()
} else {
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
panel?.focus()
}
}
// Community tab: navigate to Find tab after a plan fork (full plan view deferred to Task 9)
function onPlanForked(_payload: ForkResult) {
activeTab.value = 'find'
}
// Browser/saved tab recipe detail panel (fetches full recipe from API)
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)
const browserRecipeError = ref<string | null>(null)
async function openRecipeById(recipeId: number) {
browserRecipeError.value = null
try {
browserSelectedRecipe.value = await recipesAPI.getRecipe(recipeId)
} catch {
browserRecipeError.value = 'Could not load this recipe. Please try again.'
}
}
// Collapsible panel open state — kept in sync with DOM toggle event so
// :aria-expanded on <summary> reflects the actual open/closed state
const dietaryOpen = ref(false)
const advancedOpen = ref(false)
// Per-recipe swap section open tracking (Set of recipe IDs whose swap <details> are open)
// Uses reassignment instead of .add()/.delete() so Vue's ref() detects the change
const openSwapIds = ref<Set<number>>(new Set())
function toggleSwapOpen(recipeId: number, isOpen: boolean) {
const next = new Set(openSwapIds.value)
isOpen ? next.add(recipeId) : next.delete(recipeId)
openSwapIds.value = next
}
// Human-readable level labels for filter chips (avoids "Lv1" etc. for screen readers)
const levelLabels: Record<number, string> = {
1: 'Use What I Have',
2: 'Allow Swaps',
3: 'Get Creative',
4: 'Surprise Me',
}
// Local input state for tags
const constraintInput = ref('')
const allergyInput = ref('')
const categoryInput = ref('')
const isLoadingMore = ref(false)
// Recipe detail panel (Find tab)
const selectedRecipe = ref<RecipeSuggestion | null>(null)
// Filter state (#21)
const filterText = ref('')
const filterLevel = ref<number | null>(null)
const filterMissing = ref<number | null>(null)
const availableLevels = computed(() => {
if (!recipesStore.result) return []
return [...new Set(recipesStore.result.suggestions.map((r) => r.level))].sort()
})
const filteredSuggestions = computed(() => {
if (!recipesStore.result) return []
let items = recipesStore.result.suggestions
const q = filterText.value.trim().toLowerCase()
if (q) {
items = items.filter((r) =>
r.title.toLowerCase().includes(q) ||
r.matched_ingredients.some((i) => i.toLowerCase().includes(q)) ||
r.missing_ingredients.some((i) => i.toLowerCase().includes(q))
)
}
if (filterLevel.value !== null) {
items = items.filter((r) => r.level === filterLevel.value)
}
if (filterMissing.value !== null) {
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
}
return items
})
const hasActiveFilters = computed(
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null
)
function clearFilters() {
filterText.value = ''
filterLevel.value = null
filterMissing.value = null
}
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
if (!selectedRecipe.value || !recipesStore.result) return []
const missing = new Set(selectedRecipe.value.missing_ingredients.map((s) => s.toLowerCase()))
return recipesStore.result.grocery_links.filter((l) => missing.has(l.ingredient.toLowerCase()))
})
function openRecipe(recipe: RecipeSuggestion) {
selectedRecipe.value = recipe
}
const lastCookedRecipe = ref<{ id: number; title: string } | null>(null)
let undoTimer: ReturnType<typeof setTimeout> | null = null
function onCooked(recipe: RecipeSuggestion) {
recipesStore.logCook(recipe.id, recipe.title)
recipesStore.dismiss(recipe.id)
lastCookedRecipe.value = { id: recipe.id, title: recipe.title }
if (undoTimer) clearTimeout(undoTimer)
undoTimer = setTimeout(() => {
lastCookedRecipe.value = null
}, 12000)
}
function undoCooked() {
if (!lastCookedRecipe.value) return
recipesStore.undismiss(lastCookedRecipe.value.id)
if (undoTimer) clearTimeout(undoTimer)
lastCookedRecipe.value = null
}
onUnmounted(() => { if (undoTimer) clearTimeout(undoTimer) })
const levels = [
{ value: 1, label: 'Use What I Have', description: 'Finds recipes you can make right now using exactly what\'s in your pantry.' },
{ value: 2, label: 'Allow Swaps', description: 'Same as above, plus recipes where one or two ingredients can be substituted.' },
{ value: 3, label: 'Get Creative', description: 'AI builds recipes in your chosen cuisine style from what you have. Requires paid tier.' },
{ value: 4, label: 'Surprise Me 🎲', description: 'Fully AI-generated — open-ended and occasionally unexpected. Requires paid tier.' },
]
const dietaryPresets = [
{ label: 'Vegetarian', value: 'vegetarian' },
{ label: 'Vegan', value: 'vegan' },
{ label: 'Gluten-free', value: 'gluten-free' },
{ label: 'Dairy-free', value: 'dairy-free' },
{ label: 'Keto', value: 'keto' },
{ label: 'Low-carb', value: 'low-carb' },
{ label: 'Halal', value: 'halal' },
{ label: 'Kosher', value: 'kosher' },
]
const allergenPresets = [
{ label: 'Peanuts', value: 'peanuts' },
{ label: 'Tree nuts', value: 'tree nuts' },
{ label: 'Shellfish', value: 'shellfish' },
{ label: 'Fish', value: 'fish' },
{ label: 'Milk', value: 'milk' },
{ label: 'Eggs', value: 'eggs' },
{ label: 'Wheat', value: 'wheat' },
{ label: 'Soy', value: 'soy' },
{ label: 'Sesame', value: 'sesame' },
]
function toggleConstraint(value: string) {
if (recipesStore.constraints.includes(value)) {
recipesStore.constraints = recipesStore.constraints.filter((c) => c !== value)
} else {
recipesStore.constraints = [...recipesStore.constraints, value]
}
}
function toggleAllergy(value: string) {
if (recipesStore.allergies.includes(value)) {
recipesStore.allergies = recipesStore.allergies.filter((a) => a !== value)
} else {
recipesStore.allergies = [...recipesStore.allergies, value]
}
}
const dietaryActive = computed(() =>
recipesStore.constraints.length > 0 ||
recipesStore.allergies.length > 0 ||
recipesStore.shoppingMode
)
const advancedActive = computed(() =>
Object.values(recipesStore.nutritionFilters).some((v) => v !== null) ||
recipesStore.maxMissing !== null ||
!!recipesStore.category ||
!!recipesStore.styleId
)
// #46 — count of active nutrition filters so the summary is informative when collapsed
const activeNutritionFilterCount = computed(() =>
Object.values(recipesStore.nutritionFilters ?? {}).filter((v) => v !== null).length
)
const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level))
const cuisineStyles = [
{ id: 'italian', label: 'Italian' },
{ id: 'mediterranean', label: 'Mediterranean' },
{ id: 'east_asian', label: 'East Asian' },
{ id: 'latin', label: 'Latin' },
{ id: 'eastern_european', label: 'Eastern European' },
]
// Pantry items sorted expiry-first (available items only)
const pantryItems = computed(() => {
const sorted = [...inventoryStore.items]
.filter((item) => item.status === 'available')
.sort((a, b) => {
if (!a.expiration_date && !b.expiration_date) return 0
if (!a.expiration_date) return 1
if (!b.expiration_date) return -1
return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime()
})
return sorted.map((item) => item.product_name).filter(Boolean) as string[]
})
// Grocery links relevant to a specific recipe's missing ingredients
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
if (!recipesStore.result) return []
return recipesStore.result.grocery_links.filter((link) =>
recipe.missing_ingredients.includes(link.ingredient)
)
}
// Tag input helpers — constraints
function addConstraint(value: string) {
const tag = value.trim().toLowerCase()
if (tag && !recipesStore.constraints.includes(tag)) {
recipesStore.constraints = [...recipesStore.constraints, tag]
}
constraintInput.value = ''
}
function onConstraintKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addConstraint(constraintInput.value)
}
}
function commitConstraintInput() {
if (constraintInput.value.trim()) {
addConstraint(constraintInput.value)
}
}
// Tag input helpers — allergies
function addAllergy(value: string) {
const tag = value.trim().toLowerCase()
if (tag && !recipesStore.allergies.includes(tag)) {
recipesStore.allergies = [...recipesStore.allergies, tag]
}
allergyInput.value = ''
}
function removeAllergy(tag: string) {
recipesStore.allergies = recipesStore.allergies.filter((a) => a !== tag)
}
function onAllergyKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addAllergy(allergyInput.value)
}
}
function commitAllergyInput() {
if (allergyInput.value.trim()) {
addAllergy(allergyInput.value)
}
}
// Max missing number input
function onMaxMissingInput(e: Event) {
const target = e.target as HTMLInputElement
const val = parseInt(target.value)
recipesStore.maxMissing = isNaN(val) ? null : val
}
// Nutrition filter inputs
type NutritionKey = 'max_calories' | 'max_sugar_g' | 'max_carbs_g' | 'max_sodium_mg'
function onNutritionInput(key: NutritionKey, e: Event) {
const target = e.target as HTMLInputElement
const val = parseFloat(target.value)
recipesStore.nutritionFilters[key] = isNaN(val) ? null : val
}
// Suggest handler
async function handleSuggest() {
isLoadingMore.value = false
await recipesStore.suggest(pantryItems.value)
}
async function handleLoadMore() {
isLoadingMore.value = true
await recipesStore.loadMore(pantryItems.value)
isLoadingMore.value = false
}
onMounted(async () => {
if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
// Pre-load saved recipes so we know immediately whether to redirect
await savedStore.load()
})
// If Saved tab is empty after loading, bounce to Build Your Own
watch(
() => ({ loading: savedStore.loading, count: savedStore.saved.length }),
({ loading, count }) => {
if (!loading && count === 0 && activeTab.value === 'saved') {
activeTab.value = 'build'
}
},
{ immediate: true },
)
</script>
<style scoped>
.byo-nudge {
padding: var(--spacing-sm) 0;
}
.btn-link {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
text-decoration: underline;
font-size: inherit;
}
.tab-bar {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-sm);
}
.tab-btn {
border-radius: var(--radius-md) var(--radius-md) 0 0;
border-bottom: none;
}
.mb-controls {
margin-bottom: var(--spacing-md);
}
.mb-md {
margin-bottom: var(--spacing-md);
}
.mb-sm {
margin-bottom: var(--spacing-sm);
}
.mt-xs {
margin-top: var(--spacing-xs);
}
.ml-xs {
margin-left: var(--spacing-xs);
}
.level-description {
font-style: italic;
line-height: 1.4;
}
.wildcard-warning {
display: block;
margin-bottom: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
}
.hard-day-toggle {
cursor: pointer;
user-select: none;
}
.shopping-toggle {
cursor: pointer;
user-select: none;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.chip-remove {
background: transparent;
border: none;
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
color: inherit;
opacity: 0.7;
transition: opacity 0.15s;
min-width: 24px;
min-height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.chip-remove:hover {
opacity: 1;
transform: none;
}
.inline-spinner {
display: inline-block;
vertical-align: middle;
margin-right: var(--spacing-xs);
}
.rate-limit-banner {
display: block;
padding: var(--spacing-sm) var(--spacing-md);
}
.recipe-title {
flex: 1;
margin-right: var(--spacing-sm);
}
.btn-dismiss {
background: transparent;
border: none;
cursor: pointer;
/* WCAG 2.2 2.5.8: minimum 24×24px touch target */
min-width: 24px;
min-height: 24px;
padding: 4px 6px;
font-size: 12px;
line-height: 1;
color: var(--color-text-muted);
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.btn-dismiss:hover {
color: var(--color-error, #dc2626);
background: var(--color-error-bg, #fee2e2);
transform: none;
}
.btn-bookmark {
background: transparent;
border: none;
cursor: pointer;
/* WCAG 2.2 2.5.8: minimum 24×24px touch target */
min-width: 24px;
min-height: 24px;
padding: 4px 6px;
font-size: 14px;
line-height: 1;
color: var(--color-text-muted);
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.btn-bookmark:hover,
.btn-bookmark.active {
color: var(--color-warning, #ca8a04);
background: var(--color-warning-bg, #fef9c3);
transform: none;
}
/* Saved recipes section */
.saved-header {
user-select: none;
}
.saved-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.saved-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
}
.saved-row:last-child {
border-bottom: none;
}
.saved-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-primary);
}
.saved-title:hover {
text-decoration: underline;
}
/* Filter bar */
.filter-bar {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.filter-search {
width: 100%;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.filter-chip {
background: var(--color-bg-secondary, #f5f5f5);
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 2px 10px;
font-size: var(--font-size-xs);
cursor: pointer;
color: var(--color-text-secondary);
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.filter-chip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-bg-secondary);
transform: none;
}
.filter-chip.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.filter-chip-clear {
border-color: var(--color-error, #dc2626);
color: var(--color-error, #dc2626);
}
.filter-chip-clear:hover {
background: var(--color-error-bg, #fee2e2);
border-color: var(--color-error, #dc2626);
color: var(--color-error, #dc2626);
}
.suggest-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.btn-ghost {
background: transparent;
border: none;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
cursor: pointer;
padding: var(--spacing-xs) var(--spacing-sm);
white-space: nowrap;
}
.btn-ghost:hover {
color: var(--color-primary);
background: transparent;
transform: none;
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.load-more-row {
display: flex;
justify-content: center;
margin-bottom: var(--spacing-md);
}
.collapsible {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
}
.collapsible-summary {
cursor: pointer;
list-style: none;
padding: var(--spacing-xs) 0;
color: var(--color-primary);
}
.collapsible-summary::-webkit-details-marker {
display: none;
}
.collapsible-summary::before {
content: '▶ ';
font-size: 10px;
}
details[open] .collapsible-summary::before {
content: '▼ ';
}
.filter-summary {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-weight: 500;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
}
.filter-active-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
}
.collapsible-body {
padding-top: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
/* Hard Day Mode button */
.hard-day-btn {
width: 100%;
display: flex;
align-items: center;
gap: var(--spacing-sm);
justify-content: flex-start;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
font-weight: 500;
transition: background 0.15s, color 0.15s;
}
.hard-day-active {
background: var(--color-success, #2d7a4f);
color: white;
border-color: var(--color-success, #2d7a4f);
}
.hard-day-sub {
font-size: var(--font-size-xs, 0.75rem);
font-weight: 400;
opacity: 0.85;
margin-left: auto;
}
/* Preset grid — auto-fill 2+ columns */
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: var(--spacing-xs);
}
.preset-btn {
justify-content: center;
text-align: center;
}
.preset-active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.allergen-btn {
justify-content: center;
text-align: center;
}
.allergen-active {
background: var(--color-error, #c0392b);
color: white;
border-color: var(--color-error, #c0392b);
}
.swap-row {
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
}
.swap-row:last-child {
border-bottom: none;
}
.prep-notes-list {
padding-left: var(--spacing-lg);
list-style-type: disc;
}
.prep-note-item {
margin-bottom: var(--spacing-xs);
line-height: 1.5;
color: var(--color-text-secondary);
}
.ingredient-section {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
}
.ingredient-label {
margin-bottom: 0;
}
.ingredient-label-have {
color: var(--color-success, #16a34a);
}
.ingredient-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ingredient-chip-have {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
.directions-section {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
.directions-label {
color: var(--color-text-secondary);
text-transform: uppercase;
font-size: var(--font-size-xs);
letter-spacing: 0.05em;
margin-bottom: var(--spacing-xs);
}
.directions-list {
padding-left: var(--spacing-lg);
}
.direction-step {
margin-bottom: var(--spacing-sm);
line-height: 1.6;
}
.grocery-link {
text-decoration: none;
cursor: pointer;
transition: opacity 0.2s;
}
.grocery-link:hover {
opacity: 0.8;
}
.card-actions {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
margin-top: var(--spacing-sm);
display: flex;
justify-content: flex-end;
}
.btn-make {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-md);
}
.results-section {
margin-top: var(--spacing-md);
}
.nutrition-summary {
cursor: pointer;
}
.nutrition-filters-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
.nutrition-chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.nutrition-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-xs);
background: var(--color-bg-secondary, #f5f5f5);
color: var(--color-text-secondary);
white-space: nowrap;
}
.nutrition-chip-sugar {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.nutrition-chip-servings {
background: var(--color-info-bg);
color: var(--color-info-light);
}
.nutrition-chip-estimated {
font-style: italic;
opacity: 0.7;
}
/* Mobile adjustments */
@media (max-width: 480px) {
.flex-between {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.recipe-title {
margin-right: 0;
}
.nutrition-filters-grid {
grid-template-columns: 1fr;
}
}
.undo-toast {
position: fixed;
bottom: var(--spacing-lg);
left: 50%;
transform: translateX(-50%);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--spacing-sm) var(--spacing-md);
box-shadow: var(--shadow-md);
font-size: var(--font-size-sm);
z-index: 100;
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.undo-toast .btn-link {
min-height: 24px;
padding: 2px 4px;
}
</style>