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' type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
const currentTab = ref<Tab>('inventory') const currentTab = ref<Tab>('recipes')
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const inventoryStore = useInventoryStore() const inventoryStore = useInventoryStore()
const { kiwiVisible, kiwiDirection } = useEasterEggs() const { kiwiVisible, kiwiDirection } = useEasterEggs()
@ -198,6 +198,11 @@ async function switchTab(tab: Tab) {
} }
onMounted(async () => { 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 // Handle household invite links: /#/join?household_id=xxx&token=yyy
const hash = window.location.hash const hash = window.location.hash
if (hash.includes('/join')) { if (hash.includes('/join')) {

View file

@ -132,14 +132,24 @@
</div> </div>
</template> </template>
<!-- No-match state --> <!-- No-match state: nothing compatible AND nothing visible in other section.
<p filteredOther items are hidden when mode is 'hidden', so check visibility too. -->
v-if="!candidatesLoading && !candidatesError && filteredCompatible.length === 0 && filteredOther.length === 0" <template v-if="!candidatesLoading && !candidatesError && filteredCompatible.length === 0 && (filteredOther.length === 0 || recipesStore.missingIngredientMode === 'hidden')">
class="text-sm text-secondary mb-sm" <!-- 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">
Nothing in your pantry fits this role yet. You can skip it or <p class="text-sm text-secondary mb-xs">
<button class="btn-link" @click="recipesStore.missingIngredientMode = 'greyed'">show options to add.</button> No match for "{{ filterText.trim() }}" in your pantry.
</p> </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 --> <!-- Skip / Next -->
<div class="byo-actions"> <div class="byo-actions">
@ -273,6 +283,17 @@ function toggleIngredient(name: string) {
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] } 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) { async function selectTemplate(tmpl: AssemblyTemplateOut) {
selectedTemplate.value = tmpl selectedTemplate.value = tmpl
wizardStep.value = 0 wizardStep.value = 0

View file

@ -595,9 +595,10 @@
</template> </template>
<script setup lang="ts"> <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 { useRecipesStore } from '../stores/recipes'
import { useInventoryStore } from '../stores/inventory' import { useInventoryStore } from '../stores/inventory'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import RecipeDetailPanel from './RecipeDetailPanel.vue' import RecipeDetailPanel from './RecipeDetailPanel.vue'
import RecipeBrowserPanel from './RecipeBrowserPanel.vue' import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
import SavedRecipesPanel from './SavedRecipesPanel.vue' import SavedRecipesPanel from './SavedRecipesPanel.vue'
@ -613,18 +614,19 @@ const inventoryStore = useInventoryStore()
// Tab state // Tab state
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build' type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
const tabs: Array<{ id: TabId; label: string }> = [ 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: 'find', label: 'Find' },
{ id: 'browse', label: 'Browse' }, { 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) // Template ref for the Find-tab panel div (used for focus management on tab switch)
const findPanelRef = ref<HTMLElement | null>(null) const findPanelRef = ref<HTMLElement | null>(null)
function onTabKeydown(e: KeyboardEvent) { 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) const current = tabIds.indexOf(activeTab.value)
if (e.key === 'ArrowRight') { if (e.key === 'ArrowRight') {
e.preventDefault() e.preventDefault()
@ -919,7 +921,20 @@ onMounted(async () => {
if (inventoryStore.items.length === 0) { if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems() 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> </script>
<style scoped> <style scoped>