feat: UX polish for Build Your Own tab and default landing

- Default app landing changed from Pantry to Recipes tab
- Pre-fetch inventory on app mount so Find tab has data immediately
- Reorder recipe sub-tabs: Saved > Build Your Own > Community > Find > Browse
- Default active sub-tab changed to Saved
- Auto-redirect from Saved to Build Your Own when saved list is empty
- Add freeform custom ingredient input: typing a non-pantry item now shows
  "Use X anyway" button so users aren't blocked on unknown ingredients
This commit is contained in:
pyr0ball 2026-04-14 13:53:54 -07:00
parent 144d1dc6c4
commit 1882116235
3 changed files with 56 additions and 15 deletions

View file

@ -170,7 +170,7 @@ import { householdAPI } from './services/api'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
const currentTab = ref<Tab>('inventory')
const currentTab = ref<Tab>('recipes')
const sidebarCollapsed = ref(false)
const inventoryStore = useInventoryStore()
const { kiwiVisible, kiwiDirection } = useEasterEggs()
@ -198,6 +198,11 @@ async function switchTab(tab: Tab) {
}
onMounted(async () => {
// Pre-fetch inventory so Recipes tab has data on first load
if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
// Handle household invite links: /#/join?household_id=xxx&token=yyy
const hash = window.location.hash
if (hash.includes('/join')) {

View file

@ -132,14 +132,24 @@
</div>
</template>
<!-- No-match state -->
<p
v-if="!candidatesLoading && !candidatesError && filteredCompatible.length === 0 && filteredOther.length === 0"
class="text-sm text-secondary mb-sm"
>
<!-- No-match state: nothing compatible AND nothing visible in other section.
filteredOther items are hidden when mode is 'hidden', so check visibility too. -->
<template v-if="!candidatesLoading && !candidatesError && filteredCompatible.length === 0 && (filteredOther.length === 0 || recipesStore.missingIngredientMode === 'hidden')">
<!-- Custom freeform input: text filter with no matches offer "use anyway" -->
<div v-if="recipesStore.builderFilterMode === 'text' && filterText.trim().length > 0" class="custom-ingredient-prompt mb-sm">
<p class="text-sm text-secondary mb-xs">
No match for "{{ filterText.trim() }}" in your pantry.
</p>
<button class="btn btn-secondary" @click="useCustomIngredient">
Use "{{ filterText.trim() }}" anyway
</button>
</div>
<!-- No pantry items at all for this role -->
<p v-else class="text-sm text-secondary mb-sm">
Nothing in your pantry fits this role yet. You can skip it or
<button class="btn-link" @click="recipesStore.missingIngredientMode = 'greyed'">show options to add.</button>
</p>
</template>
<!-- Skip / Next -->
<div class="byo-actions">
@ -273,6 +283,17 @@ function toggleIngredient(name: string) {
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
}
function useCustomIngredient() {
const name = filterText.value.trim()
if (!name) return
const role = currentRole.value?.display
if (!role) return
const current = new Set(roleOverrides.value[role] ?? [])
current.add(name)
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
filterText.value = ''
}
async function selectTemplate(tmpl: AssemblyTemplateOut) {
selectedTemplate.value = tmpl
wizardStep.value = 0

View file

@ -595,9 +595,10 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, computed, onMounted, 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'
@ -613,18 +614,19 @@ 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' },
{ id: 'saved', label: 'Saved' },
{ id: 'community', label: 'Community' },
{ id: 'build', label: 'Build Your Own' },
]
const activeTab = ref<TabId>('find')
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[] = ['find', 'browse', 'saved', 'community', 'build']
const tabIds: TabId[] = ['saved', 'build', 'community', 'find', 'browse']
const current = tabIds.indexOf(activeTab.value)
if (e.key === 'ArrowRight') {
e.preventDefault()
@ -919,7 +921,20 @@ 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>