Compare commits
No commits in common. "multitenant" and "master" have entirely different histories.
multitenan
...
master
67 changed files with 1795 additions and 3367 deletions
30
.github/chatmodes/uat.chatmode.md
vendored
30
.github/chatmodes/uat.chatmode.md
vendored
|
|
@ -1,30 +0,0 @@
|
||||||
---
|
|
||||||
description: 'UAT With Devtools'
|
|
||||||
tools: ['edit', 'search', 'runCommands', 'chromedevtools/chrome-devtools-mcp/*', 'usages', 'vscodeAPI', 'problems', 'changes', 'testFailure', 'fetch', 'todos', 'runTests']
|
|
||||||
---
|
|
||||||
|
|
||||||
You are a UAT (User Acceptance Testing) assistant with access to a full browser and developer tools. Your role is to help users test software applications in browser by simulating real-world usage scenarios and identifying any issues or bugs. You inspect DOM, inspect screenshots closely, check margins, ensure components are well aligned, and understand UX and design principles well. The browser is your primary tool.
|
|
||||||
|
|
||||||
You have expert code reading and writing experience. You perform root cause analysis, produce eloquent code with strict typing, and always follow TDD principles. You have many additional skills and tools at your disposal to help you perform your role fully.
|
|
||||||
|
|
||||||
This is a meal planning and recipe management web application built with Vue 3 and TypeScript.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- User authentication (sign up, log in, log out)
|
|
||||||
- Household segregation and management (no shared meals, recipes, or shopping lists between households)
|
|
||||||
- Natural language ingredient parsing
|
|
||||||
- Recipe book with search and filtering
|
|
||||||
- Meal planning calendar
|
|
||||||
- Shopping list generation
|
|
||||||
- Adhoc extra items for meals and shopping lists
|
|
||||||
|
|
||||||
See uat-profiles.md for environment URLs and test user account details. Update as you create new profiles and personas.
|
|
||||||
|
|
||||||
Prefer navigation via UI and router links over direct URL manipulation.
|
|
||||||
|
|
||||||
Note improvements as you go, especially if they impact usability or accessibility.
|
|
||||||
Note navigational oddities or broken flows.
|
|
||||||
|
|
||||||
The team cannot repeat your testing. They can not resolve issues without detailed reports. On crash or errors, capture console logs, stack traces, and always search for and include useful network logs (eg on save error, include the save request, response and prior get requests that feed into it). Note whether to defer to backend or frontend teams.
|
|
||||||
|
|
||||||
Strive to use authentic data in testing, reflecting real-world usage. Funny is even better.
|
|
||||||
1
.nvmrc
1
.nvmrc
|
|
@ -1 +0,0 @@
|
||||||
v20.19.0
|
|
||||||
|
|
@ -48,15 +48,13 @@ Key axioms
|
||||||
- No casts (`as`, angle brackets) and no `any`/`unknown` in app code. Generated files are exempt.
|
- No casts (`as`, angle brackets) and no `any`/`unknown` in app code. Generated files are exempt.
|
||||||
- Arrays that are required in OpenAPI are non-nullable in domain types (e.g., `Meal.recipes`).
|
- Arrays that are required in OpenAPI are non-nullable in domain types (e.g., `Meal.recipes`).
|
||||||
- Disallow runtime type checks in app code; acceptable exceptions: DOM event narrowing, error/env handling in the boundary.
|
- Disallow runtime type checks in app code; acceptable exceptions: DOM event narrowing, error/env handling in the boundary.
|
||||||
- Let the type system prevent mistakes: encode intent in function signatures. Prefer explicit option objects to loose primitives, make required fields non-optional, and avoid permissive overloads that widen the error surface.
|
|
||||||
- Navigation is slug-scoped and helper-driven: only use `src/router/links.ts` helpers and named routes. For non-id pages pass `{ slug }` or omit to infer; for id pages pass `id` or `{ id, slug }`. Never build path strings or read slugs from route params in components.
|
|
||||||
|
|
||||||
Layout
|
Layout
|
||||||
- `src/api/` — Typed client and SDK boundary
|
- `src/api/` — Typed client and SDK boundary
|
||||||
- `src/domain/` — Domain types and decoders
|
- `src/domain/` — Domain types and decoders
|
||||||
- `src/composables/` — Reusable app logic (auth, meals, shopping, pagination, alert)
|
- `src/composables/` — Reusable app logic (auth, meals, shopping, pagination, alert)
|
||||||
- `src/components/` — UI components and pages
|
- `src/components/` — UI components and pages
|
||||||
- `src/router/` — Routes and helpers (`links.ts` for slug-scoped navigation, plus `parseRouteId`, `parseQueryString`)
|
- `src/router/` — Routes and helpers (`parseRouteId`, `parseQueryString`)
|
||||||
|
|
||||||
Testing and tooling
|
Testing and tooling
|
||||||
- Vitest + MSW under `tests/`
|
- Vitest + MSW under `tests/`
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# Frontend Specification: Final Polish
|
|
||||||
|
|
||||||
## 1. Current State & Objective
|
|
||||||
|
|
||||||
**The multi-tenancy migration is functionally complete and successful.** The frontend has been refactored to a robust, household-scoped application using JWT-based authentication. All legacy `.js` tests have been removed, and the codebase is clean.
|
|
||||||
|
|
||||||
The objective is to complete the final remaining UI feature to officially close out the project.
|
|
||||||
|
|
||||||
## 2. Final Tasks
|
|
||||||
|
|
||||||
This checklist represents all remaining work.
|
|
||||||
|
|
||||||
- [x] **1. Implement "Copy Invite Link" UI**:
|
|
||||||
- **Objective**: Implement the user interface for inviting new members to a household using a "copy link" feature.
|
|
||||||
- **File**: `src/views/HouseholdSettings.vue`
|
|
||||||
- **Action**:
|
|
||||||
1. ~~The backend team will provide a new endpoint that, when called, returns a JSON object with an `invite_link`.~~ ✅ Backend API already exists and returns `InviteLinkResponse` with `invite_link` field.
|
|
||||||
2. ~~Update the "Invite" button logic to call this new endpoint.~~ ✅ Created `createInviteLink()` function in `src/api/invitations.ts`.
|
|
||||||
3. ~~On a successful response, use the browser's Clipboard API (`navigator.clipboard.writeText(response.invite_link)`) to copy the link.~~ ✅ Implemented in `onCopyInviteLink()` handler.
|
|
||||||
4. ~~Display a confirmation toast to the user (e.g., "Invite link copied to clipboard!").~~ ✅ Using `useAlert()` composable to show success toast.
|
|
||||||
- **Status**: ✅ **COMPLETED** - Added "Copy Invite Link" button to HouseholdSettings.vue with full clipboard integration and toast notification.
|
|
||||||
|
|
||||||
- [x] **2. Final Codebase Sweep**:
|
|
||||||
- **Objective**: Perform a final search for and remove any dead code, comments, or variables related to the old system.
|
|
||||||
- **Action**: Search the entire codebase for the following keywords: `legacy`, `old`, `previous`, `workaround`, `fallback`, `person`.
|
|
||||||
- **Outcome**: ✅ **COMPLETED** - Removed all legacy comments from:
|
|
||||||
- `src/composables/useAuth.ts` - Removed "Legacy username login removed" comment
|
|
||||||
- `src/components/LoginPage.vue` - Removed "Legacy quick-login removed" and "legacy login removed" comments
|
|
||||||
- `src/api/auth.ts` - Removed "Legacy username login has been removed" comment
|
|
||||||
- `src/api/sdk.ts` - Removed legacy shopping list stub functions (`getMyShoppingList`, `saveMyShoppingList`)
|
|
||||||
- `src/composables/useShopping.ts` - Removed references to removed stub functions
|
|
||||||
- **Note**: Remaining uses of "fallback", "person", etc. are legitimate application logic, not legacy code.
|
|
||||||
|
|
||||||
- [x] **3. Mark Project as Complete**:
|
|
||||||
- **Objective**: Once the above tasks are done, this document is complete.
|
|
||||||
- **Action**: ✅ **PROJECT COMPLETE** - All migration tasks successfully completed on November 2, 2025.
|
|
||||||
|
|
@ -42,10 +42,6 @@
|
||||||
"vitest": "^1.6.0",
|
"vitest": "^1.6.0",
|
||||||
"vue-tsc": "^2.0.29"
|
"vue-tsc": "^2.0.29"
|
||||||
},
|
},
|
||||||
"engines": {
|
|
||||||
"node": ">=20.19.0",
|
|
||||||
"npm": ">=10.9.0"
|
|
||||||
},
|
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,vue,css,scss,md}": [
|
"*.{js,vue,css,scss,md}": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
|
|
|
||||||
22
src/App.vue
22
src/App.vue
|
|
@ -1,10 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!isPublic && activeSlug">
|
<div>
|
||||||
<ul class="nav">
|
<ul class="nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link
|
<router-link
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:to="{ name: 'recipes', params: { householdSlug: activeSlug } }"
|
:to="{ name: 'recipes' }"
|
||||||
active-class="active"
|
active-class="active"
|
||||||
>
|
>
|
||||||
Recipes
|
Recipes
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link
|
<router-link
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:to="{ name: 'mealplan', params: { householdSlug: activeSlug } }"
|
:to="{ name: 'mealplan' }"
|
||||||
active-class="active"
|
active-class="active"
|
||||||
>
|
>
|
||||||
Meal Plan
|
Meal Plan
|
||||||
|
|
@ -22,14 +22,13 @@
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link
|
<router-link
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:to="{ name: 'shopping', params: { householdSlug: activeSlug } }"
|
:to="{ name: 'shopping' }"
|
||||||
active-class="active"
|
active-class="active"
|
||||||
>
|
>
|
||||||
Shopping
|
Shopping
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<HouseholdSwitcher />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="viewport">
|
<div class="viewport">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
|
@ -40,21 +39,8 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import AlertToast from './components/AlertToast.vue'
|
import AlertToast from './components/AlertToast.vue'
|
||||||
import HouseholdSwitcher from '@/components/HouseholdSwitcher.vue'
|
|
||||||
import { useHousehold } from '@/composables/useHousehold'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
// Initialize household slug provider binding to current route
|
|
||||||
useHousehold()
|
|
||||||
|
|
||||||
// components in <script setup> are auto-registered by import + usage
|
// components in <script setup> are auto-registered by import + usage
|
||||||
const route = useRoute()
|
|
||||||
const isPublic = computed(() => {
|
|
||||||
const n = String(route.name || '')
|
|
||||||
return n === 'login' || n === 'create-account' || n === 'welcome' || n === 'invitation-accept'
|
|
||||||
})
|
|
||||||
const activeSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
117
src/api/auth.ts
117
src/api/auth.ts
|
|
@ -1,115 +1,30 @@
|
||||||
import { api, fetchApi } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import { setAuthTokenProvider } from '@/api/client'
|
import type { Person } from '@/domain/types'
|
||||||
import type { User } from '@/domain/types'
|
|
||||||
|
|
||||||
let cachedUser: User | null = null
|
let cachedUser: Person | null = null
|
||||||
let authToken: string | null = null
|
|
||||||
setAuthTokenProvider(() => authToken)
|
|
||||||
|
|
||||||
export async function currentUser(): Promise<User | null> {
|
export async function currentUser(): Promise<Person | null> {
|
||||||
if (cachedUser) return cachedUser
|
if (cachedUser) return cachedUser
|
||||||
try {
|
try {
|
||||||
const res = await api.POST('/api/v1/auth/refresh', { params: {} })
|
const res = await api.POST('/api/v1/auth/refresh', { params: { cookie: { user_id: 0 } } })
|
||||||
if (!res.response.ok) {
|
if (!res.response.ok) return null
|
||||||
authToken = null
|
cachedUser = res.data ?? null
|
||||||
cachedUser = null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const tokenVal = res.data?.accessToken ?? null
|
|
||||||
if (!tokenVal) {
|
|
||||||
authToken = null
|
|
||||||
cachedUser = null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
authToken = tokenVal
|
|
||||||
// Populate a minimal user by probing households (backend does not expose a user endpoint yet)
|
|
||||||
const hs = await api.GET('/api/v1/users/me/households', { params: {} })
|
|
||||||
if (!hs.response.ok) {
|
|
||||||
cachedUser = { id: -1, email: '', displayName: '' }
|
|
||||||
return cachedUser
|
|
||||||
}
|
|
||||||
// In absence of a user profile endpoint, synthesize a stable user id
|
|
||||||
cachedUser = { id: -1, email: '', displayName: '' }
|
|
||||||
return cachedUser
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
authToken = null
|
|
||||||
cachedUser = null
|
cachedUser = null
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return cachedUser
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginWithPassword(email: string, password: string): Promise<User> {
|
export async function login(username: string): Promise<Person> {
|
||||||
const res = await api.POST('/api/v1/auth/login', { body: { email, password } })
|
const res = await api.POST('/api/v1/auth/login', { body: { username } })
|
||||||
if (!res.response.ok) {
|
if (!res.response.ok) {
|
||||||
const err = res.error
|
const err = res.error
|
||||||
throw ((err instanceof Error && err) || new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`))
|
throw (
|
||||||
|
(err instanceof Error && err) ||
|
||||||
|
(typeof err === 'string' ? new Error(err) : new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const token = res.data?.accessToken ?? null
|
cachedUser = res.data ?? null
|
||||||
const user = res.data?.user ?? null
|
if (!cachedUser) throw new Error('Login failed: empty response')
|
||||||
if (!token || !user) throw new Error('Invalid token response')
|
|
||||||
authToken = token
|
|
||||||
cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' }
|
|
||||||
return cachedUser
|
return cachedUser
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAccount(email: string, displayName: string, password: string): Promise<User> {
|
|
||||||
const res = await api.POST('/api/v1/auth/register', { body: { email, password, displayName } })
|
|
||||||
if (!res.response.ok) {
|
|
||||||
const err = res.error
|
|
||||||
throw ((err instanceof Error && err) || new Error(`${res.response.status} ${res.response.statusText || 'HTTP error'}`))
|
|
||||||
}
|
|
||||||
const token = res.data?.accessToken ?? null
|
|
||||||
const user = res.data?.user ?? null
|
|
||||||
if (!token || !user) throw new Error('Invalid token response')
|
|
||||||
authToken = token
|
|
||||||
cachedUser = { id: user.id, email: user.email ?? '', displayName: user.displayName ?? '' }
|
|
||||||
return cachedUser
|
|
||||||
}
|
|
||||||
|
|
||||||
// Google OAuth
|
|
||||||
export async function handleGoogleLogin(): Promise<string> {
|
|
||||||
// Ask backend for the Google OAuth start URL
|
|
||||||
const res = await fetchApi('/api/v1/auth/google/start', { method: 'GET' })
|
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
|
|
||||||
const data: unknown = await res.json()
|
|
||||||
const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object'
|
|
||||||
if (isObj(data)) {
|
|
||||||
const urlVal = data['url']
|
|
||||||
if (typeof urlVal === 'string') return urlVal
|
|
||||||
}
|
|
||||||
throw new Error('Invalid google start response')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function completeGoogleLogin(code: string, state?: string): Promise<User> {
|
|
||||||
const body: Record<string, unknown> = { code }
|
|
||||||
if (state) body.state = state
|
|
||||||
const res = await fetchApi('/api/v1/auth/google/callback', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText || 'HTTP error'}`)
|
|
||||||
const data: unknown = await res.json()
|
|
||||||
const isObj = (v: unknown): v is { [k: string]: unknown } => v !== null && typeof v === 'object'
|
|
||||||
if (!isObj(data)) throw new Error('Invalid token response')
|
|
||||||
const tokenVal = data['accessToken']
|
|
||||||
const userVal = data['user']
|
|
||||||
if (!(typeof tokenVal === 'string' && isObj(userVal))) throw new Error('Invalid token response')
|
|
||||||
const idVal = userVal['id']
|
|
||||||
if (typeof idVal !== 'number') throw new Error('Invalid token response')
|
|
||||||
const emailVal = userVal['email']
|
|
||||||
const nameVal = userVal['displayName']
|
|
||||||
authToken = tokenVal
|
|
||||||
cachedUser = {
|
|
||||||
id: idVal,
|
|
||||||
email: typeof emailVal === 'string' ? emailVal : '',
|
|
||||||
displayName: typeof nameVal === 'string' ? nameVal : '',
|
|
||||||
}
|
|
||||||
return cachedUser
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
// Clear local cache; server session is cookie-based and will be refreshed on next call
|
|
||||||
cachedUser = null
|
|
||||||
authToken = null
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,59 +9,12 @@ const isTest = typeof process !== 'undefined' && (process.env?.VITEST === 'true'
|
||||||
const defaultBase = ''
|
const defaultBase = ''
|
||||||
const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || defaultBase)
|
const baseUrl: string = isTest ? 'http://localhost' : (vueCliBase || defaultBase)
|
||||||
|
|
||||||
let householdSlugProvider: (() => string | null) | null = null
|
|
||||||
let authTokenProvider: (() => string | null) | null = null
|
|
||||||
|
|
||||||
export function setHouseholdSlugProvider(provider: (() => string | null) | null) {
|
|
||||||
householdSlugProvider = provider
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose current household slug for SDK convenience (temporary during migration)
|
|
||||||
export function getHouseholdSlug(): string | null {
|
|
||||||
return householdSlugProvider ? householdSlugProvider() : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setAuthTokenProvider(provider: (() => string | null) | null) {
|
|
||||||
authTokenProvider = provider
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestInfoToUrl(input: RequestInfo | URL): string | null {
|
|
||||||
try {
|
|
||||||
if (typeof input === 'string') return input
|
|
||||||
if (typeof URL !== 'undefined' && input instanceof URL) return input.toString()
|
|
||||||
if (typeof Request !== 'undefined' && input instanceof Request) return input.url
|
|
||||||
return String(input)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRefreshRequest(input: RequestInfo | URL): boolean {
|
|
||||||
const url = requestInfoToUrl(input)
|
|
||||||
return !!url && url.includes('/api/v1/auth/refresh')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = createClient<paths>({
|
export const api = createClient<paths>({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const headers = new Headers(init?.headers || {})
|
|
||||||
const token = authTokenProvider ? authTokenProvider() : null
|
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
|
||||||
const credentials = isRefreshRequest(input) ? 'include' : 'same-origin'
|
|
||||||
return globalThis.fetch(input, {
|
return globalThis.fetch(input, {
|
||||||
credentials,
|
credentials: 'include',
|
||||||
...init,
|
...init,
|
||||||
headers,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// For endpoints not yet in OpenAPI, provide a raw fetch that preserves header injection and base URL behavior
|
|
||||||
export async function fetchApi(path: string, init?: RequestInit): Promise<Response> {
|
|
||||||
const headers = new Headers(init?.headers || {})
|
|
||||||
const token = authTokenProvider ? authTokenProvider() : null
|
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
|
||||||
const url = baseUrl ? new URL(path, baseUrl).toString() : path
|
|
||||||
const credentials = path.includes('/api/v1/auth/refresh') ? 'include' : 'same-origin'
|
|
||||||
return globalThis.fetch(url, { credentials, ...init, headers })
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { api, getHouseholdSlug } from '@/api/client'
|
|
||||||
import type { components } from '@/api/types'
|
|
||||||
|
|
||||||
export type Member = components['schemas']['HouseholdMember']
|
|
||||||
|
|
||||||
// Prefer typed endpoint if exists, fallback to raw fetch for now
|
|
||||||
export async function listMembers(): Promise<Member[]> {
|
|
||||||
const householdSlug = getHouseholdSlug()
|
|
||||||
if (!householdSlug) throw new Error('Missing household slug')
|
|
||||||
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/members', {
|
|
||||||
params: { path: { householdSlug } },
|
|
||||||
})
|
|
||||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
|
|
||||||
return Array.isArray(data) ? data : []
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { api } from '@/api/client'
|
|
||||||
|
|
||||||
export type Household = { id: number; name: string; slug: string }
|
|
||||||
|
|
||||||
export async function acceptInvitation(token: string): Promise<Household> {
|
|
||||||
const { data, response, error } = await api.POST('/api/v1/invitations/accept', { body: { token } })
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
|
|
||||||
}
|
|
||||||
const h = data?.household
|
|
||||||
if (!h || typeof h.id !== 'number' || typeof h.name !== 'string' || typeof h.slug !== 'string') {
|
|
||||||
throw new Error('Invalid invitation accept response')
|
|
||||||
}
|
|
||||||
return { id: h.id, name: h.name, slug: h.slug }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createInviteLink(householdSlug: string): Promise<string> {
|
|
||||||
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/invitations', { params: { path: { householdSlug } } })
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
|
|
||||||
}
|
|
||||||
const inviteLink = data?.invite_link
|
|
||||||
if (typeof inviteLink !== 'string') throw new Error('Invalid response: missing invite_link')
|
|
||||||
return inviteLink
|
|
||||||
}
|
|
||||||
184
src/api/sdk.ts
184
src/api/sdk.ts
|
|
@ -1,9 +1,10 @@
|
||||||
import { api, getHouseholdSlug } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import type { components } from '@/api/types'
|
import type { components } from '@/api/types'
|
||||||
import {
|
import {
|
||||||
toDate,
|
toDate,
|
||||||
decodeMeal,
|
decodeMeal,
|
||||||
decodeRecipe,
|
decodeRecipe,
|
||||||
|
decodeIngredients,
|
||||||
decodeShoppingList,
|
decodeShoppingList,
|
||||||
decodeShoppingListItems,
|
decodeShoppingListItems,
|
||||||
decodeListIngredientItems,
|
decodeListIngredientItems,
|
||||||
|
|
@ -32,12 +33,6 @@ function httpError(response: Response, error: unknown): Error {
|
||||||
return new Error(`${response.status} ${response.statusText || 'HTTP error'}`)
|
return new Error(`${response.status} ${response.statusText || 'HTTP error'}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireSlug(explicit?: string): string {
|
|
||||||
const slug = explicit ?? getHouseholdSlug() ?? null
|
|
||||||
if (!slug) throw new Error('Missing household slug')
|
|
||||||
return slug
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeLookup moved to domain/decoders to be reused across SDK and other modules
|
// decodeLookup moved to domain/decoders to be reused across SDK and other modules
|
||||||
|
|
||||||
// Shopping list mapped view types now come from domain/types
|
// Shopping list mapped view types now come from domain/types
|
||||||
|
|
@ -154,8 +149,6 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi
|
||||||
return dtoOut
|
return dtoOut
|
||||||
}
|
}
|
||||||
|
|
||||||
// With stricter OpenAPI types, we can rely on the typed responses and decoders.
|
|
||||||
|
|
||||||
export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<Recipe>> {
|
export async function listRecipes(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<Recipe>> {
|
||||||
const query: Record<string, unknown> = {}
|
const query: Record<string, unknown> = {}
|
||||||
if (params) {
|
if (params) {
|
||||||
|
|
@ -163,86 +156,80 @@ export async function listRecipes(params?: { q?: string | null; cursor?: string
|
||||||
if (params.cursor !== undefined) query.cursor = params.cursor
|
if (params.cursor !== undefined) query.cursor = params.cursor
|
||||||
if (typeof params.limit === 'number') query.limit = params.limit
|
if (typeof params.limit === 'number') query.limit = params.limit
|
||||||
}
|
}
|
||||||
const householdSlug = requireSlug()
|
const { data, error, response } = await api.GET('/api/v1/recipes', { params: { query } })
|
||||||
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug }, query } })
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
return fromOpenApiPage(data ?? null, (r) => decodeRecipe(r))
|
return fromOpenApiPage(data ?? null, (r) => decodeRecipe(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecipe(householdSlug: string, id: number | string): Promise<Recipe> {
|
export async function getRecipe(id: number | string): Promise<Recipe> {
|
||||||
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/recipes/{recipe_id}', {
|
const { data, error, response } = await api.GET('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) } } })
|
||||||
params: { path: { householdSlug: requireSlug(householdSlug), recipe_id: Number(id) } },
|
|
||||||
})
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
const mapped = decodeRecipe(data)
|
const mapped = decodeRecipe(data)
|
||||||
if (!mapped) throw new Error('Recipe not found')
|
if (!mapped) throw new Error('Recipe not found')
|
||||||
return mapped
|
return mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveRecipe(recipe: components['schemas']['RecipeCreate-Input']): Promise<Recipe | null> {
|
export async function saveRecipe(recipe: components['schemas']['Recipe-Input']): Promise<Recipe | null> {
|
||||||
const householdSlug = requireSlug()
|
const { data, error, response } = await api.POST('/api/v1/recipes', { body: recipe, params: { cookie: { user_id: 0 } } })
|
||||||
// Map "Recipe-Input" to "RecipeCreate"
|
|
||||||
const body: components['schemas']['RecipeCreate-Input'] = {
|
|
||||||
name: recipe.name,
|
|
||||||
link: recipe.link,
|
|
||||||
serves: recipe.serves,
|
|
||||||
imageUrls: recipe.imageUrls ?? [],
|
|
||||||
ingredients: recipe.ingredients ?? [],
|
|
||||||
}
|
|
||||||
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug } }, body })
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
return decodeRecipe(data)
|
return decodeRecipe(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecipe(id: number | string): Promise<void> {
|
export async function deleteRecipe(id: number | string): Promise<void> {
|
||||||
const householdSlug = requireSlug()
|
const { error, response } = await api.DELETE('/api/v1/recipes/{recipe_id}', { params: { path: { recipe_id: Number(id) }, cookie: { user_id: 0 } } })
|
||||||
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/recipes/{recipe_id}', { params: { path: { householdSlug, recipe_id: Number(id) } } })
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseRecipe(url: string, householdSlugOverride?: string): Promise<Recipe | null> {
|
export async function parseRecipe(url: string): Promise<Recipe | null> {
|
||||||
const householdSlug = requireSlug(householdSlugOverride)
|
const { data, error, response } = await api.GET('/api/v1/recipes/parse', { params: { query: { url }, cookie: { user_id: 0 } } })
|
||||||
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/recipes/parse-from-url', {
|
|
||||||
params: { path: { householdSlug } },
|
|
||||||
body: { url },
|
|
||||||
})
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
// Response is RecipeCreate-Output; map to domain Recipe via decoder by augmenting required ids
|
return decodeRecipe(data)
|
||||||
const created: components['schemas']['RecipeCreate-Output'] | undefined = data
|
|
||||||
if (!created) return null
|
|
||||||
const out: components['schemas']['RecipeOut'] = {
|
|
||||||
id: -1,
|
|
||||||
name: created.name,
|
|
||||||
link: created.link,
|
|
||||||
serves: created.serves,
|
|
||||||
imageUrls: created.imageUrls,
|
|
||||||
ingredients: created.ingredients,
|
|
||||||
createdById: -1,
|
|
||||||
}
|
|
||||||
return decodeRecipe(out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseIngredients(lines: string[]): Promise<Ingredient[]> {
|
export async function parseIngredients(lines: string[]): Promise<Ingredient[]> {
|
||||||
const householdSlug = requireSlug()
|
const { data, error, response } = await api.GET('/api/v1/recipes/ingredients/parse', {
|
||||||
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/ingredients/parse', {
|
params: { query: { ingredients: lines } },
|
||||||
params: { path: { householdSlug }, query: { lines } },
|
|
||||||
})
|
})
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
return Array.isArray(data) ? data.map(decodeIngredient) : []
|
return decodeIngredients(data ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseProduct(): Promise<components['schemas']['Product'] | null> {
|
export async function parseProduct(
|
||||||
// Removed in v2 API; product parsing/creation is not exposed via this endpoint
|
ingredient: Pick<components['schemas']['Ingredient'], 'name' | 'line'>,
|
||||||
throw new Error('parseProduct is not available in v2 API')
|
url: string
|
||||||
|
): Promise<components['schemas']['Product'] | null> {
|
||||||
|
const body: components['schemas']['ProductUrl'] = { url, tags: [ingredient.name, ingredient.line] }
|
||||||
|
const res = await api.POST('/api/v1/products', { body })
|
||||||
|
if (!res.response.ok) throw httpError(res.response, res.error)
|
||||||
|
return res.data ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Person-related functions are removed as the entity is no longer in use.
|
// Persons
|
||||||
|
async function listPersons(params?: { q?: string | null; cursor?: string | null; limit?: number }): Promise<Page<components['schemas']['Person']>> {
|
||||||
|
const query: Record<string, unknown> = {}
|
||||||
|
if (params) {
|
||||||
|
if (params.q !== undefined) query.q = params.q
|
||||||
|
if (params.cursor !== undefined) query.cursor = params.cursor
|
||||||
|
if (typeof params.limit === 'number') query.limit = params.limit
|
||||||
|
}
|
||||||
|
const { data, error, response } = await api.GET('/api/v1/persons', { params: { query } })
|
||||||
|
if (!response.ok) throw httpError(response, error)
|
||||||
|
const normalized = Array.isArray(data) ? { items: data } : (data ?? null)
|
||||||
|
return fromOpenApiPage<components['schemas']['Person'], components['schemas']['Person']>(normalized, (p) => p)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPersonsInHome(): Promise<Page<components['schemas']['Person']>> {
|
||||||
|
return listPersons()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPersons(name: string): Promise<Page<components['schemas']['Person']>> {
|
||||||
|
return listPersons({ q: name })
|
||||||
|
}
|
||||||
|
|
||||||
// Meals
|
// Meals
|
||||||
export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
|
export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
|
||||||
const householdSlug = requireSlug()
|
const { data, error, response } = await api.GET('/api/v1/meals/upcoming', {
|
||||||
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/meals/upcoming', {
|
params: { query: { from: from.toISOString(), to: to.toISOString() } },
|
||||||
params: { path: { householdSlug }, query: { from: from.toISOString(), to: to.toISOString() } },
|
|
||||||
})
|
})
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
const list = Array.isArray(data) ? data : []
|
const list = Array.isArray(data) ? data : []
|
||||||
|
|
@ -253,35 +240,32 @@ export async function getUpcomingMeals(from: Date, to: Date): Promise<Meal[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMeal(id: number | string): Promise<Meal> {
|
export async function getMeal(id: number | string): Promise<Meal> {
|
||||||
const householdSlug = requireSlug()
|
const { data, error, response } = await api.GET('/api/v1/meals/{meal_id}', { params: { path: { meal_id: Number(id) } } })
|
||||||
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/meals/{meal_id}', { params: { path: { householdSlug, meal_id: Number(id) } } })
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
const mapped = decodeMeal(data)
|
const mapped = decodeMeal(data)
|
||||||
if (!mapped) throw new Error('Meal not found')
|
if (!mapped) throw new Error('Meal not found')
|
||||||
return mapped
|
return mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMeal(meal: components['schemas']['MealIn']): Promise<Meal | null> {
|
export async function saveMeal(meal: components['schemas']['Meal-Input']): Promise<Meal | null> {
|
||||||
const hasId = typeof meal.id === 'number' && meal.id >= 0
|
const hasId = typeof meal.id === 'number' && meal.id >= 0
|
||||||
const householdSlug = requireSlug()
|
|
||||||
if (hasId) {
|
if (hasId) {
|
||||||
const { data, error, response } = await api.PUT('/api/v1/households/{householdSlug}/meals/{meal_id}', {
|
const { data, error, response } = await api.PUT('/api/v1/meals/{meal_id}', {
|
||||||
params: { path: { householdSlug, meal_id: Number(meal.id) } },
|
params: { path: { meal_id: Number(meal.id) } },
|
||||||
body: meal,
|
body: meal,
|
||||||
})
|
})
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
return decodeMeal(data)
|
return decodeMeal(data)
|
||||||
} else {
|
} else {
|
||||||
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/meals', { params: { path: { householdSlug } }, body: meal })
|
const { data, error, response } = await api.POST('/api/v1/meals', { body: meal })
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
return decodeMeal(data)
|
return decodeMeal(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markMealConsumed(mealId: number | string): Promise<Meal> {
|
export async function markMealConsumed(mealId: number | string): Promise<Meal> {
|
||||||
const householdSlug = requireSlug()
|
const { data, error, response } = await api.POST('/api/v1/meals/{meal_id}/consumed', {
|
||||||
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/meals/{meal_id}/consumed', {
|
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
|
||||||
params: { path: { householdSlug, meal_id: Number(mealId) } },
|
|
||||||
})
|
})
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
const mapped = decodeMeal(data)
|
const mapped = decodeMeal(data)
|
||||||
|
|
@ -290,24 +274,37 @@ export async function markMealConsumed(mealId: number | string): Promise<Meal> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMeal(mealId: number | string): Promise<void> {
|
export async function deleteMeal(mealId: number | string): Promise<void> {
|
||||||
const householdSlug = requireSlug()
|
const { error, response } = await api.DELETE('/api/v1/meals/{meal_id}', {
|
||||||
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/meals/{meal_id}', {
|
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
|
||||||
params: { path: { householdSlug, meal_id: Number(mealId) } },
|
|
||||||
})
|
})
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shopping
|
||||||
|
export async function getMyShoppingList(): Promise<Ingredient[]> {
|
||||||
|
const { data, error, response } = await api.GET('/api/v1/shopping/current/me/ingredients', { params: { cookie: { user_id: 0 } } })
|
||||||
|
if (!response.ok) throw httpError(response, error)
|
||||||
|
return decodeIngredients(data ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMyShoppingList(items: components['schemas']['Ingredient'][]): Promise<Ingredient[]> {
|
||||||
|
const { data, error, response } = await api.POST('/api/v1/shopping/current/me/ingredients', {
|
||||||
|
body: items,
|
||||||
|
params: { cookie: { user_id: 0 } },
|
||||||
|
})
|
||||||
|
if (!response.ok) throw httpError(response, error)
|
||||||
|
return decodeIngredients(data ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
|
export async function getShoppingList(id: number | string): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
|
||||||
const householdSlug = requireSlug()
|
const { data, error, response } = await api.GET('/api/v1/shopping/{list_id}', { params: { path: { list_id: Number(id) } } })
|
||||||
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/{list_id}', { params: { path: { householdSlug, list_id: Number(id) } } })
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
const mapped = mapPurchasedShoppingList(data)
|
const mapped = mapPurchasedShoppingList(data)
|
||||||
return mapped?.list ?? null
|
return mapped?.list ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO> {
|
export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO> {
|
||||||
const householdSlug = requireSlug()
|
const { data, error, response } = await api.GET('/api/v1/shopping/current')
|
||||||
const { data, error, response } = await api.GET('/api/v1/households/{householdSlug}/shopping/current', { params: { path: { householdSlug } } })
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
const mapped = mapCurrentShoppingList(data)
|
const mapped = mapCurrentShoppingList(data)
|
||||||
if (!mapped) throw new Error('Failed to map current shopping list')
|
if (!mapped) throw new Error('Failed to map current shopping list')
|
||||||
|
|
@ -336,10 +333,9 @@ export async function purchaseShoppingList(
|
||||||
storeName: 'home',
|
storeName: 'home',
|
||||||
items,
|
items,
|
||||||
}
|
}
|
||||||
const householdSlug = requireSlug()
|
const { data, error, response } = await api.POST('/api/v1/shopping', {
|
||||||
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping', {
|
|
||||||
params: { path: { householdSlug } },
|
|
||||||
body,
|
body,
|
||||||
|
params: { cookie: { user_id: 0 } },
|
||||||
})
|
})
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
const mapped = mapPurchasedShoppingList(data)
|
const mapped = mapPurchasedShoppingList(data)
|
||||||
|
|
@ -347,40 +343,16 @@ export async function purchaseShoppingList(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestMeal(mealId: number | string): Promise<void> {
|
export async function requestMeal(mealId: number | string): Promise<void> {
|
||||||
const householdSlug = requireSlug()
|
const { error, response } = await api.POST('/api/v1/shopping/current/meals/me', {
|
||||||
const { error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/meals/me', {
|
|
||||||
params: { path: { householdSlug } },
|
|
||||||
body: { mealId: Number(mealId) },
|
body: { mealId: Number(mealId) },
|
||||||
|
params: { cookie: { user_id: 0 } },
|
||||||
})
|
})
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unrequestMeal(mealId: number | string): Promise<void> {
|
export async function unrequestMeal(mealId: number | string): Promise<void> {
|
||||||
const householdSlug = requireSlug()
|
const { error, response } = await api.DELETE('/api/v1/shopping/current/meals/{meal_id}', {
|
||||||
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/shopping/current/meals/{meal_id}', {
|
params: { path: { meal_id: Number(mealId) }, cookie: { user_id: 0 } },
|
||||||
params: { path: { householdSlug, meal_id: Number(mealId) } },
|
|
||||||
})
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestIngredient(ingredientId: number): Promise<import('@/domain/types').ListIngredientItemWithRefs> {
|
|
||||||
const householdSlug = requireSlug()
|
|
||||||
const { data, error, response } = await api.POST('/api/v1/households/{householdSlug}/shopping/current/ingredients', {
|
|
||||||
params: { path: { householdSlug } },
|
|
||||||
body: { ingredientId },
|
|
||||||
})
|
|
||||||
if (!response.ok) throw httpError(response, error)
|
|
||||||
if (!data) throw new Error('Failed to decode requested ingredient item')
|
|
||||||
const [decoded] = decodeListIngredientItems([data])
|
|
||||||
if (!decoded) throw new Error('Failed to decode requested ingredient item')
|
|
||||||
return decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unrequestIngredient(ingredientId: number): Promise<void> {
|
|
||||||
const householdSlug = requireSlug()
|
|
||||||
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/shopping/current/ingredients', {
|
|
||||||
params: { path: { householdSlug } },
|
|
||||||
body: { ingredientId },
|
|
||||||
})
|
})
|
||||||
if (!response.ok) throw httpError(response, error)
|
if (!response.ok) throw httpError(response, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2591
src/api/types.ts
2591
src/api/types.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,66 +0,0 @@
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="households.length > 0"
|
|
||||||
class="household-switcher"
|
|
||||||
>
|
|
||||||
<label>Household:</label>
|
|
||||||
<ul>
|
|
||||||
<li
|
|
||||||
v-for="h in households"
|
|
||||||
:key="h.slug"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
:to="toHousehold(h.slug)"
|
|
||||||
:class="{ active: h.slug === activeSlug }"
|
|
||||||
>
|
|
||||||
{{ h.name }}
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<router-link
|
|
||||||
class="settings-link"
|
|
||||||
:to="toSettings()"
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useAuth } from '@/composables/useAuth'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const { households } = useAuth()
|
|
||||||
|
|
||||||
const activeSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null))
|
|
||||||
|
|
||||||
function toHousehold(slug: string) {
|
|
||||||
return { name: 'mealplan', params: { householdSlug: slug } }
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSettings() {
|
|
||||||
const slug = activeSlug.value
|
|
||||||
if (typeof slug === 'string' && slug.length > 0) {
|
|
||||||
return { name: 'household-settings', params: { householdSlug: slug } }
|
|
||||||
}
|
|
||||||
return { name: 'household-settings' }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.household-switcher {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.household-switcher ul {
|
|
||||||
list-style: none;
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 0 0 0 8px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.settings-link {
|
|
||||||
margin-left: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,102 +1,49 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<h1>Login</h1>
|
<h1>Login Page</h1>
|
||||||
<!-- Multitenant: Email/Password login -->
|
<ul class="button-group">
|
||||||
<div v-if="isMultitenant">
|
<li
|
||||||
<form @submit.prevent="onSubmitLogin">
|
v-for="(person, index) in persons"
|
||||||
<div class="form-group">
|
:key="person.id ?? index"
|
||||||
<label for="email">Email</label>
|
>
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
v-model="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-primary"
|
||||||
@click="onGoogleLogin"
|
@click="onLogin(person)"
|
||||||
>
|
>
|
||||||
Sign in with Google
|
{{ person.name }}
|
||||||
</button>
|
</button>
|
||||||
<router-link
|
</li>
|
||||||
class="btn btn-link"
|
</ul>
|
||||||
:to="{ name: 'create-account' }"
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</router-link>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else>
|
|
||||||
<p>This environment is configured without multi-tenancy enabled.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { loginWithPassword, handleGoogleLogin } from '@/api/auth'
|
import { getPersonsInHome } from '@/api/sdk'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { login as loginApi } from '@/api/auth'
|
||||||
|
import type { Person } from '@/domain/types'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
redirect: { type: String, default: '/' },
|
redirect: { type: String, default: '/' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const persons = ref<Person[]>([])
|
||||||
const email = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const isMultitenant = computed(() => true)
|
|
||||||
const { user } = useAuth()
|
|
||||||
|
|
||||||
onMounted(async () => {})
|
onMounted(async () => {
|
||||||
|
const page = await getPersonsInHome()
|
||||||
|
persons.value = page.items
|
||||||
|
})
|
||||||
|
|
||||||
function afterLoginNavigate() {
|
async function onLogin(selectedPerson: Person) {
|
||||||
const q = route.query?.redirect
|
const person = await loginApi(selectedPerson.name)
|
||||||
const redirectPath = (typeof q === 'string' ? q : undefined) || props.redirect
|
if (person?.id >= 0) {
|
||||||
router.push(redirectPath || '/')
|
router.push(props.redirect)
|
||||||
}
|
return
|
||||||
|
|
||||||
async function onSubmitLogin() {
|
|
||||||
try {
|
|
||||||
await loginWithPassword(email.value.trim(), password.value)
|
|
||||||
if (user.value?.id !== undefined) {
|
|
||||||
afterLoginNavigate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
alert('Login failed')
|
|
||||||
} catch (e) {
|
|
||||||
alert(e instanceof Error ? e.message : 'Login error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onGoogleLogin() {
|
|
||||||
try {
|
|
||||||
const url = await handleGoogleLogin()
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.location.href = url
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert(e instanceof Error ? e.message : 'Google login not available')
|
|
||||||
}
|
}
|
||||||
|
alert('Login failed')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -154,8 +101,4 @@ li:nth-child(4) > button {
|
||||||
background-color: #9a1f1f;
|
background-color: #9a1f1f;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,12 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="ingredient in ingredients"
|
v-for="ingredient in ingredients"
|
||||||
:key="ingredient.id ?? ingredient.line"
|
:key="ingredient"
|
||||||
>
|
>
|
||||||
<div v-if="editing">
|
<div v-if="editing">
|
||||||
<p class="ingredient-line">
|
<p class="ingredient-line">
|
||||||
<ingredient-line
|
<ingredient-line
|
||||||
:ingredient="ingredient"
|
:ingredient="ingredient"
|
||||||
@update-line="(ing, line) => emit('on-update-line', ing, line)"
|
|
||||||
@update-ingredient="updateIngredient"
|
@update-ingredient="updateIngredient"
|
||||||
@update-product-link="updateProduct"
|
@update-product-link="updateProduct"
|
||||||
/>
|
/>
|
||||||
|
|
@ -58,10 +57,9 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { Ingredient } from '@/domain/types'
|
import { parseProduct, parseIngredients } from '@/api/sdk'
|
||||||
import { parseIngredients } from '@/api/sdk'
|
|
||||||
import IngredientLine from './IngredientLine.vue'
|
import IngredientLine from './IngredientLine.vue'
|
||||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||||
const addCart = new URL('@/assets/add-cart.svg', import.meta.url).toString()
|
const addCart = new URL('@/assets/add-cart.svg', import.meta.url).toString()
|
||||||
|
|
@ -69,33 +67,28 @@ const editOff = new URL('@/assets/edit-off.svg', import.meta.url).toString()
|
||||||
const editOn = new URL('@/assets/edit.svg', import.meta.url).toString()
|
const editOn = new URL('@/assets/edit.svg', import.meta.url).toString()
|
||||||
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
|
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
|
||||||
|
|
||||||
const props = defineProps<{ ingredients: Ingredient[]; editOnly?: boolean }>()
|
const props = defineProps({
|
||||||
const emit = defineEmits<{
|
ingredients: { type: Array, required: true },
|
||||||
(e: 'on-add'): void
|
editOnly: { type: Boolean, default: false },
|
||||||
(e: 'on-delete', ingredient: Ingredient): void
|
})
|
||||||
(e: 'on-update-ingredient', ingredient: Ingredient, newIngredient: Ingredient): void
|
const emit = defineEmits(['on-add', 'on-delete', 'on-update-ingredient', 'on-editing'])
|
||||||
(e: 'on-update-line', ingredient: Ingredient, newLine: string): void
|
|
||||||
(e: 'on-editing', isEditing: boolean): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const editing = ref(props.editOnly ?? false)
|
const editing = ref(props.editOnly ?? false)
|
||||||
|
|
||||||
|
async function updateProduct(ingredient, product_link) {
|
||||||
async function updateProduct(): Promise<void> {
|
const product = await parseProduct(ingredient, product_link)
|
||||||
// parseProduct is not available in v2 API; ignore for now
|
emit('on-update-ingredient', ingredient, { ...ingredient, product })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateIngredient(ingredient: Ingredient, line: string) {
|
async function updateIngredient(ingredient, line) {
|
||||||
const newIngredients = await parseIngredients([line])
|
const newIngredients = await parseIngredients([line])
|
||||||
const next = newIngredients[0]
|
emit('on-update-ingredient', ingredient, newIngredients[0])
|
||||||
if (next) emit('on-update-ingredient', ingredient, next)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEditing() {
|
function toggleEditing() {
|
||||||
editing.value = !editing.value
|
editing.value = !editing.value
|
||||||
emit('on-editing', editing.value)
|
emit('on-editing', editing.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
<input
|
<input
|
||||||
v-model="ingredientText"
|
v-model="ingredientText"
|
||||||
placeholder="Enter an ingredient"
|
placeholder="Enter an ingredient"
|
||||||
@input="onInput"
|
|
||||||
@keyup.enter="updateIngredient"
|
@keyup.enter="updateIngredient"
|
||||||
@blur="updateIngredient"
|
@blur="updateIngredient"
|
||||||
>
|
>
|
||||||
|
|
@ -31,7 +30,6 @@ import type { Ingredient } from '@/domain/types'
|
||||||
|
|
||||||
const props = defineProps<{ ingredient: Ingredient }>()
|
const props = defineProps<{ ingredient: Ingredient }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update-line', ingredient: Ingredient, newLine: string): void
|
|
||||||
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
|
(e: 'update-ingredient', ingredient: Ingredient, newLine: string): void
|
||||||
(e: 'update-product-link', ingredient: Ingredient, link: string): void
|
(e: 'update-product-link', ingredient: Ingredient, link: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -59,20 +57,6 @@ function updateProductLink() {
|
||||||
emit('update-product-link', props.ingredient, productLink.value)
|
emit('update-product-link', props.ingredient, productLink.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit raw line changes so parent stays in sync even before parse
|
|
||||||
function isHtmlInput(el: EventTarget | null): el is HTMLInputElement {
|
|
||||||
return typeof HTMLElement !== 'undefined' && el instanceof HTMLInputElement
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInput(e: Event) {
|
|
||||||
let val = ingredientText.value
|
|
||||||
const t = e.target
|
|
||||||
if (isHtmlInput(t)) {
|
|
||||||
val = t.value
|
|
||||||
}
|
|
||||||
if (val !== props.ingredient.line) emit('update-line', props.ingredient, val)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,23 @@
|
||||||
:date="meal.suggestedDate ?? new Date()"
|
:date="meal.suggestedDate ?? new Date()"
|
||||||
@date-selected="selectDate"
|
@date-selected="selectDate"
|
||||||
/>
|
/>
|
||||||
<div class="members-list">
|
<div class="persons-list">
|
||||||
Cooked by
|
Cooked by
|
||||||
<member-list
|
<person-list
|
||||||
:people="meal.chefs"
|
:people="meal.chefs"
|
||||||
@remove="(p) => removePerson('chefs', p)"
|
@remove-person="(p) => removePerson('chefs', p)"
|
||||||
@add="(p) => addPerson('chefs', p)"
|
@add-person="(p) => addPerson('chefs', p)"
|
||||||
/>
|
/>
|
||||||
for
|
for
|
||||||
<member-list
|
<person-list
|
||||||
:people="meal.consumers"
|
:people="meal.consumers"
|
||||||
@remove="(p) => removePerson('consumers', p)"
|
@remove-person="(p) => removePerson('consumers', p)"
|
||||||
@add="(p) => addPerson('consumers', p)"
|
@add-person="(p) => addPerson('consumers', p)"
|
||||||
/>, with
|
/>, with
|
||||||
<member-list
|
<person-list
|
||||||
:people="meal.cleanup"
|
:people="meal.cleanup"
|
||||||
@remove="(p) => removePerson('cleanup', p)"
|
@remove-person="(p) => removePerson('cleanup', p)"
|
||||||
@add="(p) => addPerson('cleanup', p)"
|
@add-person="(p) => addPerson('cleanup', p)"
|
||||||
/>
|
/>
|
||||||
on cleanup.
|
on cleanup.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,7 +102,6 @@
|
||||||
@on-add="addIngredient"
|
@on-add="addIngredient"
|
||||||
@on-delete="deleteIngredient"
|
@on-delete="deleteIngredient"
|
||||||
@on-update-ingredient="updateIngredient"
|
@on-update-ingredient="updateIngredient"
|
||||||
@on-update-line="updateIngredientLine"
|
|
||||||
@on-editing="onEditAdditionalIngredients"
|
@on-editing="onEditAdditionalIngredients"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -119,15 +118,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, onBeforeMount } from 'vue'
|
import { reactive, onBeforeMount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { toMealEdit } from '@/router/links'
|
|
||||||
import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
|
import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
|
||||||
import { toMealInput } from '@/domain/decoders'
|
import { toMealInput } from '@/domain/decoders'
|
||||||
import { currentUser } from '@/api/auth'
|
import { currentUser } from '@/api/auth'
|
||||||
import { useAlert } from '@/composables/useAlert'
|
import { useAlert } from '@/composables/useAlert'
|
||||||
import { parseRouteId } from '@/router/helpers'
|
import { parseRouteId } from '@/router/helpers'
|
||||||
import type { Ingredient, Meal, MealRecipe } from '@/domain/types'
|
import type { Person, Ingredient, Meal, MealRecipe } from '@/domain/types'
|
||||||
import { listMembers } from '@/api/households'
|
|
||||||
import { selectMyMember } from '@/domain/members'
|
|
||||||
|
|
||||||
import { ago } from '@/dateformats'
|
import { ago } from '@/dateformats'
|
||||||
|
|
||||||
|
|
@ -135,14 +131,12 @@ import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
|
||||||
import RecipeCard from '@/components/recipes/RecipeCard.vue'
|
import RecipeCard from '@/components/recipes/RecipeCard.vue'
|
||||||
import DatePicker from './DatePicker.vue'
|
import DatePicker from './DatePicker.vue'
|
||||||
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
|
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
|
||||||
import MemberList from './MemberList.vue'
|
import PersonList from './PersonList.vue'
|
||||||
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
|
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
|
||||||
const showIngredientsIcon = new URL('@/assets/show-ingredients.svg', import.meta.url).toString()
|
const showIngredientsIcon = new URL('@/assets/show-ingredients.svg', import.meta.url).toString()
|
||||||
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
|
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
|
||||||
|
|
||||||
import type { MemberRef } from '@/domain/types'
|
function addPersonIfNotExists(list: Person[], person: Person | null | undefined) {
|
||||||
|
|
||||||
function addPersonIfNotExists<T extends { id: number }>(list: T[], person: T | null | undefined) {
|
|
||||||
if (!person) return
|
if (!person) return
|
||||||
if (!list.find((p) => p.id === person.id)) {
|
if (!list.find((p) => p.id === person.id)) {
|
||||||
list.push(person)
|
list.push(person)
|
||||||
|
|
@ -153,13 +147,8 @@ const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { show: showAlert } = useAlert()
|
const { show: showAlert } = useAlert()
|
||||||
|
|
||||||
// No child refs; parent maintains source of truth for lines
|
|
||||||
|
|
||||||
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
|
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
|
||||||
|
|
||||||
// Track which ingredient objects have unparsed edits so we only parse what changed
|
|
||||||
const dirtyLines = new Map<Ingredient, string>()
|
|
||||||
|
|
||||||
const meal = reactive<Meal>({
|
const meal = reactive<Meal>({
|
||||||
id: -1,
|
id: -1,
|
||||||
suggestedDate: new Date(),
|
suggestedDate: new Date(),
|
||||||
|
|
@ -178,15 +167,13 @@ onBeforeMount(async () => {
|
||||||
const loaded = await getMeal(id)
|
const loaded = await getMeal(id)
|
||||||
Object.assign(meal, loaded)
|
Object.assign(meal, loaded)
|
||||||
} else {
|
} else {
|
||||||
const [user, members] = await Promise.all([currentUser(), listMembers()])
|
const self = await currentUser()
|
||||||
const fallback = Array.isArray(members) && members.length > 0 ? { id: members[0]!.id, displayName: members[0]!.displayName } : null
|
if (self) {
|
||||||
const me = selectMyMember(members, user) ?? fallback
|
meal.chefs = [self]
|
||||||
if (me) {
|
meal.consumers = [self]
|
||||||
meal.chefs = [me]
|
meal.cleanup = [self]
|
||||||
meal.consumers = [me]
|
}
|
||||||
meal.cleanup = [me]
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function selectDate(date: Date) {
|
function selectDate(date: Date) {
|
||||||
|
|
@ -213,37 +200,28 @@ function deleteIngredient(ingredient: Ingredient) {
|
||||||
|
|
||||||
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
|
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
|
||||||
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
|
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
|
||||||
// Clear dirty status for this row (was parsed and replaced)
|
|
||||||
if (dirtyLines.has(ingredient)) dirtyLines.delete(ingredient)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIngredientLine(ingredient: Ingredient, newLine: string) {
|
function removePerson(list: PeopleKey, person: Person) {
|
||||||
// Update the raw line immediately so Save has the latest text
|
|
||||||
const idx = meal.extraIngredients.indexOf(ingredient)
|
|
||||||
if (idx >= 0) {
|
|
||||||
// mutate in place to preserve object identity (used as dirtyLines key)
|
|
||||||
const target = meal.extraIngredients[idx]
|
|
||||||
if (target) target.line = newLine
|
|
||||||
} else {
|
|
||||||
// fallback (shouldn't generally happen)
|
|
||||||
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? { ...i, line: newLine } : i))
|
|
||||||
}
|
|
||||||
// Mark as dirty to parse later (on save) if needed
|
|
||||||
dirtyLines.set(ingredient, newLine)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePerson(list: PeopleKey, person: MemberRef) {
|
|
||||||
meal[list] = meal[list].filter((p) => p.id !== person.id)
|
meal[list] = meal[list].filter((p) => p.id !== person.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addPerson(list: PeopleKey, person: MemberRef) {
|
function addPerson(list: PeopleKey, person: Person) {
|
||||||
addPersonIfNotExists(meal[list], person)
|
addPersonIfNotExists(meal[list], person)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectRecipe(recipe: { id: number | string }) {
|
async function selectRecipe(recipe: { id: number | string }) {
|
||||||
// Refetch to get additional details
|
// Refetch to get additional details
|
||||||
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
|
const r = await getRecipe(recipe.id)
|
||||||
const r = await getRecipe(slug, recipe.id)
|
|
||||||
|
if (r.createdBy) {
|
||||||
|
addPersonIfNotExists(meal.chefs, r.createdBy)
|
||||||
|
addPersonIfNotExists(meal.consumers, r.createdBy)
|
||||||
|
|
||||||
|
if (meal.cleanup.length === 0) {
|
||||||
|
addPersonIfNotExists(meal.cleanup, r.createdBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
|
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
|
||||||
}
|
}
|
||||||
|
|
@ -251,8 +229,8 @@ async function selectRecipe(recipe: { id: number | string }) {
|
||||||
async function onEditAdditionalIngredients(editing: boolean) {
|
async function onEditAdditionalIngredients(editing: boolean) {
|
||||||
if (editing && meal.extraIngredients.length === 0) {
|
if (editing && meal.extraIngredients.length === 0) {
|
||||||
addIngredient()
|
addIngredient()
|
||||||
} else {
|
} else {
|
||||||
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => !!i.line)
|
meal.extraIngredients = meal.extraIngredients.filter((i) => !!i.line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,70 +257,19 @@ function scaleIngredients(mealRecipe: MealRecipe) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSaveMeal() {
|
async function onSaveMeal() {
|
||||||
try {
|
|
||||||
// Blur any focused input so its change handlers run
|
|
||||||
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
|
|
||||||
document.activeElement.blur()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Drop any empty-line rows and clear their dirty flags
|
|
||||||
const nonEmpty: Ingredient[] = []
|
|
||||||
for (const ing of meal.extraIngredients) {
|
|
||||||
const line = typeof ing.line === 'string' ? ing.line.trim() : ''
|
|
||||||
if (line.length === 0) {
|
|
||||||
// also clear dirty if present
|
|
||||||
if (dirtyLines.has(ing)) dirtyLines.delete(ing)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nonEmpty.push(ing)
|
|
||||||
}
|
|
||||||
meal.extraIngredients = nonEmpty
|
|
||||||
|
|
||||||
// 2) Build list of only the dirty lines that still exist in the array
|
|
||||||
const dirtyEntries: Array<{ ing: Ingredient; line: string }> = []
|
|
||||||
for (const [ing, line] of dirtyLines.entries()) {
|
|
||||||
// only consider ingredients still present
|
|
||||||
if (meal.extraIngredients.includes(ing)) {
|
|
||||||
const t = typeof line === 'string' ? line.trim() : ''
|
|
||||||
if (t.length > 0) dirtyEntries.push({ ing, line: t })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Parse only dirty lines
|
|
||||||
if (dirtyEntries.length > 0) {
|
|
||||||
const lines = dirtyEntries.map((e) => e.line)
|
|
||||||
const parsed = await (await import('@/api/sdk')).parseIngredients(lines)
|
|
||||||
// Replace corresponding rows by identity
|
|
||||||
parsed.forEach((p, idx) => {
|
|
||||||
const target = dirtyEntries[idx]?.ing
|
|
||||||
if (!target) return
|
|
||||||
const i = meal.extraIngredients.indexOf(target)
|
|
||||||
if (i >= 0) meal.extraIngredients.splice(i, 1, p)
|
|
||||||
// Clear dirty marker for this ingredient object
|
|
||||||
dirtyLines.delete(target)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final safety: drop any zero-quantity items (should be rare post-parse)
|
|
||||||
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => (typeof i.quantity === 'number' ? i.quantity > 0 : true))
|
|
||||||
|
|
||||||
const saved = await saveMeal(toMealInput(meal))
|
const saved = await saveMeal(toMealInput(meal))
|
||||||
if (saved && saved.id >= 0) {
|
if (saved && saved.id >= 0) {
|
||||||
Object.assign(meal, saved)
|
Object.assign(meal, saved)
|
||||||
router.push(toMealEdit(saved.id))
|
router.push(`/meals/${saved.id}`)
|
||||||
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
|
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showAlert({
|
showAlert({
|
||||||
heading: 'Error saving meal',
|
heading: 'Error saving meal',
|
||||||
message: 'An unknown error occurred while saving the meal',
|
message: 'An error occurred while saving the meal',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save meal'
|
|
||||||
showAlert({ heading: 'Error saving meal', message, type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -352,17 +279,17 @@ img.icon {
|
||||||
height: 2em;
|
height: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.members-list {
|
.persons-list {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 1ex 2em;
|
padding: 1ex 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.members-list p {
|
.persons-list p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-bottom: 1em;
|
padding-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-list li {
|
.person-list li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
v-for="(chef, index) in meal.chefs"
|
v-for="(chef, index) in meal.chefs"
|
||||||
:key="chef.id"
|
:key="chef.id"
|
||||||
>
|
>
|
||||||
{{ chef.displayName }}{{ englishSeperator(index, meal.chefs) }}
|
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!meal.chefs.length">somebody?</span>
|
<span v-if="!meal.chefs.length">somebody?</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
v-for="(consumer, index) in meal.consumers"
|
v-for="(consumer, index) in meal.consumers"
|
||||||
:key="consumer.id"
|
:key="consumer.id"
|
||||||
>
|
>
|
||||||
{{ consumer.displayName }}{{ englishSeperator(index, meal.consumers) }}
|
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!meal.consumers.length">somebody?</span>
|
<span v-if="!meal.consumers.length">somebody?</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<li>
|
<li>
|
||||||
<router-link
|
<router-link
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:to="{ name: 'meal-edit', params: { householdSlug: slug, id: selectedMeal.id } }"
|
:to="`/meals/${selectedMeal.id}`"
|
||||||
active-class="active"
|
active-class="active"
|
||||||
>
|
>
|
||||||
Edit Meal
|
Edit Meal
|
||||||
|
|
@ -45,14 +45,13 @@
|
||||||
<action-item
|
<action-item
|
||||||
title="Plan Meal"
|
title="Plan Meal"
|
||||||
:image="planMeal"
|
:image="planMeal"
|
||||||
@click="() => $router.push({ name: 'meal-add', params: { householdSlug: slug } })"
|
@click="() => $router.push('/meals/add')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onBeforeMount } from 'vue'
|
import { ref, onBeforeMount } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import ActionItem from '@/components/ActionItem.vue'
|
import ActionItem from '@/components/ActionItem.vue'
|
||||||
import MealCard from '@/components/meals/MealCard.vue'
|
import MealCard from '@/components/meals/MealCard.vue'
|
||||||
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/api/sdk'
|
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/api/sdk'
|
||||||
|
|
@ -68,8 +67,6 @@ to.setDate(to.getDate() + 7)
|
||||||
|
|
||||||
const meals = ref([])
|
const meals = ref([])
|
||||||
const selectedMeal = ref(null)
|
const selectedMeal = ref(null)
|
||||||
const route = useRoute()
|
|
||||||
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
|
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
meals.value = await getUpcomingMeals(from, to)
|
meals.value = await getUpcomingMeals(from, to)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="member-list">
|
<span class="person-list">
|
||||||
<span
|
<span
|
||||||
v-for="person in people"
|
v-for="person in people"
|
||||||
:key="person.id"
|
:key="person.id"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="member-circle remove-person"
|
class="person-circle remove-person"
|
||||||
@click="removePerson(person)"
|
@click="removePerson(person)"
|
||||||
>
|
>
|
||||||
{{ person.displayName || '' }}
|
{{ person.name }}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<button
|
<button
|
||||||
v-if="!isAddingPerson"
|
v-if="!isAddingPerson"
|
||||||
class="member-circle add-person"
|
class="person-circle add-person"
|
||||||
@click="isAddingPerson = true"
|
@click="isAddingPerson = true"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|
@ -30,17 +30,17 @@
|
||||||
<ul
|
<ul
|
||||||
v-if="isAddingPerson && searchResults.length"
|
v-if="isAddingPerson && searchResults.length"
|
||||||
ref="persondroplist"
|
ref="persondroplist"
|
||||||
class="member-droplist"
|
class="person-droplist"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="person in searchResults"
|
v-for="person in searchResults"
|
||||||
:key="person.id"
|
:key="person.id"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="member-circle add-person"
|
class="person-circle add-person"
|
||||||
@mousedown="addPerson(person)"
|
@mousedown="addPerson(person)"
|
||||||
>
|
>
|
||||||
{{ person.displayName || '' }}
|
{{ person.name }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -50,35 +50,35 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { listMembers } from '@/api/households'
|
import { searchPersons } from '@/api/sdk'
|
||||||
import type { MemberRef } from '@/domain/types'
|
import type { Person } from '@/domain/types'
|
||||||
|
const props = withDefaults(defineProps<{ people?: Person[] }>(), { people: () => [] })
|
||||||
const props = withDefaults(defineProps<{ people?: MemberRef[] }>(), { people: () => [] })
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'add', person: MemberRef): void
|
(e: 'add-person', person: Person): void
|
||||||
(e: 'remove', person: MemberRef): void
|
(e: 'remove-person', person: Person): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isAddingPerson = ref(false)
|
const isAddingPerson = ref(false)
|
||||||
const searchName = ref('')
|
const searchName = ref('')
|
||||||
const searchResults = ref<MemberRef[]>([])
|
const searchResults = ref<Person[]>([])
|
||||||
|
|
||||||
// Template refs for DOM elements
|
// Template refs for DOM elements
|
||||||
const searchNameInput = ref<HTMLInputElement | null>(null)
|
const searchNameInput = ref<HTMLInputElement | null>(null)
|
||||||
const persondroplist = ref<HTMLUListElement | null>(null)
|
const persondroplist = ref<HTMLUListElement | null>(null)
|
||||||
|
|
||||||
async function updateSearchResults() {
|
async function updateSearchResults() {
|
||||||
const q = searchName.value.trim().toLowerCase()
|
const q = searchName.value.trim()
|
||||||
const allMembers = await listMembers()
|
if (!q) {
|
||||||
const idSet = new Set(props.people.map((p) => p.id))
|
searchResults.value = []
|
||||||
let results: MemberRef[] = allMembers.filter((p) => !idSet.has(p.id))
|
return
|
||||||
if (q) {
|
|
||||||
results = results.filter((p) => p.displayName.toLowerCase().includes(q))
|
|
||||||
}
|
}
|
||||||
searchResults.value = results
|
const page = await searchPersons(q)
|
||||||
|
const results: Person[] = page.items.map((p) => ({ id: p.id, name: p.name }))
|
||||||
|
const idSet = new Set(props.people.map((p) => p.id))
|
||||||
|
searchResults.value = results.filter((p: Person) => !idSet.has(p.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function addPerson(person?: MemberRef) {
|
function addPerson(person?: Person) {
|
||||||
if (!person && searchResults.value.length > 0) {
|
if (!person && searchResults.value.length > 0) {
|
||||||
person = searchResults.value[0]
|
person = searchResults.value[0]
|
||||||
}
|
}
|
||||||
|
|
@ -89,15 +89,15 @@ function addPerson(person?: MemberRef) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!props.people.find((p) => p.id === person.id)) {
|
if (!props.people.find((p) => p.id === person.id)) {
|
||||||
emit('add', person)
|
emit('add-person', person)
|
||||||
}
|
}
|
||||||
searchName.value = ''
|
searchName.value = ''
|
||||||
searchResults.value = []
|
searchResults.value = []
|
||||||
isAddingPerson.value = false
|
isAddingPerson.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePerson(person: MemberRef) {
|
function removePerson(person: Person) {
|
||||||
emit('remove', person)
|
emit('remove-person', person)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watchers
|
// Watchers
|
||||||
|
|
@ -123,7 +123,7 @@ watch([persondroplist, searchNameInput], ([drop, input]) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.member-list {
|
.person-list {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
|
|
@ -132,15 +132,15 @@ watch([persondroplist, searchNameInput], ([drop, input]) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove all the button styling */
|
/* Remove all the button styling */
|
||||||
.member-circle {
|
.person-circle {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show the initials of the member in a circle */
|
/* Show the initials of the person in a circle */
|
||||||
.member-circle {
|
.person-circle {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
|
|
@ -182,7 +182,7 @@ watch([persondroplist, searchNameInput], ([drop, input]) => {
|
||||||
background-color: green;
|
background-color: green;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-droplist {
|
.person-droplist {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
|
|
@ -195,7 +195,7 @@ watch([persondroplist, searchNameInput], ([drop, input]) => {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-droplist li {
|
.person-droplist li {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline;
|
display: inline;
|
||||||
|
|
@ -75,8 +75,7 @@ import { useAlert } from '@/composables/useAlert'
|
||||||
import { parseQueryString } from '@/router/helpers'
|
import { parseQueryString } from '@/router/helpers'
|
||||||
import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/sdk'
|
import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/sdk'
|
||||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||||
import type { Recipe as DomainRecipe, Ingredient } from '@/domain/types'
|
import type { Recipe as DomainRecipe, Ingredient, RecipeInput } from '@/domain/types'
|
||||||
import type { components } from '@/api/types'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: { type: String, required: false, default: undefined },
|
id: { type: String, required: false, default: undefined },
|
||||||
|
|
@ -84,7 +83,6 @@ const props = defineProps({
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
|
|
||||||
const { show: showAlert } = useAlert()
|
const { show: showAlert } = useAlert()
|
||||||
|
|
||||||
const link = ref<string>(parseQueryString(route.query.url))
|
const link = ref<string>(parseQueryString(route.query.url))
|
||||||
|
|
@ -103,19 +101,19 @@ const image_styling = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
function parseLink() {
|
function parseLink() {
|
||||||
router.push({ name: 'recipe-add', params: { householdSlug: slug }, query: { url: link.value } })
|
router.push({ path: '/recipes/add', query: { url: link.value } })
|
||||||
refreshRecipe()
|
refreshRecipe()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRecipe() {
|
async function refreshRecipe() {
|
||||||
const id = props.id ? parseInt(props.id) : null
|
const id = props.id ? parseInt(props.id) : null
|
||||||
if (id !== null && id >= 0) {
|
if (id !== null && id >= 0) {
|
||||||
const r = await getRecipe(slug, id)
|
const r = await getRecipe(id)
|
||||||
recipe.value = r
|
recipe.value = r
|
||||||
link.value = r.link ?? ''
|
link.value = r.link ?? ''
|
||||||
return
|
return
|
||||||
} else if (link.value) {
|
} else if (link.value) {
|
||||||
const r = await parseRecipe(link.value, slug)
|
const r = await parseRecipe(link.value)
|
||||||
recipe.value = r
|
recipe.value = r
|
||||||
parse_failed.value = !r
|
parse_failed.value = !r
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -136,10 +134,10 @@ function deleteIngredient(ingredient: Ingredient) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRecipe() {
|
async function saveRecipe() {
|
||||||
const saved = recipe.value ? await saveRecipeApi(toRecipeCreate(recipe.value)) : null
|
const saved = recipe.value ? await saveRecipeApi(toRecipeInput(recipe.value)) : null
|
||||||
if (saved && saved.id >= 0) {
|
if (saved && saved.id >= 0) {
|
||||||
showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' })
|
showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' })
|
||||||
router.push({ name: 'recipe-edit', params: { householdSlug: slug, id: saved.id } })
|
router.push(`/recipes/${saved.id}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showAlert({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' })
|
showAlert({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' })
|
||||||
|
|
@ -149,14 +147,13 @@ function createFromScratch() {
|
||||||
recipe.value = {
|
recipe.value = {
|
||||||
id: -1,
|
id: -1,
|
||||||
name: 'My new recipe',
|
name: 'My new recipe',
|
||||||
|
createdById: -1,
|
||||||
link: '',
|
link: '',
|
||||||
ingredients: [],
|
ingredients: [],
|
||||||
imageUrls: [],
|
imageUrls: [],
|
||||||
serves: 1,
|
serves: 1,
|
||||||
createdById: -1,
|
dateCreated: new Date(),
|
||||||
createdBy: null,
|
dateHidden: null,
|
||||||
hiddenById: null,
|
|
||||||
hiddenBy: null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,7 +168,7 @@ async function deleteRecipe() {
|
||||||
if (confirm('Are you sure you want to delete this recipe?')) {
|
if (confirm('Are you sure you want to delete this recipe?')) {
|
||||||
if (!recipe.value) return
|
if (!recipe.value) return
|
||||||
await deleteRecipeApi(recipe.value.id)
|
await deleteRecipeApi(recipe.value.id)
|
||||||
router.push({ name: 'recipes', params: { householdSlug: slug } })
|
router.push('/recipes')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,13 +189,21 @@ watch(
|
||||||
)
|
)
|
||||||
|
|
||||||
// expose functions for template binding names (automatic in <script setup>)
|
// expose functions for template binding names (automatic in <script setup>)
|
||||||
function toRecipeCreate(r: DomainRecipe): components['schemas']['RecipeCreate-Input'] {
|
function toRecipeInput(r: DomainRecipe): RecipeInput {
|
||||||
return {
|
return {
|
||||||
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
link: r.link,
|
link: r.link,
|
||||||
serves: r.serves,
|
serves: r.serves,
|
||||||
imageUrls: r.imageUrls ?? [],
|
imageUrls: r.imageUrls ?? [],
|
||||||
ingredients: r.ingredients ?? [],
|
ingredients: r.ingredients ?? [],
|
||||||
|
basedOnRecipe: r.basedOnRecipe ?? null,
|
||||||
|
// let backend set created/hidden dates
|
||||||
|
createdById: r.createdById,
|
||||||
|
createdBy: r.createdBy ?? null,
|
||||||
|
// dateHidden omitted
|
||||||
|
hiddenById: r.hiddenById ?? null,
|
||||||
|
hiddenBy: r.hiddenBy ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { toRecipeAdd, toRecipeEdit } from '@/router/links'
|
|
||||||
import ActionItem from '@/components/ActionItem.vue'
|
import ActionItem from '@/components/ActionItem.vue'
|
||||||
import RecipeSearchBox from './RecipeSearchBox.vue'
|
import RecipeSearchBox from './RecipeSearchBox.vue'
|
||||||
import type { Recipe } from '@/domain/types'
|
import type { Recipe } from '@/domain/types'
|
||||||
|
|
@ -23,10 +22,10 @@ const addRecipe = new URL('@/assets/add-recipe.svg', import.meta.url).toString()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
function onSelectRecipe(r: Pick<Recipe, 'id'>) {
|
function onSelectRecipe(r: Pick<Recipe, 'id'>) {
|
||||||
router.push(toRecipeEdit(r.id))
|
router.push(`/recipes/${r.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddRecipe() {
|
function onAddRecipe() {
|
||||||
router.push(toRecipeAdd())
|
router.push('/recipes/add')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onBeforeMount } from 'vue'
|
import { ref, computed, onBeforeMount } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { toShoppingList } from '@/router/links'
|
|
||||||
import { useAlert } from '@/composables/useAlert'
|
import { useAlert } from '@/composables/useAlert'
|
||||||
import { useShopping } from '@/composables/useShopping'
|
import { useShopping } from '@/composables/useShopping'
|
||||||
import { getUpcomingMeals } from '@/api/sdk'
|
import { getUpcomingMeals } from '@/api/sdk'
|
||||||
|
|
@ -158,21 +157,13 @@ async function loadData() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mealSelected(meal: { id: number }) {
|
async function mealSelected(meal: { id: number }) {
|
||||||
try {
|
await requestMeal(meal.id)
|
||||||
await requestMeal(meal.id)
|
await loadData()
|
||||||
await loadData()
|
|
||||||
} catch (err) {
|
|
||||||
showAlert({ type: 'error', heading: 'Failed to include meal', message: err instanceof Error ? err.message : 'Unknown error' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mealUnselected(meal: { id: number }) {
|
async function mealUnselected(meal: { id: number }) {
|
||||||
try {
|
await unrequestMeal(meal.id)
|
||||||
await unrequestMeal(meal.id)
|
await loadData()
|
||||||
await loadData()
|
|
||||||
} catch (err) {
|
|
||||||
showAlert({ type: 'error', heading: 'Failed to un-include meal', message: err instanceof Error ? err.message : 'Unknown error' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markFound() {
|
async function markFound() {
|
||||||
|
|
@ -192,7 +183,7 @@ async function markPurchased() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selected.value = []
|
selected.value = []
|
||||||
router.push(toShoppingList(shopping.id))
|
router.push(`/shopping/${shopping.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelect(item: Group) {
|
function toggleSelect(item: Group) {
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1>My Shopping List</h1>
|
<h1>My Shopping List</h1>
|
||||||
<router-link :to="{ name: 'shopping-current', params: { householdSlug: slug } }">
|
<router-link :to="`/shopping/current`">
|
||||||
Full Shopping List
|
Full Shopping List
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div class="adder">
|
|
||||||
<label>
|
|
||||||
Add existing ingredient:
|
|
||||||
<select v-model.number="selectedIngredientId">
|
|
||||||
<option :value="-1">-- select --</option>
|
|
||||||
<option
|
|
||||||
v-for="opt in availableIngredientOptions"
|
|
||||||
:key="opt.id"
|
|
||||||
:value="opt.id"
|
|
||||||
>
|
|
||||||
{{ opt.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
:disabled="selectedIngredientId < 0"
|
|
||||||
@click="addSelectedIngredient"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<editable-ingredients-panel
|
<editable-ingredients-panel
|
||||||
:ingredients="ingredients"
|
:ingredients="ingredients"
|
||||||
edit-only
|
|
||||||
@on-add="addIngredient"
|
@on-add="addIngredient"
|
||||||
@on-delete="deleteIngredient"
|
@on-delete="deleteIngredient"
|
||||||
@on-update-ingredient="updateIngredient"
|
@on-update-ingredient="updateIngredient"
|
||||||
|
|
@ -64,97 +41,47 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onBeforeMount } from 'vue'
|
import { ref, onBeforeMount } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { useAlert } from '@/composables/useAlert'
|
|
||||||
import { useShopping } from '@/composables/useShopping'
|
import { useShopping } from '@/composables/useShopping'
|
||||||
import type { Ingredient } from '@/domain/types'
|
import type { Ingredient } from '@/domain/types'
|
||||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
|
||||||
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
|
|
||||||
const { loadUser } = useAuth()
|
const { loadUser } = useAuth()
|
||||||
const { show: showAlert } = useAlert()
|
const { getMyShoppingList, saveMyShoppingList } = useShopping()
|
||||||
const { getCurrentShoppingList, requestIngredient, unrequestIngredient } = useShopping()
|
|
||||||
|
|
||||||
const ingredients = ref<Ingredient[]>([])
|
const ingredients = ref<Ingredient[]>([])
|
||||||
let loading = false
|
|
||||||
const availableIngredientOptions = ref<{ id: number; name: string }[]>([])
|
|
||||||
const selectedIngredientId = ref<number>(-1)
|
|
||||||
|
|
||||||
async function refreshFromServer() {
|
async function updateShoppingList(save = false) {
|
||||||
const dto = await getCurrentShoppingList()
|
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList()
|
||||||
// Personal ad-hoc ingredients are those in outstandingItems with no recipeId and no mealId
|
ingredients.value = newIngredients.map((i) => ({ ...i }))
|
||||||
const personalItems = (dto.outstandingItems ?? []).filter((i) => (i.recipeId ?? null) == null && (i.mealId ?? null) == null)
|
|
||||||
ingredients.value = personalItems.map((i) => ({
|
|
||||||
id: i.ingredientId,
|
|
||||||
name: dto.ingredientsLookup?.[String(i.ingredientId)]?.name ?? '',
|
|
||||||
line: dto.ingredientsLookup?.[String(i.ingredientId)]?.line ?? '',
|
|
||||||
unit: dto.ingredientsLookup?.[String(i.ingredientId)]?.unit ?? 'Items',
|
|
||||||
quantity: dto.ingredientsLookup?.[String(i.ingredientId)]?.quantity ?? 1,
|
|
||||||
preparation: dto.ingredientsLookup?.[String(i.ingredientId)]?.preparation ?? '',
|
|
||||||
productId: dto.ingredientsLookup?.[String(i.ingredientId)]?.productId ?? null,
|
|
||||||
recipeId: null,
|
|
||||||
mealId: null,
|
|
||||||
product: dto.ingredientsLookup?.[String(i.ingredientId)]?.product ?? null,
|
|
||||||
}))
|
|
||||||
// Populate available ingredient options from lookup
|
|
||||||
const lookup = dto.ingredientsLookup ?? {}
|
|
||||||
availableIngredientOptions.value = Object.keys(lookup)
|
|
||||||
.map((k) => ({ id: Number(k), name: lookup[k]?.name ?? `#${k}` }))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addIngredient() {
|
function addIngredient() {
|
||||||
if (loading) return
|
|
||||||
// Add a blank ingredient locally to allow text entry; upon edit, we parse line to an Ingredient
|
|
||||||
ingredients.value = [
|
ingredients.value = [
|
||||||
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null, productId: null, recipeId: null, mealId: null },
|
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null },
|
||||||
...ingredients.value,
|
...ingredients.value,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addSelectedIngredient() {
|
function deleteIngredient(ingredient: Ingredient) {
|
||||||
try {
|
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
|
||||||
const id = selectedIngredientId.value
|
|
||||||
if (typeof id !== 'number' || id < 0) return
|
|
||||||
await requestIngredient(id)
|
|
||||||
selectedIngredientId.value = -1
|
|
||||||
await refreshFromServer()
|
|
||||||
} catch (e) {
|
|
||||||
showAlert({ type: 'error', message: e instanceof Error ? e.message : 'Failed to add item' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteIngredient(ingredient: Ingredient) {
|
|
||||||
try {
|
|
||||||
if (ingredient.id && ingredient.id >= 0) {
|
|
||||||
await unrequestIngredient(ingredient.id)
|
|
||||||
await refreshFromServer()
|
|
||||||
}
|
|
||||||
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
|
|
||||||
} catch (e) {
|
|
||||||
showAlert({ type: 'error', message: e instanceof Error ? e.message : 'Failed to remove item' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIngredient(oldIngredient: Ingredient, newIngredient: Ingredient) {
|
function updateIngredient(oldIngredient: Ingredient, newIngredient: Ingredient) {
|
||||||
// Local edit only; ad-hoc add/remove is handled by the selector and delete button
|
|
||||||
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
|
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEditing(isStartingEdit: boolean) {
|
async function onEditing(isStartingEdit: boolean) {
|
||||||
if (isStartingEdit) {
|
await updateShoppingList(!isStartingEdit)
|
||||||
await refreshFromServer()
|
if (isStartingEdit && ingredients.value.length === 0) addIngredient()
|
||||||
if (ingredients.value.length === 0) await addIngredient()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
const u = await loadUser()
|
const u = await loadUser()
|
||||||
if (!u) return router.push({ name: 'login' })
|
if (!u) return router.push({ name: 'login' })
|
||||||
await refreshFromServer()
|
await updateShoppingList()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,11 @@
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="source.recipe && source.ingredient">
|
<span v-else-if="source.recipe && source.ingredient">
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} in
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} in
|
||||||
<router-link :to="toRecipeEdit(source.recipe.id)">{{
|
<router-link :to="`/recipes/${source.recipe.id}/`">{{
|
||||||
source.recipe.name
|
source.recipe.name
|
||||||
}}</router-link>
|
}}</router-link>
|
||||||
for
|
for
|
||||||
<router-link :to="source.meal ? toMealEdit(source.meal.id) : toRecipes()">{{
|
<router-link :to="`/meals/${source.meal?.id}/`">{{
|
||||||
source.meal?.suggestedDate
|
source.meal?.suggestedDate
|
||||||
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
|
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="source.meal && source.ingredient">
|
<span v-else-if="source.meal && source.ingredient">
|
||||||
{{ source.ingredient.line }} for
|
{{ source.ingredient.line }} for
|
||||||
<router-link :to="source.meal ? toMealEdit(source.meal.id) : toMealPlan()">{{
|
<router-link :to="`/meals/${source.meal?.id}/`">{{
|
||||||
source.meal?.suggestedDate
|
source.meal?.suggestedDate
|
||||||
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
|
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
|
|
@ -84,11 +84,11 @@
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="source.recipe && source.ingredient">
|
<span v-else-if="source.recipe && source.ingredient">
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} in
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} in
|
||||||
<router-link :to="toRecipeEdit(source.recipe.id)">{{
|
<router-link :to="`/recipes/${source.recipe.id}/`">{{
|
||||||
source.recipe.name
|
source.recipe.name
|
||||||
}}</router-link>
|
}}</router-link>
|
||||||
for
|
for
|
||||||
<router-link :to="source.meal ? toMealEdit(source.meal.id) : toRecipes()">{{
|
<router-link :to="`/meals/${source.meal?.id ?? ''}/`">{{
|
||||||
source.meal?.suggestedDate
|
source.meal?.suggestedDate
|
||||||
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
|
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
|
|
@ -100,7 +100,7 @@
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="source.meal && source.ingredient">
|
<span v-else-if="source.meal && source.ingredient">
|
||||||
{{ source.ingredient.line }} for
|
{{ source.ingredient.line }} for
|
||||||
<router-link :to="toMealEdit(source.meal.id)">{{
|
<router-link :to="`/meals/${source.meal.id}/`">{{
|
||||||
source.meal.suggestedDate
|
source.meal.suggestedDate
|
||||||
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
|
? source.meal.suggestedDate.toLocaleDateString('en-AU', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
|
|
@ -118,13 +118,11 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { toMealEdit, toRecipeEdit, toMealPlan, toRecipes } from '@/router/links'
|
|
||||||
import { ago } from '@/dateformats'
|
import { ago } from '@/dateformats'
|
||||||
import { calculateTotals } from '@/units'
|
import { calculateTotals } from '@/units'
|
||||||
import type { Group } from '@/composables/useShopping'
|
import type { Group } from '@/composables/useShopping'
|
||||||
|
|
||||||
const props = defineProps<{ shoppingListItemGroup: Group }>()
|
const props = defineProps<{ shoppingListItemGroup: Group }>()
|
||||||
// Slug resolved via link helpers using current household context
|
|
||||||
|
|
||||||
const fallbackImg = new URL('@/assets/missing-product.svg', import.meta.url).toString()
|
const fallbackImg = new URL('@/assets/missing-product.svg', import.meta.url).toString()
|
||||||
const imageSrc = computed(() => {
|
const imageSrc = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { currentUser as apiCurrentUser, loginWithPassword as apiLoginWithPassword, logout as apiLogout, createAccount as apiCreateAccount } from '@/api/auth'
|
import { currentUser as apiCurrentUser, login as apiLogin } from '@/api/auth'
|
||||||
import { api } from '@/api/client'
|
import type { Person } from '@/domain/types'
|
||||||
import type { User } from '@/domain/types'
|
|
||||||
|
|
||||||
type Household = { id: number; name: string; slug: string }
|
const user = ref<Person | null>(null)
|
||||||
|
|
||||||
const user = ref<User | null>(null)
|
|
||||||
const households = ref<Household[]>([])
|
|
||||||
const activeHousehold = ref<Household | null>(null)
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
|
||||||
export async function loadUser() {
|
export async function loadUser() {
|
||||||
|
|
@ -18,58 +13,11 @@ export async function loadUser() {
|
||||||
return user.value
|
return user.value
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginWithPassword(email: string, password: string) {
|
export async function login(username: string) {
|
||||||
user.value = await apiLoginWithPassword(email, password)
|
user.value = await apiLogin(username)
|
||||||
return user.value
|
return user.value
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout() {
|
|
||||||
await apiLogout()
|
|
||||||
user.value = null
|
|
||||||
households.value = []
|
|
||||||
activeHousehold.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setHouseholds(hs: Household[]) {
|
|
||||||
households.value = Array.isArray(hs) ? hs.slice() : []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setActiveHousehold(h: Household | string | null) {
|
|
||||||
if (h == null) {
|
|
||||||
activeHousehold.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof h === 'string') {
|
|
||||||
activeHousehold.value = households.value.find((x) => x.slug === h) ?? null
|
|
||||||
} else {
|
|
||||||
activeHousehold.value = h
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
async function fetchHouseholds(): Promise<Household[]> {
|
return { user, loadUser, login }
|
||||||
const res = await api.GET('/api/v1/users/me/households', { params: {} })
|
|
||||||
const list = Array.isArray(res.data) ? res.data : []
|
|
||||||
const hs: Household[] = list.map((h) => ({ id: h.id, name: h.name, slug: h.slug }))
|
|
||||||
setHouseholds(hs)
|
|
||||||
return hs
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createHousehold(name: string): Promise<Household> {
|
|
||||||
const res = await api.POST('/api/v1/households', { body: { name } })
|
|
||||||
if (!res.response.ok || !res.data) throw new Error('Failed to create household')
|
|
||||||
const h: Household = { id: res.data.id, name: res.data.name, slug: res.data.slug }
|
|
||||||
// Update local state: append and set active
|
|
||||||
setHouseholds([...households.value, h])
|
|
||||||
setActiveHousehold(h)
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAccount(email: string, displayName: string, password: string) {
|
|
||||||
const u = await apiCreateAccount(email, displayName, password)
|
|
||||||
user.value = u
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
|
|
||||||
return { user, households, activeHousehold, loadUser, loginWithPassword, logout, setHouseholds, setActiveHousehold, fetchHouseholds, createHousehold, createAccount }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { computed, watchEffect } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { setHouseholdSlugProvider } from '@/api/client'
|
|
||||||
|
|
||||||
export function useHousehold() {
|
|
||||||
const route = useRoute()
|
|
||||||
const activeHouseholdSlug = computed<string | null>(() => {
|
|
||||||
const p = route.params?.householdSlug
|
|
||||||
return typeof p === 'string' ? p : null
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keep API client in sync with current route's household slug
|
|
||||||
watchEffect(() => {
|
|
||||||
setHouseholdSlugProvider(() => activeHouseholdSlug.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
return { activeHouseholdSlug }
|
|
||||||
}
|
|
||||||
|
|
@ -60,8 +60,9 @@ export function useShopping() {
|
||||||
purchaseShoppingList: sdk.purchaseShoppingList,
|
purchaseShoppingList: sdk.purchaseShoppingList,
|
||||||
requestMeal: sdk.requestMeal,
|
requestMeal: sdk.requestMeal,
|
||||||
unrequestMeal: sdk.unrequestMeal,
|
unrequestMeal: sdk.unrequestMeal,
|
||||||
requestIngredient: sdk.requestIngredient,
|
getMyShoppingList: sdk.getMyShoppingList,
|
||||||
unrequestIngredient: sdk.unrequestIngredient,
|
saveMyShoppingList: sdk.saveMyShoppingList,
|
||||||
|
// View-model helpers
|
||||||
groupsFrom,
|
groupsFrom,
|
||||||
mealsFrom,
|
mealsFrom,
|
||||||
async purchaseFromGroups(groups: Group[]) {
|
async purchaseFromGroups(groups: Group[]) {
|
||||||
|
|
|
||||||
|
|
@ -24,23 +24,27 @@ export function decodeLookup<TIn, TOut>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeRecipe(
|
export function decodeRecipe(
|
||||||
r: components['schemas']['RecipeOut'] | components['schemas']['Recipe'] | null | undefined
|
r: components['schemas']['RecipeOut'] | components['schemas']['Recipe-Output'] | null | undefined
|
||||||
): Recipe {
|
): Recipe {
|
||||||
if (!r) throw new Error('Invalid recipe payload')
|
if (!r) throw new Error('Invalid recipe payload')
|
||||||
const imageUrls = Array.isArray(r.imageUrls) ? r.imageUrls : []
|
// Normalize arrays that may be optional in legacy Recipe-Output
|
||||||
const ingredients = Array.isArray(r.ingredients) ? r.ingredients : []
|
const imageUrls = r.imageUrls ?? []
|
||||||
const link = typeof r.link === 'string' ? r.link : ''
|
const ingredients = r.ingredients ?? []
|
||||||
const serves = typeof r.serves === 'number' ? r.serves : 1
|
return {
|
||||||
const createdById = typeof r.createdById === 'number' ? r.createdById : -1
|
...r,
|
||||||
return { ...r, link, serves, createdById, imageUrls, ingredients }
|
imageUrls,
|
||||||
|
ingredients,
|
||||||
|
dateCreated: toDate(r.dateCreated),
|
||||||
|
dateHidden: toDate(r.dateHidden),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeMeal(
|
export function decodeMeal(
|
||||||
m: components['schemas']['MealOut'] | components['schemas']['Meal'] | null | undefined
|
m: components['schemas']['MealOut'] | components['schemas']['Meal-Output'] | null | undefined
|
||||||
): Meal {
|
): Meal {
|
||||||
if (!m) throw new Error('Invalid meal payload')
|
if (!m) throw new Error('Invalid meal payload')
|
||||||
const recipes = Array.isArray(m.recipes)
|
const recipes = Array.isArray(m.recipes)
|
||||||
? m.recipes.map((mr: components['schemas']['MealRecipe'] | null | undefined) => decodeMealRecipe(mr))
|
? m.recipes.map((mr) => decodeMealRecipe(mr))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -49,14 +53,14 @@ export function decodeMeal(
|
||||||
purchaseDate: toDate(m.purchaseDate),
|
purchaseDate: toDate(m.purchaseDate),
|
||||||
consumedDate: toDate(m.consumedDate),
|
consumedDate: toDate(m.consumedDate),
|
||||||
recipes,
|
recipes,
|
||||||
chefs: Array.isArray(m.chefs) ? m.chefs : [],
|
chefs: m.chefs ?? [],
|
||||||
consumers: Array.isArray(m.consumers) ? m.consumers : [],
|
consumers: m.consumers ?? [],
|
||||||
cleanup: Array.isArray(m.cleanup) ? m.cleanup : [],
|
cleanup: m.cleanup ?? [],
|
||||||
extraIngredients: Array.isArray(m.extraIngredients) ? m.extraIngredients : [],
|
extraIngredients: m.extraIngredients ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeMealRecipe(mr: components['schemas']['MealRecipe'] | null | undefined): MealRecipe {
|
function decodeMealRecipe(mr: components['schemas']['MealRecipe-Output'] | null | undefined): MealRecipe {
|
||||||
if (!mr) throw new Error('Invalid meal recipe payload')
|
if (!mr) throw new Error('Invalid meal recipe payload')
|
||||||
return {
|
return {
|
||||||
...mr,
|
...mr,
|
||||||
|
|
@ -133,6 +137,7 @@ export function toMealInput(meal: Meal): MealInput {
|
||||||
id: meal.id,
|
id: meal.id,
|
||||||
suggestedDate: meal.suggestedDate ? meal.suggestedDate.toISOString() : new Date().toISOString(),
|
suggestedDate: meal.suggestedDate ? meal.suggestedDate.toISOString() : new Date().toISOString(),
|
||||||
consumedDate: meal.consumedDate ? meal.consumedDate.toISOString() : null,
|
consumedDate: meal.consumedDate ? meal.consumedDate.toISOString() : null,
|
||||||
|
purchaseDate: meal.purchaseDate ? meal.purchaseDate.toISOString() : null,
|
||||||
chefs: meal.chefs,
|
chefs: meal.chefs,
|
||||||
cleanup: meal.cleanup,
|
cleanup: meal.cleanup,
|
||||||
consumers: meal.consumers,
|
consumers: meal.consumers,
|
||||||
|
|
@ -140,6 +145,7 @@ export function toMealInput(meal: Meal): MealInput {
|
||||||
mealId: r.mealId,
|
mealId: r.mealId,
|
||||||
recipeId: r.recipeId,
|
recipeId: r.recipeId,
|
||||||
servings: r.servings,
|
servings: r.servings,
|
||||||
|
recipe: null,
|
||||||
})),
|
})),
|
||||||
extraIngredients: meal.extraIngredients,
|
extraIngredients: meal.extraIngredients,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import type { components } from '@/api/types'
|
|
||||||
import type { MemberRef, User } from '@/domain/types'
|
|
||||||
|
|
||||||
export type HouseholdMember = components['schemas']['HouseholdMember']
|
|
||||||
|
|
||||||
export function toMemberRef(m: HouseholdMember): MemberRef {
|
|
||||||
return { id: m.id, displayName: m.displayName }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectMyMember(members: HouseholdMember[] | null | undefined, user: User | null): MemberRef | null {
|
|
||||||
const list: HouseholdMember[] = Array.isArray(members) ? members : []
|
|
||||||
if (list.length === 0) return null
|
|
||||||
if (list.length === 1) return toMemberRef(list[0]!)
|
|
||||||
|
|
||||||
if (user && typeof user.displayName === 'string' && user.displayName) {
|
|
||||||
const match = list.find((m) => m.displayName === user.displayName)
|
|
||||||
if (match) return toMemberRef(match)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
@ -18,17 +18,13 @@ export type RecipeOut = components['schemas']['RecipeOut']
|
||||||
export type MealOut = components['schemas']['MealOut']
|
export type MealOut = components['schemas']['MealOut']
|
||||||
export type Ingredient = components['schemas']['Ingredient']
|
export type Ingredient = components['schemas']['Ingredient']
|
||||||
export type Product = components['schemas']['Product']
|
export type Product = components['schemas']['Product']
|
||||||
export type User = components['schemas']['User']
|
export type Person = components['schemas']['Person']
|
||||||
export type Household = components['schemas']['HouseholdResponse']
|
export type RecipeInput = components['schemas']['Recipe-Input']
|
||||||
// Member reference used across meals and lookups
|
export type MealInput = components['schemas']['Meal-Input']
|
||||||
export type MemberRef = components['schemas']['MemberRef']
|
export type MealRecipeOut = components['schemas']['MealRecipe-Output']
|
||||||
// v2 no longer exposes Recipe-Input; use RecipeCreate at boundary when creating
|
|
||||||
export type MealInput = components['schemas']['MealIn']
|
|
||||||
export type MealRecipeOut = components['schemas']['MealRecipe']
|
|
||||||
|
|
||||||
// Domain shapes: only adjust where UI needs Dates
|
// Domain shapes: only adjust where UI needs Dates
|
||||||
// RecipeOut has no dateCreated/dateHidden in current schema; keep arrays normalized in decoder
|
export type Recipe = WithDates<RecipeOut, 'dateCreated' | 'dateHidden'>
|
||||||
export type Recipe = RecipeOut
|
|
||||||
|
|
||||||
// MealRecipe with decoded recipe dates
|
// MealRecipe with decoded recipe dates
|
||||||
export type MealRecipe = Replace<MealRecipeOut, { recipe: Recipe | null }>
|
export type MealRecipe = Replace<MealRecipeOut, { recipe: Recipe | null }>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { createRouter, createWebHashHistory, createMemoryHistory, Router, RouterView } from 'vue-router'
|
import { createRouter, createWebHashHistory, Router } from 'vue-router'
|
||||||
import { setHouseholdSlugProvider } from '@/api/client'
|
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
// Lazy-loaded route components
|
// Lazy-loaded route components
|
||||||
|
|
@ -11,90 +10,65 @@ const PurchasedShoppingListPage = () => import('@/components/shopping/PurchasedS
|
||||||
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
|
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
|
||||||
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
|
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
|
||||||
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
|
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
|
||||||
const CreateAccount = () => import('@/views/CreateAccount.vue')
|
|
||||||
const Welcome = () => import('@/views/Welcome.vue')
|
|
||||||
const InvitationAccept = () => import('@/views/InvitationAccept.vue')
|
|
||||||
const HouseholdSettings = () => import('@/views/HouseholdSettings.vue')
|
|
||||||
|
|
||||||
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
|
export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown): Router {
|
||||||
const publicRoutes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{ path: '/', redirect: { name: 'mealplan' } },
|
||||||
{ path: '/login', name: 'login', component: LoginPage },
|
{ path: '/login', name: 'login', component: LoginPage },
|
||||||
{ path: '/create-account', name: 'create-account', component: CreateAccount },
|
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
|
||||||
{ path: '/welcome', name: 'welcome', component: Welcome },
|
{
|
||||||
{ path: '/invitations/accept', name: 'invitation-accept', component: InvitationAccept },
|
path: '/shopping',
|
||||||
|
name: 'shopping',
|
||||||
|
component: MyShoppingPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/shopping/current',
|
||||||
|
name: 'shopping-current',
|
||||||
|
component: CurrentShoppingListPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/shopping/:id',
|
||||||
|
name: 'shopping-list',
|
||||||
|
component: PurchasedShoppingListPage,
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
|
||||||
|
{
|
||||||
|
path: '/recipes/add',
|
||||||
|
name: 'recipe-add',
|
||||||
|
component: EditRecipePage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/recipes/:id',
|
||||||
|
name: 'recipe-edit',
|
||||||
|
component: EditRecipePage,
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
|
||||||
|
{
|
||||||
|
path: '/meals/:id',
|
||||||
|
name: 'meal-edit',
|
||||||
|
component: EditMealPage,
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const featureChildren: RouteRecordRaw[] = [
|
|
||||||
{ path: 'recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
|
|
||||||
{ path: 'recipes/add', name: 'recipe-add', component: EditRecipePage, meta: { requiresAuth: true } },
|
|
||||||
{ path: 'recipes/:id', name: 'recipe-edit', component: EditRecipePage, props: true, meta: { requiresAuth: true } },
|
|
||||||
{ path: 'mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
|
|
||||||
{ path: 'meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
|
|
||||||
{ path: 'meals/:id', name: 'meal-edit', component: EditMealPage, props: true, meta: { requiresAuth: true } },
|
|
||||||
{ path: 'shopping', name: 'shopping', component: MyShoppingPage, meta: { requiresAuth: true } },
|
|
||||||
{ path: 'shopping/current', name: 'shopping-current', component: CurrentShoppingListPage, meta: { requiresAuth: true } },
|
|
||||||
{ path: 'shopping/:id', name: 'shopping-list', component: PurchasedShoppingListPage, props: true, meta: { requiresAuth: true } },
|
|
||||||
{ path: 'settings/members', name: 'household-settings', component: HouseholdSettings, meta: { requiresAuth: true } },
|
|
||||||
]
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = []
|
|
||||||
// Avoid runtime template compilation by using render functions/components
|
|
||||||
const Empty = { render: () => null }
|
|
||||||
routes.push({ path: '/', name: 'root', component: Empty, meta: { requiresAuth: true } })
|
|
||||||
routes.push(...publicRoutes)
|
|
||||||
routes.push({ path: '/:householdSlug', component: RouterView, children: featureChildren })
|
|
||||||
|
|
||||||
// Use hash history in real browsers; fallback to memory history in tests/SSR where `globalThis.location` may be unavailable
|
|
||||||
// Some test runners may polyfill `window` but not the global `location`, and vue-router's hash history uses the global.
|
|
||||||
// Avoid 'as' assertions; relying on typeof global 'location' is safe and non-throwing in Node
|
|
||||||
const hasLocation = typeof location !== 'undefined'
|
|
||||||
const history = hasLocation ? createWebHashHistory() : createMemoryHistory()
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history,
|
history: createWebHashHistory(),
|
||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
// Keep API client household slug in sync with the target route before components mount
|
|
||||||
const paramSlug = typeof to.params.householdSlug === 'string' ? to.params.householdSlug : null
|
|
||||||
setHouseholdSlugProvider(() => paramSlug)
|
|
||||||
|
|
||||||
// Allow public routes
|
|
||||||
const publicNames = new Set(['login', 'create-account', 'welcome', 'invitation-accept'])
|
|
||||||
if (publicNames.has(String(to.name))) return true
|
|
||||||
|
|
||||||
if (!to.meta.requiresAuth) return true
|
if (!to.meta.requiresAuth) return true
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) throw new Error('not-authenticated')
|
if (user) return true
|
||||||
|
|
||||||
// Household-scoped redirects
|
|
||||||
// Fetch households and redirect accordingly
|
|
||||||
const { useAuth } = await import('@/composables/useAuth')
|
|
||||||
const { fetchHouseholds, setActiveHousehold, households } = useAuth()
|
|
||||||
const hs = households.value.length > 0 ? households.value : await fetchHouseholds()
|
|
||||||
|
|
||||||
if (hs.length === 0) {
|
|
||||||
if (to.name !== 'welcome') return { name: 'welcome' }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no slug in route, redirect to first household's mealplan
|
|
||||||
const slug = typeof to.params.householdSlug === 'string' ? to.params.householdSlug : null
|
|
||||||
if (!slug) {
|
|
||||||
if (hs.length > 0) {
|
|
||||||
const first = hs[0]!
|
|
||||||
setActiveHousehold(first)
|
|
||||||
return { name: 'mealplan', params: { householdSlug: first.slug } }
|
|
||||||
}
|
|
||||||
// Fallback (should not reach here due to hs.length check)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Ensure active household is set when navigating to a household route
|
|
||||||
const found = hs.find((h) => h.slug === slug) ?? (hs.length > 0 ? hs[0] : null)
|
|
||||||
if (found) setActiveHousehold(found)
|
|
||||||
return true
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
|
||||||
import { getHouseholdSlug } from '@/api/client'
|
|
||||||
|
|
||||||
// Centralized builders for slug-scoped routes to keep navigation consistent
|
|
||||||
|
|
||||||
// When an options object is provided, require an explicit slug.
|
|
||||||
// Callers may also omit the argument entirely to infer from context.
|
|
||||||
type WithSlug = { slug: string }
|
|
||||||
|
|
||||||
function resolveSlug(input?: WithSlug): string {
|
|
||||||
if (input && typeof input.slug === 'string') return input.slug
|
|
||||||
return getHouseholdSlug() ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pages without ids
|
|
||||||
export function toMealPlan(slugOrOpts?: WithSlug): RouteLocationRaw {
|
|
||||||
const slug = resolveSlug(slugOrOpts)
|
|
||||||
return { name: 'mealplan', params: { householdSlug: slug } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toRecipes(slugOrOpts?: WithSlug): RouteLocationRaw {
|
|
||||||
const slug = resolveSlug(slugOrOpts)
|
|
||||||
return { name: 'recipes', params: { householdSlug: slug } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toRecipeAdd(slugOrOpts?: WithSlug): RouteLocationRaw {
|
|
||||||
const slug = resolveSlug(slugOrOpts)
|
|
||||||
return { name: 'recipe-add', params: { householdSlug: slug } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toMealAdd(slugOrOpts?: WithSlug): RouteLocationRaw {
|
|
||||||
const slug = resolveSlug(slugOrOpts)
|
|
||||||
return { name: 'meal-add', params: { householdSlug: slug } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toShoppingCurrent(slugOrOpts?: WithSlug): RouteLocationRaw {
|
|
||||||
const slug = resolveSlug(slugOrOpts)
|
|
||||||
return { name: 'shopping-current', params: { householdSlug: slug } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toHouseholdSettings(slugOrOpts?: WithSlug): RouteLocationRaw {
|
|
||||||
const slug = resolveSlug(slugOrOpts)
|
|
||||||
return { name: 'household-settings', params: { householdSlug: slug } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pages with ids
|
|
||||||
type WithId = { id: number | string }
|
|
||||||
type IdOrOpts = number | string | (WithId & WithSlug)
|
|
||||||
|
|
||||||
export function toRecipeEdit(idOrOpts: IdOrOpts): RouteLocationRaw {
|
|
||||||
const id = typeof idOrOpts === 'object' ? idOrOpts.id : idOrOpts
|
|
||||||
const slug = resolveSlug(typeof idOrOpts === 'object' ? idOrOpts : undefined)
|
|
||||||
return { name: 'recipe-edit', params: { householdSlug: slug, id } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toMealEdit(idOrOpts: IdOrOpts): RouteLocationRaw {
|
|
||||||
const id = typeof idOrOpts === 'object' ? idOrOpts.id : idOrOpts
|
|
||||||
const slug = resolveSlug(typeof idOrOpts === 'object' ? idOrOpts : undefined)
|
|
||||||
return { name: 'meal-edit', params: { householdSlug: slug, id } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toShoppingList(idOrOpts: IdOrOpts): RouteLocationRaw {
|
|
||||||
const id = typeof idOrOpts === 'object' ? idOrOpts.id : idOrOpts
|
|
||||||
const slug = resolveSlug(typeof idOrOpts === 'object' ? idOrOpts : undefined)
|
|
||||||
return { name: 'shopping-list', params: { householdSlug: slug, id } }
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="create-account">
|
|
||||||
<h1>Create Account</h1>
|
|
||||||
<form @submit.prevent="onSubmit">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="email">Email</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
v-model="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="displayName">Display Name</label>
|
|
||||||
<input
|
|
||||||
id="displayName"
|
|
||||||
v-model="displayName"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autocomplete="name"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="submitting"
|
|
||||||
>
|
|
||||||
Sign up
|
|
||||||
</button>
|
|
||||||
<router-link
|
|
||||||
class="btn btn-link"
|
|
||||||
:to="{ name: 'login' }"
|
|
||||||
>
|
|
||||||
Back to login
|
|
||||||
</router-link>
|
|
||||||
</form>
|
|
||||||
<p
|
|
||||||
v-if="error"
|
|
||||||
class="error"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { useAuth } from '@/composables/useAuth'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
const { createAccount, user } = useAuth()
|
|
||||||
|
|
||||||
const email = ref('')
|
|
||||||
const displayName = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const submitting = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
async function onSubmit() {
|
|
||||||
error.value = ''
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await createAccount(email.value.trim(), displayName.value.trim(), password.value)
|
|
||||||
if (user.value?.id !== undefined) {
|
|
||||||
const q = route.query?.redirect
|
|
||||||
const redirectPath = (typeof q === 'string' ? q : undefined) || '/'
|
|
||||||
router.push(redirectPath || '/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
error.value = 'Sign up failed'
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : 'An error occurred'
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color: #a00;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h1>Household Members</h1>
|
|
||||||
<section>
|
|
||||||
<h2>Invite a member</h2>
|
|
||||||
<p class="muted">Share an invite link with the person you want to add.</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:disabled="submitting"
|
|
||||||
@click="onCopyInviteLink"
|
|
||||||
>
|
|
||||||
Copy Invite Link
|
|
||||||
</button>
|
|
||||||
<p
|
|
||||||
v-if="message"
|
|
||||||
class="message"
|
|
||||||
>
|
|
||||||
{{ message }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="error"
|
|
||||||
class="error"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Current members</h2>
|
|
||||||
<ul v-if="members.length > 0">
|
|
||||||
<li
|
|
||||||
v-for="m in members"
|
|
||||||
:key="m.id"
|
|
||||||
>
|
|
||||||
{{ m.displayName }} <small>— role: {{ m.role }}</small>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p
|
|
||||||
v-else
|
|
||||||
class="muted"
|
|
||||||
>
|
|
||||||
No members to show yet.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import { createInviteLink } from '@/api/invitations'
|
|
||||||
import { listMembers, type Member } from '@/api/households'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useAlert } from '@/composables/useAlert'
|
|
||||||
|
|
||||||
const submitting = ref(false)
|
|
||||||
const message = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const members = ref<Member[]>([])
|
|
||||||
const route = useRoute()
|
|
||||||
const householdSlug = computed(() => (typeof route.params.householdSlug === 'string' ? route.params.householdSlug : null))
|
|
||||||
const { show: showAlert, scheduleAutoDismiss } = useAlert()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
members.value = await listMembers()
|
|
||||||
} catch (_e) {
|
|
||||||
// ignore; backend may not support this yet
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function onCopyInviteLink() {
|
|
||||||
message.value = ''
|
|
||||||
error.value = ''
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
const slug = householdSlug.value
|
|
||||||
if (!slug) throw new Error('No household selected')
|
|
||||||
const inviteLink = await createInviteLink(slug)
|
|
||||||
await navigator.clipboard.writeText(inviteLink)
|
|
||||||
|
|
||||||
showAlert({ type: 'success', heading: 'Success', message: 'Invite link copied to clipboard!' })
|
|
||||||
scheduleAutoDismiss(3000)
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : 'Failed to copy invite link'
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.muted { color: #666; }
|
|
||||||
.message { color: #0a0; }
|
|
||||||
.error { color: #a00; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h1>Accept Invitation</h1>
|
|
||||||
<p v-if="loading">
|
|
||||||
Processing your invitation token...
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-else-if="error"
|
|
||||||
class="error"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { acceptInvitation } from '@/api/invitations'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const token = typeof route.query.token === 'string' ? route.query.token : null
|
|
||||||
if (!token) {
|
|
||||||
error.value = 'Missing invitation token'
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const h = await acceptInvitation(token)
|
|
||||||
await router.push({ name: 'mealplan', params: { householdSlug: h.slug } })
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : 'Failed to accept invitation'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.error { color: #a00; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h1>Welcome</h1>
|
|
||||||
<section>
|
|
||||||
<h2>Create a new household</h2>
|
|
||||||
<form @submit.prevent="onCreate">
|
|
||||||
<label for="name">Household Name</label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
v-model="name"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<button type="submit">
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h2>Join an existing household</h2>
|
|
||||||
<p>To join a household, ask an existing member to share an invitation link with you.</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useAuth } from '@/composables/useAuth'
|
|
||||||
|
|
||||||
const name = ref('')
|
|
||||||
const router = useRouter()
|
|
||||||
const { createHousehold } = useAuth()
|
|
||||||
|
|
||||||
async function onCreate() {
|
|
||||||
const h = await createHousehold(name.value.trim())
|
|
||||||
if (h?.slug) {
|
|
||||||
router.push({ name: 'mealplan', params: { householdSlug: h.slug } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { logout, loginWithPassword, createAccount, handleGoogleLogin } from '@/api/auth'
|
|
||||||
import { api } from '@/api/client'
|
|
||||||
|
|
||||||
describe('auth api (jwt + households)', () => {
|
|
||||||
it('loginWithPassword posts email/password and returns user from token response', async () => {
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/auth/login', async ({ request }) => {
|
|
||||||
const body = await request.json()
|
|
||||||
expect(body).toEqual({ email: 'test@example.com', password: 'secret' })
|
|
||||||
return HttpResponse.json({ accessToken: 'abc123', tokenType: 'bearer', user: { id: 123, email: 'test@example.com', displayName: 'Test User' } })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const user = await loginWithPassword('test@example.com', 'secret')
|
|
||||||
expect(user.id).toBe(123)
|
|
||||||
expect(user.displayName).toBe('Test User')
|
|
||||||
|
|
||||||
// Subsequent API call should include Authorization header
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/users/me/households', ({ request }) => {
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
expect(auth?.toLowerCase()).toBe('bearer abc123')
|
|
||||||
return HttpResponse.json([])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const res = await api.GET('/api/v1/users/me/households', { params: {} })
|
|
||||||
expect(res.response.ok).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('createAccount posts to /auth/register and sets token', async () => {
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/auth/register', async ({ request }) => {
|
|
||||||
const body = await request.json()
|
|
||||||
expect(body).toEqual({ email: 'new@example.com', password: 'pw', displayName: 'New User' })
|
|
||||||
return HttpResponse.json({ accessToken: 'xyz789', tokenType: 'bearer', user: { id: 55, email: 'new@example.com', displayName: 'New User' } })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const user = await createAccount('new@example.com', 'New User', 'pw')
|
|
||||||
expect(user.id).toBe(55)
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/users/me/households', ({ request }) => {
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
expect(auth?.toLowerCase()).toBe('bearer xyz789')
|
|
||||||
return HttpResponse.json([])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const res = await api.GET('/api/v1/users/me/households', { params: {} })
|
|
||||||
expect(res.response.ok).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handleGoogleLogin exists (not implemented yet)', async () => {
|
|
||||||
await expect(handleGoogleLogin('token-123')).rejects.toBeInstanceOf(Error)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('logout clears auth token', async () => {
|
|
||||||
// Simulate logged-in state then logout and verify header is not sent
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/auth/login', () => HttpResponse.json({ accessToken: 'tok', tokenType: 'bearer', user: { id: 1, email: 'a@b', displayName: 'A' } }))
|
|
||||||
)
|
|
||||||
await loginWithPassword('a@b', 'pw')
|
|
||||||
await logout()
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/users/me/households', ({ request }) => {
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
expect(auth).toBeFalsy()
|
|
||||||
return HttpResponse.json([])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const res = await api.GET('/api/v1/users/me/households', { params: {} })
|
|
||||||
expect(res.response.ok).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { handleGoogleLogin, completeGoogleLogin } from '@/api/auth'
|
|
||||||
import { api } from '@/api/client'
|
|
||||||
|
|
||||||
describe('auth google oauth', () => {
|
|
||||||
it('handleGoogleLogin returns start URL', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/auth/google/start', () => {
|
|
||||||
return HttpResponse.json({ url: 'https://accounts.google.com/o/oauth2/v2/auth?x=y' })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const url = await handleGoogleLogin()
|
|
||||||
expect(url).toContain('https://accounts.google.com')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('completeGoogleLogin exchanges code for token and sets auth header', async () => {
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/auth/google/callback', async ({ request }) => {
|
|
||||||
const body = await request.json()
|
|
||||||
expect(body).toEqual({ code: 'abc', state: 'state-1' })
|
|
||||||
return HttpResponse.json({ accessToken: 'goog-123', tokenType: 'bearer', user: { id: 42, email: 'g@example.com', displayName: 'G User' } })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const user = await completeGoogleLogin('abc', 'state-1')
|
|
||||||
expect(user.id).toBe(42)
|
|
||||||
|
|
||||||
// subsequent requests include bearer
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/users/me/households', ({ request }) => {
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
expect(auth?.toLowerCase()).toBe('bearer goog-123')
|
|
||||||
return HttpResponse.json([])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const res = await api.GET('/api/v1/users/me/households', { params: {} })
|
|
||||||
expect(res.response.ok).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { currentUser, logout } from '@/api/auth'
|
|
||||||
import { api } from '@/api/client'
|
|
||||||
|
|
||||||
describe('auth refresh (currentUser)', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await logout()
|
|
||||||
})
|
|
||||||
it('currentUser refresh sets auth token (no user in response)', async () => {
|
|
||||||
// Register both refresh and households handlers before invoking currentUser
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/auth/refresh', () =>
|
|
||||||
HttpResponse.json({ accessToken: 'ref-123', tokenType: 'bearer', user: { id: 9, email: 'ref@example.com', displayName: 'Refreshed' } })
|
|
||||||
),
|
|
||||||
http.get('*/api/v1/users/me/households', ({ request }) => {
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
expect(auth?.toLowerCase()).toBe('bearer ref-123')
|
|
||||||
return HttpResponse.json([])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const user = await currentUser()
|
|
||||||
expect(user).not.toBeNull()
|
|
||||||
|
|
||||||
// Subsequent API call should also include Authorization header
|
|
||||||
const res = await api.GET('/api/v1/users/me/households', { params: {} })
|
|
||||||
expect(res.response.ok).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null if refresh fails', async () => {
|
|
||||||
server.use(http.post('*/api/v1/auth/refresh', () => HttpResponse.json({ message: 'not logged in' }, { status: 401 })))
|
|
||||||
const user = await currentUser()
|
|
||||||
expect(user).toBeNull()
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/users/me/households', ({ request }) => {
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
expect(auth).toBeFalsy()
|
|
||||||
return HttpResponse.json([])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const res = await api.GET('/api/v1/users/me/households', { params: {} })
|
|
||||||
expect(res.response.ok).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { api } from '@/api/client'
|
|
||||||
|
|
||||||
describe('API household path scoping', () => {
|
|
||||||
it('requires household slug in path params', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/households/the-smiths/recipes', () => HttpResponse.json({ items: [], total: 0 })),
|
|
||||||
)
|
|
||||||
const res = await api.GET('/api/v1/households/{householdSlug}/recipes', { params: { path: { householdSlug: 'the-smiths' }, query: {} } })
|
|
||||||
expect(res.response.ok).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { useAuth } from '@/composables/useAuth'
|
|
||||||
|
|
||||||
describe('useAuth createHousehold', () => {
|
|
||||||
it('creates a new household and sets activeHousehold', async () => {
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/households', async ({ request }) => {
|
|
||||||
const body = await request.json()
|
|
||||||
expect(body).toEqual({ name: 'The Smiths' })
|
|
||||||
return HttpResponse.json({ id: 10, name: 'The Smiths', slug: 'the-smiths' })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const { households, activeHousehold, createHousehold } = useAuth()
|
|
||||||
expect(households.value).toEqual([])
|
|
||||||
const h = await createHousehold('The Smiths')
|
|
||||||
expect(h.slug).toBe('the-smiths')
|
|
||||||
expect(activeHousehold.value?.slug).toBe('the-smiths')
|
|
||||||
expect(households.value.find((x) => x.slug === 'the-smiths')).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { loginWithPassword } from '@/api/auth'
|
|
||||||
import { setHouseholdSlugProvider } from '@/api/client'
|
|
||||||
import { listMembers } from '@/api/households'
|
|
||||||
|
|
||||||
describe('households api (list members)', () => {
|
|
||||||
it('GETs members with Authorization header and slug in path', async () => {
|
|
||||||
// Simulate login token
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/auth/login', () =>
|
|
||||||
HttpResponse.json({ accessToken: 'tokLM', tokenType: 'bearer', user: { id: 3, email: 'u@e', displayName: 'User' } })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await loginWithPassword('u@e', 'pw')
|
|
||||||
|
|
||||||
// Provide a household slug for header injection
|
|
||||||
setHouseholdSlugProvider(() => 'the-smiths')
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/households/the-smiths/members', ({ request }) => {
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
expect(auth?.toLowerCase()).toBe('bearer toklm')
|
|
||||||
return HttpResponse.json([
|
|
||||||
{ id: 10, email: 'a@example.com', displayName: 'Alice' },
|
|
||||||
{ id: 11, email: 'b@example.com', displayName: 'Bob' },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const members = await listMembers()
|
|
||||||
expect(members.length).toBe(2)
|
|
||||||
expect(members[0]?.displayName).toBe('Alice')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { useAuth } from '@/composables/useAuth'
|
|
||||||
|
|
||||||
describe('useAuth fetchHouseholds', () => {
|
|
||||||
it('loads households and stores them', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/users/me/households', () => {
|
|
||||||
return HttpResponse.json([
|
|
||||||
{ id: 1, name: 'Smiths', slug: 'the-smiths' },
|
|
||||||
{ id: 2, name: 'Johnsons', slug: 'the-johnsons' },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const { households, fetchHouseholds, setActiveHousehold, activeHousehold } = useAuth()
|
|
||||||
expect(households.value).toEqual([])
|
|
||||||
const hs = await fetchHouseholds()
|
|
||||||
expect(hs.length).toBe(2)
|
|
||||||
expect(households.value[0].slug).toBe('the-smiths')
|
|
||||||
setActiveHousehold(hs[1])
|
|
||||||
expect(activeHousehold.value?.slug).toBe('the-johnsons')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { loginWithPassword } from '@/api/auth'
|
|
||||||
import { acceptInvitation } from '@/api/invitations'
|
|
||||||
|
|
||||||
describe('invitations api', () => {
|
|
||||||
it('acceptInvitation posts token and returns household slug', async () => {
|
|
||||||
// simulate login so Authorization header is present
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/auth/login', () =>
|
|
||||||
HttpResponse.json({ accessToken: 'tok123', tokenType: 'bearer', user: { id: 9, email: 'u@e', displayName: 'U' } })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await loginWithPassword('u@e', 'pw')
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/invitations/accept', async ({ request }) => {
|
|
||||||
const body = await request.json()
|
|
||||||
expect(body).toEqual({ token: 'abc' })
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
expect(auth?.toLowerCase()).toBe('bearer tok123')
|
|
||||||
return HttpResponse.json({ household: { id: 1, name: 'Smiths', slug: 'the-smiths' } })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await acceptInvitation('abc')
|
|
||||||
expect(result.slug).toBe('the-smiths')
|
|
||||||
expect(result.name).toBe('Smiths')
|
|
||||||
expect(result.id).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { loginWithPassword } from '@/api/auth'
|
|
||||||
import { sendInvitation } from '@/api/invitations'
|
|
||||||
|
|
||||||
describe('invitations api (send invite)', () => {
|
|
||||||
it('posts email with Authorization and householdSlug path param', async () => {
|
|
||||||
// Simulate auth token
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/auth/login', () =>
|
|
||||||
HttpResponse.json({ accessToken: 'tokABC', tokenType: 'bearer', user: { id: 4, email: 'x@y', displayName: 'X' } })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await loginWithPassword('x@y', 'pw')
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.post('*/api/v1/households/the-smiths/invitations', async ({ request }) => {
|
|
||||||
const body = await request.json()
|
|
||||||
expect(body).toEqual({ email: 'invite@example.com' })
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
expect(auth?.toLowerCase()).toBe('bearer tokabc')
|
|
||||||
return HttpResponse.json({}, { status: 204 })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
await expect(sendInvitation('the-smiths', 'invite@example.com')).resolves.toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
27
tests/mealMapper.test.js
Normal file
27
tests/mealMapper.test.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { decodeMeal } from '@/domain/decoders'
|
||||||
|
|
||||||
|
describe('mealMapper', () => {
|
||||||
|
it('maps individual meal date fields to Date instances', () => {
|
||||||
|
const input = {
|
||||||
|
id: 1,
|
||||||
|
suggestedDate: '2025-01-01T00:00:00Z',
|
||||||
|
purchaseDate: '2025-01-02T00:00:00Z',
|
||||||
|
consumedDate: '2025-01-03T00:00:00Z',
|
||||||
|
}
|
||||||
|
const result = decodeMeal({ ...input })
|
||||||
|
expect(result.suggestedDate).toBeInstanceOf(Date)
|
||||||
|
expect(result.purchaseDate).toBeInstanceOf(Date)
|
||||||
|
expect(result.consumedDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps lists of meals', () => {
|
||||||
|
const input = [
|
||||||
|
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
|
||||||
|
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
|
||||||
|
]
|
||||||
|
const result = input.map((m) => decodeMeal(m)).filter((m) => m)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0].suggestedDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
})
|
||||||
14
tests/meals.api.errors.test.js
Normal file
14
tests/meals.api.errors.test.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getUpcomingMeals } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('meals api (errors)', () => {
|
||||||
|
it('propagates errors on invalid upcoming range', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/meals/upcoming', () => new HttpResponse(null, { status: 400 }))
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
getUpcomingMeals(new Date('2025-01-03T00:00:00Z'), new Date('2025-01-01T00:00:00Z'))
|
||||||
|
).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
43
tests/meals.api.test.js
Normal file
43
tests/meals.api.test.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getMeal, markMealConsumed, getUpcomingMeals } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('meals api (typed client)', () => {
|
||||||
|
it('gets a meal by id', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/meals/:id', ({ params }) => {
|
||||||
|
return HttpResponse.json({ id: params.id, suggestedDate: '2025-01-01T00:00:00Z' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const meal = await getMeal(10)
|
||||||
|
expect(meal.id).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a meal consumed', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('*/api/v1/meals/:id/consumed', () => {
|
||||||
|
return HttpResponse.json({ id: 10, consumedDate: '2025-01-02T00:00:00Z' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const meal = await markMealConsumed(10)
|
||||||
|
expect(meal.consumedDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists upcoming meals', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/meals/upcoming', ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (!url.searchParams.get('from') || !url.searchParams.get('to')) {
|
||||||
|
return new HttpResponse(null, { status: 400 })
|
||||||
|
}
|
||||||
|
return HttpResponse.json([
|
||||||
|
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
|
||||||
|
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const list = await getUpcomingMeals(new Date('2025-01-01T00:00:00Z'), new Date('2025-01-03T00:00:00Z'))
|
||||||
|
expect(Array.isArray(list)).toBe(true)
|
||||||
|
expect(list[0].suggestedDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
})
|
||||||
19
tests/parse.api.errors.test.js
Normal file
19
tests/parse.api.errors.test.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { parseIngredients, parseRecipe } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('parse api errors', () => {
|
||||||
|
it('returns 422 for invalid ingredient lines', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/ingredients/parse', () => new HttpResponse(null, { status: 422 }))
|
||||||
|
)
|
||||||
|
await expect(parseIngredients([''])).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 422 for invalid recipe URL', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/parse', () => new HttpResponse(null, { status: 422 }))
|
||||||
|
)
|
||||||
|
await expect(parseRecipe('not-a-url')).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
48
tests/parse.api.test.js
Normal file
48
tests/parse.api.test.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { parseIngredients, parseProduct, parseRecipe } from '@/api/sdk'
|
||||||
|
|
||||||
|
// Parse endpoints: ingredients, product, recipe
|
||||||
|
|
||||||
|
describe('parse api (typed client)', () => {
|
||||||
|
it('parses ingredient lines', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/ingredients/parse', () => {
|
||||||
|
return HttpResponse.json([
|
||||||
|
{ id: 1, name: 'Eggs', line: '2 eggs', unit: 'Items', quantity: 2 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const result = await parseIngredients(['2 eggs'])
|
||||||
|
expect(result[0].name).toBe('Eggs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses a recipe from URL', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/parse', () => {
|
||||||
|
return HttpResponse.json({ id: 10, name: 'Pancakes', ingredients: [] })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const recipe = await parseRecipe('https://example.com/pancakes')
|
||||||
|
expect(recipe?.name).toBe('Pancakes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses/creates a product from URL', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('*/api/v1/products', async ({ request }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
if (!body?.url) return new HttpResponse(null, { status: 422 })
|
||||||
|
return HttpResponse.json({
|
||||||
|
id: 99,
|
||||||
|
name: 'Sample Product',
|
||||||
|
link: body.url,
|
||||||
|
unit: 'Items',
|
||||||
|
imgSmall: '',
|
||||||
|
imgLarge: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const product = await parseProduct({ name: 'Eggs', line: '2 eggs' }, 'https://store/item')
|
||||||
|
expect(product?.name).toBe('Sample Product')
|
||||||
|
})
|
||||||
|
})
|
||||||
12
tests/persons.api.errors.test.js
Normal file
12
tests/persons.api.errors.test.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getPersonsInHome } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('persons api errors', () => {
|
||||||
|
it('propagates non-2xx errors', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/persons', () => new HttpResponse(null, { status: 422 }))
|
||||||
|
)
|
||||||
|
await expect(getPersonsInHome()).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
30
tests/persons.api.test.js
Normal file
30
tests/persons.api.test.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getPersonsInHome, searchPersons } from '@/api/sdk'
|
||||||
|
|
||||||
|
// Persons API tests
|
||||||
|
|
||||||
|
describe('persons api (typed client)', () => {
|
||||||
|
it('lists persons in home', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/persons', () => {
|
||||||
|
return HttpResponse.json([{ id: 1, name: 'Ada Lovelace' }])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const page = await getPersonsInHome()
|
||||||
|
expect(Array.isArray(page.items)).toBe(true)
|
||||||
|
expect(page.items[0].name).toBe('Ada Lovelace')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('searches persons by name', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/persons', ({ request }) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const q = url.searchParams.get('q')
|
||||||
|
return HttpResponse.json(q ? [{ id: 2, name: 'Alan Turing' }] : [])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const page = await searchPersons('alan')
|
||||||
|
expect(page.items[0].name.toLowerCase()).toContain('alan')
|
||||||
|
})
|
||||||
|
})
|
||||||
12
tests/recipes.api.errors.test.js
Normal file
12
tests/recipes.api.errors.test.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getRecipe } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('recipes api (errors)', () => {
|
||||||
|
it('throws on 404 getRecipe', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/:id', () => new HttpResponse(null, { status: 404 }))
|
||||||
|
)
|
||||||
|
await expect(getRecipe('999')).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
22
tests/recipes.api.test.js
Normal file
22
tests/recipes.api.test.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getRecipe } from '@/api/sdk'
|
||||||
|
|
||||||
|
// Tests validate the typed client wrapper behavior without needing the backend
|
||||||
|
|
||||||
|
describe('recipes api (typed client)', () => {
|
||||||
|
it('gets a recipe by id', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('*/api/v1/recipes/:id', ({ params }) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('MSW handler hit with params:', params)
|
||||||
|
const { id } = params
|
||||||
|
return HttpResponse.json({ id, name: 'Pancakes', ingredients: [] }, { status: 200 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = await getRecipe('123')
|
||||||
|
expect(data).toBeTruthy()
|
||||||
|
expect(data.name).toBe('Pancakes')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { server, http, HttpResponse } from './test-setup'
|
|
||||||
import { getRecipe } from '@/api/sdk'
|
|
||||||
import { setHouseholdSlugProvider } from '@/api/client'
|
|
||||||
|
|
||||||
// Drive migration to path-scoped recipes via householdSlug
|
|
||||||
|
|
||||||
describe('recipes api v2 (household-scoped)', () => {
|
|
||||||
it('gets a recipe by id with householdSlug path', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get('*/api/v1/households/:householdSlug/recipes/:recipe_id', ({ params }) => {
|
|
||||||
const { householdSlug, recipe_id } = params
|
|
||||||
expect(householdSlug).toBe('the-smiths')
|
|
||||||
return HttpResponse.json({ id: Number(recipe_id), name: 'Pancakes', ingredients: [] }, { status: 200 })
|
|
||||||
})
|
|
||||||
)
|
|
||||||
setHouseholdSlugProvider(() => 'the-smiths')
|
|
||||||
|
|
||||||
const data = await getRecipe('the-smiths', 123)
|
|
||||||
expect(data).toBeTruthy()
|
|
||||||
expect(data.name).toBe('Pancakes')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
describe('router guard refresh 401 handling', () => {
|
|
||||||
it('redirects to /login with redirect when getCurrentUser throws (e.g., refresh 401)', async () => {
|
|
||||||
const { createAppRouter } = await import('@/router/index')
|
|
||||||
const router = createAppRouter(async () => { throw new Error('401 Unauthorized') })
|
|
||||||
|
|
||||||
// Stub login route with inline component to avoid loading .vue files
|
|
||||||
try { router.removeRoute('login') } catch (_) { /* ignore */ }
|
|
||||||
router.addRoute({ path: '/login', name: 'login', component: { template: '<div />' } })
|
|
||||||
|
|
||||||
await router.push('/')
|
|
||||||
|
|
||||||
const current = router.currentRoute.value
|
|
||||||
expect(current.name).toBe('login')
|
|
||||||
expect(current.query.redirect).toBe('/')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
describe('router guard unauthenticated redirect', () => {
|
|
||||||
it('redirects to /login with redirect query when not authenticated', async () => {
|
|
||||||
const { createAppRouter } = await import('@/router/index')
|
|
||||||
const router = createAppRouter(async () => null)
|
|
||||||
|
|
||||||
// Replace the lazy .vue login route with an inline component to avoid plugin-vue in tests
|
|
||||||
try { router.removeRoute('login') } catch (_) { /* ignore */ }
|
|
||||||
router.addRoute({ path: '/login', name: 'login', component: { template: '<div />' } })
|
|
||||||
|
|
||||||
// Navigate to a protected route that uses an inline component to avoid lazy .vue imports
|
|
||||||
await router.push('/')
|
|
||||||
|
|
||||||
const current = router.currentRoute.value
|
|
||||||
expect(current.name).toBe('login')
|
|
||||||
expect(current.query.redirect).toBe('/')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
describe('router slugged routing', () => {
|
|
||||||
it('includes public routes and only slugged feature routes', async () => {
|
|
||||||
const { createAppRouter } = await import('@/router/index')
|
|
||||||
const router = createAppRouter(() => null)
|
|
||||||
const paths = router.getRoutes().map((r) => r.path)
|
|
||||||
expect(paths).toContain('/login')
|
|
||||||
expect(paths).toContain('/create-account')
|
|
||||||
expect(paths).toContain('/welcome')
|
|
||||||
expect(paths).toContain('/invitations/accept')
|
|
||||||
// Only slugged feature routes
|
|
||||||
expect(paths).toContain('/:householdSlug/recipes')
|
|
||||||
expect(paths).toContain('/:householdSlug/mealplan')
|
|
||||||
expect(paths).toContain('/:householdSlug/shopping')
|
|
||||||
expect(paths).toContain('/:householdSlug/shopping/current')
|
|
||||||
expect(paths).toContain('/:householdSlug/settings/members')
|
|
||||||
// No legacy flat routes
|
|
||||||
expect(paths).not.toContain('/recipes')
|
|
||||||
expect(paths).not.toContain('/shopping')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
describe('slug-scoped navigation', () => {
|
|
||||||
it('feature routes are nested under :householdSlug and links use named routes', async () => {
|
|
||||||
const { createAppRouter } = await import('@/router/index')
|
|
||||||
const router = createAppRouter(() => ({}))
|
|
||||||
|
|
||||||
const paths = router.getRoutes().map((r) => r.path)
|
|
||||||
expect(paths).toContain('/:householdSlug/recipes')
|
|
||||||
expect(paths).toContain('/:householdSlug/recipes/:id')
|
|
||||||
expect(paths).toContain('/:householdSlug/mealplan')
|
|
||||||
expect(paths).toContain('/:householdSlug/meals/add')
|
|
||||||
expect(paths).toContain('/:householdSlug/meals/:id')
|
|
||||||
expect(paths).toContain('/:householdSlug/shopping')
|
|
||||||
expect(paths).toContain('/:householdSlug/shopping/current')
|
|
||||||
expect(paths).toContain('/:householdSlug/shopping/:id')
|
|
||||||
|
|
||||||
// No flat feature routes
|
|
||||||
expect(paths).not.toContain('/recipes')
|
|
||||||
expect(paths).not.toContain('/meals/:id')
|
|
||||||
expect(paths).not.toContain('/shopping')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
12
tests/shopping.api.errors.test.js
Normal file
12
tests/shopping.api.errors.test.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { purchaseShoppingList } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('shopping api (errors)', () => {
|
||||||
|
it('propagates errors on purchase failure', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('*/api/v1/shopping', () => new HttpResponse(null, { status: 422 }))
|
||||||
|
)
|
||||||
|
await expect(purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])).rejects.toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
31
tests/shopping.api.test.js
Normal file
31
tests/shopping.api.test.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { server, http, HttpResponse } from './test-setup'
|
||||||
|
import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('shopping api (typed client)', () => {
|
||||||
|
it('gets current shopping list', async () => {
|
||||||
|
server.use(http.get('*/api/v1/shopping/current', () => HttpResponse.json({ outstandingItems: [] })))
|
||||||
|
const list = await getCurrentShoppingList()
|
||||||
|
expect(list).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gets a purchased shopping list by id', async () => {
|
||||||
|
server.use(http.get('*/api/v1/shopping/:id', () => HttpResponse.json({ list: { id: 99, items: [] } })))
|
||||||
|
const list = await getShoppingList(99)
|
||||||
|
expect(list.id).toBe(99)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requests and unrequests a meal', async () => {
|
||||||
|
server.use(http.post('*/api/v1/shopping/current/meals/me', () => HttpResponse.json([{ id: 1 }])))
|
||||||
|
await requestMeal(44)
|
||||||
|
|
||||||
|
server.use(http.delete('*/api/v1/shopping/current/meals/:id', () => new HttpResponse(null, { status: 204 })))
|
||||||
|
await unrequestMeal(44)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('purchases a list', async () => {
|
||||||
|
server.use(http.post('*/api/v1/shopping', () => HttpResponse.json({ list: { id: 1, items: [] } })))
|
||||||
|
const list = await purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])
|
||||||
|
expect(list.id).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -14,7 +14,7 @@ describe('shopping mappers boundary', () => {
|
||||||
],
|
],
|
||||||
purchasedItems: [],
|
purchasedItems: [],
|
||||||
}
|
}
|
||||||
const mapped = mapCurrentShoppingList(dto as unknown as Parameters<typeof mapCurrentShoppingList>[0])
|
const mapped = mapCurrentShoppingList(dto as any)
|
||||||
expect(mapped.outstandingItems[0].ingredient).toBeUndefined()
|
expect(mapped.outstandingItems[0].ingredient).toBeUndefined()
|
||||||
expect(mapped.outstandingItems[0].meal).toBeUndefined()
|
expect(mapped.outstandingItems[0].meal).toBeUndefined()
|
||||||
expect(mapped.outstandingItems[0].recipe).toBeUndefined()
|
expect(mapped.outstandingItems[0].recipe).toBeUndefined()
|
||||||
|
|
@ -37,7 +37,7 @@ describe('shopping mappers boundary', () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const mapped = mapPurchasedShoppingList(dto as unknown as Parameters<typeof mapPurchasedShoppingList>[0])
|
const mapped = mapPurchasedShoppingList(dto as any)
|
||||||
expect(mapped.list.createdDate).toBeInstanceOf(Date)
|
expect(mapped.list.createdDate).toBeInstanceOf(Date)
|
||||||
expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date)
|
expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date)
|
||||||
expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date)
|
expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date)
|
||||||
|
|
|
||||||
39
tests/shoppingListMapper.test.js
Normal file
39
tests/shoppingListMapper.test.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/sdk'
|
||||||
|
|
||||||
|
describe('shoppingListMapper', () => {
|
||||||
|
it('maps current shopping list and wires references', () => {
|
||||||
|
const dto = {
|
||||||
|
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
|
||||||
|
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
|
||||||
|
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
|
||||||
|
shoppingListLookup: { 7: { id: 7, createdDate: '2025-01-01T00:00:00Z' } },
|
||||||
|
outstandingItems: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 7, createdDate: '2025-01-01T00:00:00Z' }],
|
||||||
|
requestedMeals: [],
|
||||||
|
purchasedItems: [],
|
||||||
|
}
|
||||||
|
const mapped = mapCurrentShoppingList(dto)
|
||||||
|
// DEBUG
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('mapped current keys:', Object.keys(mapped || {}))
|
||||||
|
const item = mapped.outstandingItems[0]
|
||||||
|
expect(item.ingredient.name).toBe('Eggs')
|
||||||
|
expect(item.meal.suggestedDate).toBeInstanceOf(Date)
|
||||||
|
expect(mapped.shoppingListLookup['7'].createdDate).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps purchased shopping list with list dates and item refs', () => {
|
||||||
|
const dto = {
|
||||||
|
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
|
||||||
|
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
|
||||||
|
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
|
||||||
|
list: { id: 9, createdDate: '2025-02-02T00:00:00Z', items: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 9 }] },
|
||||||
|
}
|
||||||
|
const mapped = mapPurchasedShoppingList(dto)
|
||||||
|
// DEBUG
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('mapped purchased keys:', Object.keys(mapped || {}))
|
||||||
|
expect(mapped.list.createdDate).toBeInstanceOf(Date)
|
||||||
|
expect(mapped.list.items[0].recipe.name).toBe('Omelette')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/api/auth', () => {
|
|
||||||
return {
|
|
||||||
currentUser: vi.fn(async () => null),
|
|
||||||
loginWithPassword: vi.fn(async (email: string) => ({ id: 2, email, displayName: email })),
|
|
||||||
createAccount: vi.fn(async (email: string, displayName: string) => ({ id: 77, email, displayName })),
|
|
||||||
logout: vi.fn(async () => {}),
|
|
||||||
handleGoogleLogin: vi.fn(async () => {
|
|
||||||
throw new Error('handleGoogleLogin not implemented yet')
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useAuth.createAccount', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetModules()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates user after successful createAccount', async () => {
|
|
||||||
const { useAuth } = await import('@/composables/useAuth')
|
|
||||||
const auth = useAuth()
|
|
||||||
const { user } = auth
|
|
||||||
expect(user.value).toBeNull()
|
|
||||||
// Implemented method should exist
|
|
||||||
expect(typeof (auth as Record<string, unknown>).createAccount).toBe('function')
|
|
||||||
const newUser = await (auth as unknown as { createAccount: (e: string, d: string, p: string) => Promise<{ id: number; displayName: string }> }).createAccount('new@example.com', 'New User', 'pw')
|
|
||||||
expect(newUser.id).toBe(77)
|
|
||||||
expect(user.value?.displayName).toBe('New User')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/api/auth', () => {
|
|
||||||
return {
|
|
||||||
currentUser: vi.fn(async () => ({ id: 42, email: 'grace@example.com', displayName: 'Grace Hopper' })),
|
|
||||||
loginWithPassword: vi.fn(async (email: string) => ({ id: 8, email, displayName: email })),
|
|
||||||
logout: vi.fn(async () => {}),
|
|
||||||
createAccount: vi.fn(async () => {
|
|
||||||
throw new Error('createAccount not implemented yet')
|
|
||||||
}),
|
|
||||||
handleGoogleLogin: vi.fn(async () => {
|
|
||||||
throw new Error('handleGoogleLogin not implemented yet')
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
import { useAuth, loadUser } from '@/composables/useAuth'
|
|
||||||
|
|
||||||
describe('useAuth (multitenant state)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset modules and state between tests
|
|
||||||
vi.resetModules()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('exposes households and activeHousehold state with setters', async () => {
|
|
||||||
const { user, households, activeHousehold, setHouseholds, setActiveHousehold } = useAuth()
|
|
||||||
expect(user.value).toBeNull()
|
|
||||||
expect(households.value).toEqual([])
|
|
||||||
expect(activeHousehold.value).toBeNull()
|
|
||||||
|
|
||||||
const hs = [
|
|
||||||
{ id: 1, name: 'Smiths', slug: 'the-smiths' },
|
|
||||||
{ id: 2, name: 'Johnsons', slug: 'the-johnsons' },
|
|
||||||
]
|
|
||||||
setHouseholds(hs)
|
|
||||||
expect(households.value.length).toBe(2)
|
|
||||||
setActiveHousehold(hs[1])
|
|
||||||
expect(activeHousehold.value?.slug).toBe('the-johnsons')
|
|
||||||
setActiveHousehold('the-smiths')
|
|
||||||
expect(activeHousehold.value?.slug).toBe('the-smiths')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('supports loading current user, loginWithPassword, and logout clearing user', async () => {
|
|
||||||
const { user, loginWithPassword, logout } = useAuth()
|
|
||||||
// load current user
|
|
||||||
await loadUser()
|
|
||||||
expect(user.value?.displayName).toBe('Grace Hopper')
|
|
||||||
// login with email/password
|
|
||||||
await loginWithPassword('user@example.com', 'pw')
|
|
||||||
expect(user.value?.displayName).toBe('user@example.com')
|
|
||||||
// logout clears user
|
|
||||||
await logout()
|
|
||||||
expect(user.value).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
http://127.0.0.1:8000/
|
|
||||||
Server is running with watch enabled
|
|
||||||
|
|
||||||
# Dummy login
|
|
||||||
First account created. No specfic persona details.
|
|
||||||
- Email: specuser+20251102@example.com
|
|
||||||
- Display Name: Spec User
|
|
||||||
- Password: SpecPassw0rd!
|
|
||||||
- Household slug: faulconfridge-qa2
|
|
||||||
|
|
||||||
# Baker
|
|
||||||
- Email: patty.cake+20251102@example.com
|
|
||||||
- Display Name: Patty Cake
|
|
||||||
- Password: a-very-secure-password
|
|
||||||
- Household slug: the-rolling-scones
|
|
||||||
```
|
|
||||||
Loading…
Reference in a new issue