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:
parent
144d1dc6c4
commit
1882116235
3 changed files with 56 additions and 15 deletions
|
|
@ -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')) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue